Skip to content

Local ZATCA validation

The zatca-sdk-238-R3.4.8 profile implements all 257 assertion sites in the pinned SDK stylesheets: 105 CEN sites and 152 Saudi sites. Business rules run as native Rust predicates over the original XML. XSD validation and XML canonicalization use libxml2. Runtime validation needs neither Java nor the SDK; reference captures use the unmodified official SDK CLI, with no direct Saxon integration.

The existing validate_xml_invoice_from_str and validate_xml_invoice_report_from_str APIs still check XSD only. Use validate_zatca_invoice_from_str for the full local pipeline.

Coverage and outcomes

Every report has these six stages:

Stage Checks Completion requirement
xsd Bundled UBL 2.1 schema completed
cen 105 assertion sites from the SDK CEN stylesheet completed
ksa 152 assertion sites from the SDK Saudi stylesheet completed
signature Invoice and signed-properties digests, signing key and constrained signature structure completed, or not_applicable for standard documents
qr TLV structure and values bound to the invoice and signing certificate completed, or not_applicable for standard documents
previous_invoice_hash PIH encoding and equality with caller-supplied predecessor hash completed

completed means execution finished; a completed stage can contain error findings. The other statuses are not_run, context_required, and evaluation_failed. Schema rejection stops dependent stages. A signature rejection leaves QR validation unrun because its verified inputs are unavailable.

ZatcaValidationReport::is_complete() requires exactly one entry for each stage with the status shown above. has_errors() includes findings from partial stages. is_valid() requires completion without error findings; warnings are allowed. JSON reports include all three derived booleans. Deserializing a report recomputes them from its stages, so supplied booleans cannot establish coverage.

Rule findings retain the assertion site, source severity and original message. Locations use namespace-independent XPath expressions with sibling positions. CEN and KSA stages also identify the source stylesheet, its SHA-256 and the assertion sites evaluated. validation_report() aggregates findings into the shared report type, retaining partial findings. It marks BusinessRules checked only when both profiles completed.

ZatcaValidationOptions::evaluated_at supplies a reproducible instant and implicit timezone. Without it, the validator captures the UTC clock once. For chain continuity, set previous_invoice_hash to the predecessor hash saved in trusted invoice history. The value must be canonical base64 containing a 32-byte digest or the SDK's 64-byte hexadecimal digest representation. The comparison is exact. Omitting it leaves the PIH stage context_required, even when the embedded PIH is well formed. Options JSON rejects unknown fields.

Schema or rule rejections return Ok(report). Execution failures return ZatcaValidationError with the partial report, failure kind and available stage/assertion/location metadata. See Errors for the binding representation.

Call the validator

Rust:

use fatoora_core::config::Config;
use fatoora_core::invoice::validation::{
    ZatcaValidationOptions, validate_zatca_invoice_from_str,
};

fn validate(xml: &str, previous_hash: String) -> Result<bool, Box<dyn std::error::Error>> {
    let options = ZatcaValidationOptions {
        evaluated_at: Some("2026-09-23T12:00:00+03:00".parse()?),
        previous_invoice_hash: Some(previous_hash),
    };
    let report = validate_zatca_invoice_from_str(xml, &Config::default(), &options)?;
    eprintln!("{}", serde_json::to_string_pretty(&report)?);
    Ok(report.is_valid())
}

CLI:

fatoora-rs-cli validate --invoice invoice.xml --profile zatca --format json \
  --evaluated-at '2026-09-23T12:00:00+03:00' \
  --previous-invoice-hash "$PREVIOUS_INVOICE_HASH"

The ZATCA profile returns exit 0 for valid input, 2 for rejection, 3 for an execution failure, and 4 for incomplete coverage without errors. Rejection takes precedence over incomplete coverage. JSON stdout contains one report or execution error; CLI file/context failures produce a diagnostic object without an invented report. Ordinary argument usage errors retain clap's stderr diagnostics and exit 2. Text output includes profile, stage status and finding locations. The default --profile xsd --format text preserves the existing OK output and exit 1 on failure. ZATCA context flags are rejected with the XSD profile.

Python:

from fatoora import Config, Environment, validate_zatca_invoice_from_str
from fatoora.errors import FatooraError

with Config(Environment.NON_PRODUCTION) as config:
    try:
        report = validate_zatca_invoice_from_str(
            config, xml,
            previous_invoice_hash=previous_hash,
            evaluated_at="2026-09-23T12:00:00+03:00",
        )
        print(report["is_valid"], report["stages"])
    except FatooraError as error:
        print(error.details.get("report"))

Python returns a dictionary for rejected or incomplete validation. Execution failures raise a binding exception. Raw XML and string options reject embedded NULs before C conversion.

C callers pass a live configuration handle and UTF-8 strings:

/* config, xml and options_json are supplied by the caller. */
struct FfiResult_FfiString result =
    fatoora_validate_zatca_invoice_from_str(config, xml, options_json);
if (result.ok) {
    /* Parse result.value.ptr and inspect is_valid before accepting the invoice. */
    fatoora_string_free(result.value);
} else {
    struct FfiString details = fatoora_error_details_json(result.error);
    /* details.ptr includes the partial report for pipeline execution failures. */
    fatoora_string_free(details);
    fatoora_error_free(result.error);
}

options_json may be null for defaults. Otherwise, pass an object with optional evaluated_at and previous_invoice_hash fields. result.ok means the call returned a report; inspect that report's is_valid separately. Each returned string is owned and must be freed once.

Integrity scope and recorded policies

Standard invoices, credit notes and debit notes mark signature and QR stages not_applicable, following the pinned SDK profile. Simplified documents run both checks. Local signature verification establishes content and key integrity. Issuer trust, certificate revocation and remote ZATCA acceptance require separate verification. Caller-owned history supplies PIH continuity.

The SDK signs the invoice digest bytes with ECDSA-SHA256, applying a second SHA-256. It does not sign a generic XMLDSig SignedInfo preimage. This validator checks the declared invoice and SignedProperties digests separately, plus certificate digest, issuer and serial linkage. A recomputed SignedProperties digest cannot authenticate a changed SigningTime; that timestamp is not an authenticated signing-time claim under this SDK contract.

These policy IDs describe deliberate compatibility and integrity decisions; they are separate from finding codes such as QR_TIMESTAMP or QR_VAT.

Policy Behavior
SDK-QR-001 This validator follows the SDK's tag 4 comparison against PayableAmount (BT-115). The library's QR generator uses TaxInclusiveAmount; payable rounding can make its generated QR fail this SDK profile.
SDK-QR-002 Tag 3 must contain a complete RFC3339 timestamp; a missing offset is interpreted as UTC. Comparison uses instants. Native validation rejects inconsistent offsets, a different day and malformed suffixes accepted by the recorded SDK CLI runs.
SDK-QR-003 Tag 5 uses exact XML-decimal equality. Native validation rejects NaN, exponent syntax and distinct values that collapse to the same binary float. Captures also show the SDK accepting 16.0 against invoice VAT 15.00; native validation rejects that mismatch.
LOCAL-INTEGRITY-001 Signature algorithms, references and structure are restricted to the supported profile. Duplicate IDs, unbound references and malformed TLV data are rejected. This is a constrained invoice verifier; arbitrary XMLDSig transforms are unsupported.

The integrity corpus contains 40 official-SDK reference cases, including six signed document variants and mutations of signature, QR and PIH fields. Each case records its native expectation and any applicable policy ID. Differences stay visible in the evidence instead of changing the SDK logs or expected output.

Source inventory and offline evidence

The SDK CEN stylesheet contains 103 distinct rule IDs across 105 assertion sites; the Saudi stylesheet contains 142 IDs across 152 sites. Rule prefixes do not identify their source file: the CEN profile includes Saudi rules. Coverage keys combine source, ordinal and rule ID, preserving repeated IDs.

The files under fatoora-core/tests/fixtures/business-rules/ include:

  • catalog.json and catalog.sha256: source metadata and ordered executable template trees. Empty templates are retained because they can suppress later rules. Executable guards are distinct from printed diagnostic expressions.
  • coverage.json: all 257 sites marked implemented, with implementation and test paths. Rust tests require this set to match the evaluator metadata exactly.
  • observations.json: structured findings linked by hashes to the original SDK-parity logs. Those original logs do not establish an implicit timezone.
  • Sixteen mutation families containing 408 business-rule cases:
Family Cases Family Cases
mutations 24 identity 46
structural 57 totals 18
ksa-fields 22 ksa-buyer 34
ksa-common 21 vat 22
ksa-adjustments 21 ksa-exemptions 24
ksa-currency 26 ksa-dates 21
ksa-date-casts 10 ksa-prepayment 28
ksa-arithmetic 28 ksa-arithmetic-guards 6

Each mutation retains its XML, raw SDK logs and process status. Manifests include expected findings, independently specified target outcomes, execution intervals, UTC configuration and resource hashes. Copies of the capture scripts accompany the evidence. integrity/ stores the separate 40-case integrity corpus.

python3 scripts/business_rules.py check
python3 scripts/integrity_validation.py check
python3 -m unittest discover -s scripts/tests -v
cargo test -p fatoora-core --locked --offline --lib invoice::validation

Offline checks do not invoke Java or load an installed SDK. They verify fixture integrity and compare native behavior with recorded evidence. Unit regressions also cover branch boundaries, duplicate operands and per-node execution.

The SDK may log warning sections at ERROR level and may exit 0 after rejection. The parser therefore reads section severity and structured stage outcomes. Unavailable stages remain not_run; transform failures are execution failures. The SDK CLI coalesces some findings: two empty item names yield one SDK BR-25 warning but two native occurrences with separate locations. Comparisons use the SDK-visible projection, while native tests retain occurrence counts and locations.

Native semantics and limits

The evaluator preserves namespaces and repeated XML elements. Numeric lexical checks use the original text. Its private ExactDecimal supports XML decimal syntax and XPath rounding within a digit budget. The existing invoice Decimal keeps its 96-bit coefficient and invoice rounding contract. Explicit double operations and format-number have separate tests; these differences affect monetary boundaries and prepayment arithmetic.

The business-rule limits are fixed for the public profile:

Resource Limit
XML input 8 MiB
Element nodes 100,000
Element depth 128
Retained XML data 64 MiB
Decimal digits 4,096
Findings 10,000
Finding data 8 MiB

Regex evaluation also bounds input/pattern size, compiled size and backtracking. At most 128 distinct nonliteral input/pattern pairs are cached per invoice. Exceeding an evaluation limit returns an execution failure with partial findings. DTD declarations in invoice input are rejected. Bundled XSD bytes are embedded and compiled from a private temporary directory, so installed binaries and wheels do not depend on the build checkout. The trusted XMLDSig schema retains its internal DTD.

Capture reference evidence

Define mutations and target expectations before invoking the official SDK:

python3 scripts/business_rules.py inventory \
  --sdk-root "$FATOORA_HOME" --output /tmp/business-rules-inventory
python3 scripts/business_rules.py capture --family ksa-prepayment \
  --sdk-root "$FATOORA_HOME" --output /tmp/business-rules-candidate
python3 scripts/integrity_validation.py capture \
  --sdk-root "$FATOORA_HOME" --output /tmp/integrity-candidate

Capture directories must not already exist. The tools verify source pins, use an isolated SDK copy and preserve raw output. Captures verify that the installed SDK remains unchanged. Review mismatches against executable source and SDK logs before replacing a corpus. Keep the coverage ledger and imported license notices when refreshing source inventories.