writing/tutorial/2026/08
TutorialAug 9, 2026·30 min read

Building a Saber Pre-Submission Catalogue Validator in TypeScript

Saber has no bulk submission API, so the only place to prevent certificate rejections is your own catalogue. Build a TypeScript validator that checks HS codes, technical regulation coverage, certificate expiry and shipment-to-product consistency before anyone opens the portal.

Every guide to Saber describes the same journey: log into the platform, register your facility, pick a conformity assessment body, fill in the product data, pay, receive a certificate. All of them are written for someone clicking through a portal one product at a time.

None of them are written for the person holding a catalogue of four thousand SKUs, exporting to Saudi Arabia every month, watching a predictable percentage of shipment certificate applications come back rejected — and having no idea which of the four thousand will fail until the container is already at Jeddah Islamic Port with demurrage accruing.

That gap exists because of a structural fact we covered in why Saudi shipments get held at port: there is no bulk Saber submission API. You cannot programmatically push four thousand products at the platform and get four thousand verdicts back. Saber is a human-operated portal by design.

That constraint is not a dead end. It is the entire specification for what you should build instead. If you cannot ask the platform whether your data will be accepted, you build the thing that answers that question locally — before submission, on your own catalogue, in bulk.

This tutorial builds that thing.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ and npm installed
  • TypeScript fundamentals — interfaces, generics, discriminated unions
  • Working knowledge of Zod or a similar schema validator (we will use Zod v4)
  • Access to your own product catalogue in some structured form (CSV, database export, ERP extract)
  • Familiarity with what a Product Certificate of Conformity (PCoC) and a Shipment Certificate of Conformity (SCoC) are — the blog post above covers this if you need it

You do not need Saber platform credentials to follow along. That is the point: everything here runs against your own data plus public SASO reference data.

What You'll Build

A command-line validator that takes a product catalogue and produces a triaged rejection report. Concretely, it will:

  1. Normalise messy catalogue rows into a canonical product record
  2. Validate HS codes against the 12-digit Saudi tariff structure — not the 6-digit international one
  3. Resolve technical regulations for each HS code from a local SASO reference table, so you know which products need conformity assessment at all
  4. Check certificate coverage and expiry with a configurable lead time, so a certificate expiring mid-transit is flagged before shipping
  5. Reconcile shipment lines against registered products, which is where the majority of SCoC rejections actually originate
  6. Emit a prioritised report grouped by fixability, not by row number

The output is a list your compliance team works through in an afternoon, instead of a rejection they discover six weeks later at a port.

Here is the architecture we are heading toward:

catalogue.csv ──► normalise ──► ProductRecord[]
                                     │
                    ┌────────────────┼────────────────┐
                    ▼                ▼                ▼
              hs-code rules   regulation map   certificate ledger
                    │                │                │
                    └────────────────┼────────────────┘
                                     ▼
                            ValidationIssue[]
                                     ▼
                          triaged rejection report

Step 1: Project Setup

Create the project and install dependencies.

mkdir saber-validator && cd saber-validator
npm init -y
npm install zod csv-parse date-fns
npm install -D typescript tsx @types/node vitest
npx tsc --init

Set up tsconfig.json for a modern Node target:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}

Add "type": "module" to package.json and a few scripts:

{
  "type": "module",
  "scripts": {
    "validate": "tsx src/cli.ts",
    "test": "vitest run"
  }
}

Create the directory structure:

mkdir -p src/{rules,data,report} tests

A note on noUncheckedIndexedAccess. It is on deliberately. This codebase does a lot of lookup-by-key against reference tables, and the difference between "this HS code has no regulation" and "this HS code maps to undefined because of a typo" is precisely the class of bug that ships a bad catalogue. Let the compiler force you to handle the miss.

Step 2: Model the Canonical Product Record

Everything downstream depends on one well-defined shape. Real catalogues arrive as spreadsheets with inconsistent column names, mixed Arabic and English, trailing whitespace, and HS codes stored as numbers with the leading zero eaten by Excel.

Define the canonical record first, then write adapters into it.

// src/types.ts
import { z } from "zod";
 
export const ProductRecordSchema = z.object({
  /** Your internal SKU — the join key for everything */
  sku: z.string().min(1),
 
  /** Product name as it will appear on the certificate and invoice */
  nameEn: z.string().min(1),
  nameAr: z.string().optional(),
 
  /** Brand and model must match the physical goods and the invoice */
  brand: z.string().min(1),
  model: z.string().min(1),
 
  /** Saudi 12-digit tariff code, stored as a string to preserve leading zeros */
  hsCode: z.string(),
 
  /** Country of manufacture, ISO 3166-1 alpha-2 */
  countryOfOrigin: z.string().length(2),
 
  /** Manufacturer legal name, as printed on the test report */
  manufacturer: z.string().min(1),
 
  /** PCoC reference, if one has already been issued */
  pcocNumber: z.string().optional(),
  pcocExpiry: z.coerce.date().optional(),
});
 
export type ProductRecord = z.infer<typeof ProductRecordSchema>;

Now the issue type. This is the most important design decision in the project, so it is worth dwelling on.

// src/types.ts (continued)
 
export type Severity = "blocker" | "warning" | "info";
 
/**
 * Fixability drives the report grouping. A compliance officer does not want
 * issues sorted by row number — they want to know what they can fix today
 * versus what requires a new test report and six weeks of lead time.
 */
export type Fixability =
  | "data-entry"        // fix in your own system, minutes
  | "documentation"     // request a document from the supplier, days
  | "certification";    // new conformity assessment, weeks
 
export interface ValidationIssue {
  sku: string;
  code: string;
  severity: Severity;
  fixability: Fixability;
  message: string;
  /** What the value was, so the report is actionable without opening the source file */
  observed?: string;
  /** What it should look like */
  expected?: string;
}

Grouping by fixability rather than by severity is the thing that makes this tool get used rather than get generated once and ignored. Two blockers are not equivalent if one is a typo and the other is a missing test report.

Step 3: Validate the 12-Digit Tariff Code

Here is the rule most catalogues get wrong. The international Harmonized System code is six digits. Saudi Arabia — and the unified GCC tariff — extends it to twelve. Saber resolves technical regulations at the full 12-digit level, so a catalogue carrying 6- or 8-digit codes is not merely imprecise, it is unresolvable.

The structure decomposes like this:

DigitsMeaning
1–2Chapter
3–4Heading
5–6Subheading (end of the international HS code)
7–8GCC unified tariff subdivision
9–12National statistical subdivision

Write the validator:

// src/rules/hs-code.ts
import type { ProductRecord, ValidationIssue } from "../types.js";
 
const DIGITS_ONLY = /^\d+$/;
 
export function validateHsCode(product: ProductRecord): ValidationIssue[] {
  const issues: ValidationIssue[] = [];
  const raw = product.hsCode.trim();
 
  // Excel is the enemy here. Codes arrive as "8516.60.00" or "851660"
  // or as a float that lost its leading zero.
  const normalised = raw.replace(/[.\s-]/g, "");
 
  if (!DIGITS_ONLY.test(normalised)) {
    issues.push({
      sku: product.sku,
      code: "HS_NON_NUMERIC",
      severity: "blocker",
      fixability: "data-entry",
      message: "HS code contains non-numeric characters after normalisation.",
      observed: raw,
      expected: "12 digits, e.g. 851660100000",
    });
    return issues; // No point running length checks on garbage
  }
 
  if (normalised.length === 12) {
    return issues; // Correct
  }
 
  if (normalised.length < 12) {
    // The common case: a 6- or 8-digit international code was imported
    // and nobody extended it to the Saudi national level.
    issues.push({
      sku: product.sku,
      code: "HS_TOO_SHORT",
      severity: "blocker",
      fixability: "data-entry",
      message:
        `HS code has ${normalised.length} digits. Saber resolves technical ` +
        "regulations at 12 digits; a shorter code cannot be matched to a regulation.",
      observed: normalised,
      expected: `${normalised.padEnd(12, "0")} (verify — do not pad blindly)`,
    });
  } else {
    issues.push({
      sku: product.sku,
      code: "HS_TOO_LONG",
      severity: "blocker",
      fixability: "data-entry",
      message: `HS code has ${normalised.length} digits, expected 12.`,
      observed: normalised,
    });
  }
 
  return issues;
}

Do not auto-pad with zeros. The expected field above suggests a padded value as a hint for a human, and the message says so. The national subdivision digits carry meaning — padding an 8-digit code to 12 with zeros can silently point at a different product class with different regulatory requirements. The validator's job is to surface the gap, not to guess its way past it.

Step 4: Resolve Technical Regulations

An HS code being well-formed says nothing about whether the product needs a certificate. That depends on which Saudi technical regulation covers the code.

SASO publishes this mapping. The searchable HS code list lives at saber.sa/home/hscodes, and SASO exposes Open Data APIs for its published datasets. Build a local reference table from these sources, refreshed on a schedule — never at validation time.

// src/data/regulations.ts
export interface RegulationEntry {
  /** 12-digit code, or a prefix for range matching */
  hsPrefix: string;
  regulationCode: string;
  regulationNameEn: string;
  /** Does this regulation require a PCoC before an SCoC can issue? */
  requiresPcoc: boolean;
  /** High-risk categories attract additional scrutiny and longer lead times */
  riskLevel: "low" | "medium" | "high";
}
 
/**
 * Illustrative subset. Populate the real table from SASO's published
 * data and version it — regulations change, and you want to know which
 * ruleset a past validation ran against.
 */
export const REGULATIONS: RegulationEntry[] = [
  {
    hsPrefix: "8516",
    regulationCode: "SASO-TR-ELEC-01",
    regulationNameEn: "Technical Regulation for Low Voltage Electrical Equipment",
    requiresPcoc: true,
    riskLevel: "high",
  },
  {
    hsPrefix: "9503",
    regulationCode: "SASO-TR-TOYS-01",
    regulationNameEn: "Technical Regulation for Toys",
    requiresPcoc: true,
    riskLevel: "high",
  },
  {
    hsPrefix: "6109",
    regulationCode: "SASO-TR-TEX-01",
    regulationNameEn: "Technical Regulation for Textile Products",
    requiresPcoc: true,
    riskLevel: "medium",
  },
];

Longest-prefix matching is the right lookup strategy, because regulations are defined at varying levels of specificity — a rule may cover an entire chapter, or one 12-digit line.

// src/rules/regulation.ts
import { REGULATIONS, type RegulationEntry } from "../data/regulations.js";
import type { ProductRecord, ValidationIssue } from "../types.js";
 
export function resolveRegulation(hsCode: string): RegulationEntry | undefined {
  const normalised = hsCode.replace(/[.\s-]/g, "");
 
  // Longest prefix wins: a 12-digit specific rule beats a 4-digit chapter rule.
  let best: RegulationEntry | undefined;
  for (const entry of REGULATIONS) {
    if (!normalised.startsWith(entry.hsPrefix)) continue;
    if (!best || entry.hsPrefix.length > best.hsPrefix.length) {
      best = entry;
    }
  }
  return best;
}
 
export function validateRegulationCoverage(
  product: ProductRecord,
): ValidationIssue[] {
  const issues: ValidationIssue[] = [];
  const regulation = resolveRegulation(product.hsCode);
 
  if (!regulation) {
    // Genuinely unregulated products exist. But an unmatched code is far more
    // often a wrong code than a genuinely exempt product — so warn, do not pass.
    issues.push({
      sku: product.sku,
      code: "REG_UNMATCHED",
      severity: "warning",
      fixability: "data-entry",
      message:
        "No technical regulation matched this HS code. Confirm the product is " +
        "genuinely exempt rather than mis-classified before shipping.",
      observed: product.hsCode,
    });
    return issues;
  }
 
  if (regulation.requiresPcoc && !product.pcocNumber) {
    issues.push({
      sku: product.sku,
      code: "PCOC_MISSING",
      severity: "blocker",
      // This is the expensive one: it needs a conformity assessment body,
      // test reports, and weeks of calendar time.
      fixability: "certification",
      message:
        `${regulation.regulationNameEn} (${regulation.regulationCode}) requires ` +
        "a Product Certificate of Conformity. No PCoC is recorded for this SKU.",
    });
  }
 
  if (regulation.riskLevel === "high") {
    issues.push({
      sku: product.sku,
      code: "REG_HIGH_RISK",
      severity: "info",
      fixability: "documentation",
      message:
        `Covered by a high-risk regulation (${regulation.regulationCode}). ` +
        "Expect additional scrutiny and longer assessment lead times.",
    });
  }
 
  return issues;
}

Step 5: Certificate Expiry With Transit Lead Time

A PCoC that is valid today and expires in eighteen days is a problem if your sea freight takes twenty-eight. The naive check — is the expiry date later than today — passes it, and the shipment certificate application fails after the goods have sailed.

Validate against the date the certificate must still be valid, not against today.

// src/rules/certificate.ts
import { addDays, differenceInDays, isBefore } from "date-fns";
import type { ProductRecord, ValidationIssue } from "../types.js";
 
export interface ExpiryOptions {
  /** Days from validation until the goods are expected to clear customs */
  transitLeadDays: number;
  /** Extra buffer for the SCoC application itself */
  bufferDays: number;
  /** Injected so tests are deterministic */
  now?: Date;
}
 
export function validateCertificateValidity(
  product: ProductRecord,
  options: ExpiryOptions,
): ValidationIssue[] {
  const issues: ValidationIssue[] = [];
  if (!product.pcocNumber) return issues; // Handled by regulation coverage
 
  const now = options.now ?? new Date();
  const requiredValidUntil = addDays(
    now,
    options.transitLeadDays + options.bufferDays,
  );
 
  if (!product.pcocExpiry) {
    issues.push({
      sku: product.sku,
      code: "PCOC_NO_EXPIRY",
      severity: "blocker",
      fixability: "documentation",
      message:
        `PCoC ${product.pcocNumber} is recorded with no expiry date. ` +
        "Validity cannot be confirmed.",
    });
    return issues;
  }
 
  if (isBefore(product.pcocExpiry, now)) {
    issues.push({
      sku: product.sku,
      code: "PCOC_EXPIRED",
      severity: "blocker",
      fixability: "certification",
      message: `PCoC ${product.pcocNumber} has already expired.`,
      observed: product.pcocExpiry.toISOString().slice(0, 10),
    });
    return issues;
  }
 
  if (isBefore(product.pcocExpiry, requiredValidUntil)) {
    const daysShort = differenceInDays(requiredValidUntil, product.pcocExpiry);
    issues.push({
      sku: product.sku,
      code: "PCOC_EXPIRES_IN_TRANSIT",
      severity: "blocker",
      fixability: "certification",
      message:
        `PCoC ${product.pcocNumber} expires before the goods are expected to ` +
        `clear customs — ${daysShort} days short. Renew before shipping.`,
      observed: product.pcocExpiry.toISOString().slice(0, 10),
      expected: `valid through ${requiredValidUntil.toISOString().slice(0, 10)}`,
    });
  }
 
  return issues;
}

That single rule — checking expiry against arrival rather than against today — catches a failure mode that portal-driven workflows structurally cannot see, because the portal only ever knows about one product at one moment.

Step 6: Reconcile Shipment Lines Against Registered Products

This is the step nobody else builds, and it is where most shipment certificate rejections actually come from.

The PCoC describes a product. The commercial invoice describes what is in the container. The SCoC will only issue if those two agree. They drift constantly: marketing renames a product, a supplier ships a superseded model number, the invoice says "LED Lamp 9W" while the registration says "LED Bulb 9W".

// src/rules/reconcile.ts
import type { ProductRecord, ValidationIssue } from "../types.js";
 
export interface ShipmentLine {
  sku: string;
  /** Description exactly as printed on the commercial invoice */
  invoiceDescription: string;
  invoiceBrand: string;
  invoiceModel: string;
  hsCode: string;
  quantity: number;
}
 
/** Fold case, collapse whitespace, drop punctuation — compare meaning, not formatting. */
function canonical(value: string): string {
  return value
    .toLowerCase()
    .replace(/[^\p{L}\p{N}\s]/gu, " ")
    .replace(/\s+/g, " ")
    .trim();
}
 
export function reconcileShipment(
  lines: ShipmentLine[],
  catalogue: Map<string, ProductRecord>,
): ValidationIssue[] {
  const issues: ValidationIssue[] = [];
 
  for (const line of lines) {
    const product = catalogue.get(line.sku);
 
    if (!product) {
      issues.push({
        sku: line.sku,
        code: "SHIP_SKU_UNREGISTERED",
        severity: "blocker",
        fixability: "certification",
        message:
          "Shipment line references a SKU with no registered product record. " +
          "It cannot be covered by an existing PCoC.",
      });
      continue;
    }
 
    if (canonical(line.invoiceBrand) !== canonical(product.brand)) {
      issues.push({
        sku: line.sku,
        code: "SHIP_BRAND_MISMATCH",
        severity: "blocker",
        fixability: "documentation",
        message:
          "Invoice brand does not match the registered product brand. " +
          "The conformity body will reject the shipment certificate.",
        observed: line.invoiceBrand,
        expected: product.brand,
      });
    }
 
    if (canonical(line.invoiceModel) !== canonical(product.model)) {
      issues.push({
        sku: line.sku,
        code: "SHIP_MODEL_MISMATCH",
        severity: "blocker",
        fixability: "documentation",
        message:
          "Invoice model does not match the registered model. This is the " +
          "single most common cause of SCoC rejection.",
        observed: line.invoiceModel,
        expected: product.model,
      });
    }
 
    const lineHs = line.hsCode.replace(/[.\s-]/g, "");
    const productHs = product.hsCode.replace(/[.\s-]/g, "");
    if (lineHs !== productHs) {
      issues.push({
        sku: line.sku,
        code: "SHIP_HS_MISMATCH",
        severity: "blocker",
        fixability: "data-entry",
        message:
          "Invoice HS code differs from the registered product HS code. " +
          "Customs and Saber will resolve different regulations for the same goods.",
        observed: lineHs,
        expected: productHs,
      });
    }
 
    if (line.quantity <= 0) {
      issues.push({
        sku: line.sku,
        code: "SHIP_QUANTITY_INVALID",
        severity: "blocker",
        fixability: "data-entry",
        message: "Shipment line quantity must be greater than zero.",
        observed: String(line.quantity),
      });
    }
  }
 
  return issues;
}

Note the canonical() helper uses Unicode property escapes (\p{L}, \p{N}) rather than [a-z0-9]. Catalogues in this market carry Arabic product names, and an ASCII-only character class would strip them to nothing and report every Arabic-named product as a mismatch.

Step 7: Compose the Validation Pipeline

With the rules written as independent pure functions, composition is trivial — and that is the payoff of the design. Each rule takes a record and returns issues; nothing shares mutable state.

// src/pipeline.ts
import { validateHsCode } from "./rules/hs-code.js";
import { validateRegulationCoverage } from "./rules/regulation.js";
import { validateCertificateValidity, type ExpiryOptions } from "./rules/certificate.js";
import { reconcileShipment, type ShipmentLine } from "./rules/reconcile.js";
import { ProductRecordSchema, type ProductRecord, type ValidationIssue } from "./types.js";
 
export interface ValidationInput {
  rawProducts: unknown[];
  shipmentLines?: ShipmentLine[];
  expiry: ExpiryOptions;
}
 
export interface ValidationResult {
  issues: ValidationIssue[];
  validProducts: ProductRecord[];
  parseFailures: number;
}
 
export function validateCatalogue(input: ValidationInput): ValidationResult {
  const issues: ValidationIssue[] = [];
  const validProducts: ProductRecord[] = [];
  let parseFailures = 0;
 
  for (const raw of input.rawProducts) {
    const parsed = ProductRecordSchema.safeParse(raw);
 
    if (!parsed.success) {
      parseFailures++;
      // Try to recover a SKU for the report even from a malformed row,
      // otherwise the operator cannot find the offending line.
      const sku =
        typeof raw === "object" && raw !== null && "sku" in raw
          ? String((raw as Record<string, unknown>).sku)
          : "unknown";
 
      for (const issue of parsed.error.issues) {
        issues.push({
          sku,
          code: "SCHEMA_INVALID",
          severity: "blocker",
          fixability: "data-entry",
          message: `Field "${issue.path.join(".")}": ${issue.message}`,
        });
      }
      continue;
    }
 
    const product = parsed.data;
    validProducts.push(product);
 
    issues.push(...validateHsCode(product));
    issues.push(...validateRegulationCoverage(product));
    issues.push(...validateCertificateValidity(product, input.expiry));
  }
 
  if (input.shipmentLines?.length) {
    const catalogue = new Map(validProducts.map((p) => [p.sku, p]));
    issues.push(...reconcileShipment(input.shipmentLines, catalogue));
  }
 
  return { issues, validProducts, parseFailures };
}

Step 8: A Report Your Compliance Team Can Act On

A flat list of six hundred issues is not a deliverable. Group by fixability, because that maps to who does the work and how long it takes.

// src/report/summarise.ts
import type { Fixability, ValidationIssue } from "../types.js";
 
export interface ReportSection {
  fixability: Fixability;
  headline: string;
  leadTime: string;
  blockers: number;
  affectedSkus: string[];
  issues: ValidationIssue[];
}
 
const SECTION_META: Record<Fixability, { headline: string; leadTime: string }> = {
  "data-entry": {
    headline: "Fix in your own system",
    leadTime: "minutes to hours",
  },
  documentation: {
    headline: "Request corrected documents from the supplier",
    leadTime: "days",
  },
  certification: {
    headline: "Requires conformity assessment — start now",
    leadTime: "weeks; this is your critical path",
  },
};
 
const ORDER: Fixability[] = ["certification", "documentation", "data-entry"];
 
export function buildReport(issues: ValidationIssue[]): ReportSection[] {
  return ORDER.map((fixability) => {
    const scoped = issues.filter((i) => i.fixability === fixability);
    const meta = SECTION_META[fixability];
 
    return {
      fixability,
      headline: meta.headline,
      leadTime: meta.leadTime,
      blockers: scoped.filter((i) => i.severity === "blocker").length,
      affectedSkus: [...new Set(scoped.map((i) => i.sku))],
      issues: scoped,
    };
  }).filter((section) => section.issues.length > 0);
}

Certification-class issues sort first deliberately. They are the ones with weeks of lead time, so they need to be visible on day one — even though a typo is technically "more fixable", the typo is not what causes a container to sit at a port.

Step 9: Wire It Together as a CLI

// src/cli.ts
import { readFileSync } from "node:fs";
import { parse } from "csv-parse/sync";
import { validateCatalogue } from "./pipeline.js";
import { buildReport } from "./report/summarise.js";
 
const [, , cataloguePath, transitDaysArg] = process.argv;
 
if (!cataloguePath) {
  console.error("Usage: npm run validate -- <catalogue.csv> [transitDays]");
  process.exit(1);
}
 
const rows = parse(readFileSync(cataloguePath, "utf8"), {
  columns: true,
  skip_empty_lines: true,
  trim: true,
});
 
const result = validateCatalogue({
  rawProducts: rows,
  expiry: {
    transitLeadDays: Number(transitDaysArg ?? 28),
    bufferDays: 14,
  },
});
 
const report = buildReport(result.issues);
 
console.log(`\nValidated ${rows.length} rows`);
console.log(`Parse failures: ${result.parseFailures}`);
console.log(`Total issues: ${result.issues.length}\n`);
 
for (const section of report) {
  console.log(`── ${section.headline.toUpperCase()} (${section.leadTime})`);
  console.log(
    `   ${section.blockers} blockers across ${section.affectedSkus.length} SKUs\n`,
  );
 
  for (const issue of section.issues.slice(0, 20)) {
    console.log(`   [${issue.code}] ${issue.sku}`);
    console.log(`     ${issue.message}`);
    if (issue.observed) console.log(`     observed: ${issue.observed}`);
    if (issue.expected) console.log(`     expected: ${issue.expected}`);
    console.log();
  }
 
  if (section.issues.length > 20) {
    console.log(`   ... and ${section.issues.length - 20} more\n`);
  }
}
 
// Non-zero exit so this can gate a CI job or a pre-shipment pipeline
const hasBlockers = result.issues.some((i) => i.severity === "blocker");
process.exit(hasBlockers ? 1 : 0);

Run it:

npm run validate -- ./data/catalogue.csv 35

Testing Your Implementation

The rules are pure functions, which makes them pleasant to test. Inject now so expiry tests do not rot.

// tests/certificate.test.ts
import { describe, expect, it } from "vitest";
import { validateCertificateValidity } from "../src/rules/certificate.js";
import type { ProductRecord } from "../src/types.js";
 
const base: ProductRecord = {
  sku: "LED-9W-E27",
  nameEn: "LED Bulb 9W E27",
  brand: "Lumina",
  model: "LX-9W-E27",
  hsCode: "851660100000",
  countryOfOrigin: "CN",
  manufacturer: "Lumina Lighting Co Ltd",
  pcocNumber: "PC-2026-004411",
  pcocExpiry: new Date("2026-09-01"),
};
 
describe("certificate validity", () => {
  const now = new Date("2026-08-09");
 
  it("flags a certificate that expires while goods are in transit", () => {
    const issues = validateCertificateValidity(base, {
      transitLeadDays: 28,
      bufferDays: 14,
      now,
    });
 
    expect(issues).toHaveLength(1);
    expect(issues[0]?.code).toBe("PCOC_EXPIRES_IN_TRANSIT");
    expect(issues[0]?.fixability).toBe("certification");
  });
 
  it("passes a certificate valid beyond arrival plus buffer", () => {
    const issues = validateCertificateValidity(
      { ...base, pcocExpiry: new Date("2027-01-01") },
      { transitLeadDays: 28, bufferDays: 14, now },
    );
 
    expect(issues).toHaveLength(0);
  });
});

And the reconciliation logic, which needs to prove it handles Arabic text correctly:

// tests/reconcile.test.ts
import { describe, expect, it } from "vitest";
import { reconcileShipment } from "../src/rules/reconcile.js";
import type { ProductRecord } from "../src/types.js";
 
const product: ProductRecord = {
  sku: "LED-9W-E27",
  nameEn: "LED Bulb 9W E27",
  nameAr: "مصباح ليد ٩ واط",
  brand: "Lumina",
  model: "LX-9W-E27",
  hsCode: "851660100000",
  countryOfOrigin: "CN",
  manufacturer: "Lumina Lighting Co Ltd",
};
 
describe("shipment reconciliation", () => {
  const catalogue = new Map([[product.sku, product]]);
 
  it("treats punctuation and case differences as equivalent", () => {
    const issues = reconcileShipment(
      [
        {
          sku: "LED-9W-E27",
          invoiceDescription: "LED Bulb 9W",
          invoiceBrand: "LUMINA",
          invoiceModel: "LX 9W E27",
          hsCode: "8516.60.10.0000",
          quantity: 500,
        },
      ],
      catalogue,
    );
 
    expect(issues).toHaveLength(0);
  });
 
  it("catches a superseded model number", () => {
    const issues = reconcileShipment(
      [
        {
          sku: "LED-9W-E27",
          invoiceDescription: "LED Bulb 9W",
          invoiceBrand: "Lumina",
          invoiceModel: "LX-9W-E27-V2",
          hsCode: "851660100000",
          quantity: 500,
        },
      ],
      catalogue,
    );
 
    expect(issues.map((i) => i.code)).toContain("SHIP_MODEL_MISMATCH");
  });
});
npm test

Troubleshooting

Every HS code fails as HS_TOO_SHORT. Your source export almost certainly went through Excel, which treats tariff codes as numbers and strips leading zeros and trailing precision. Export as text, or read the file with all columns forced to string before parsing.

Arabic product names report as mismatches. Check that your normalisation uses Unicode-aware character classes (\p{L}), not [a-z]. Also confirm the file is read as UTF-8 — a mis-decoded file produces mojibake that will never match.

REG_UNMATCHED fires on most of the catalogue. Your reference table is too sparse. The illustrative table in Step 4 has three entries; a production table has thousands. Populate it from SASO's published HS code list before drawing conclusions from the output.

A product passes validation and still gets rejected. Expected, and worth being honest about: this validator predicts the mechanical rejections — malformed codes, missing certificates, invoice-to-registration drift. It cannot predict a conformity body's technical judgement on a test report. Treat it as eliminating the avoidable failures, not as a guarantee.

Certificate checks pass locally but fail near quarter end. Confirm you are injecting a real now in production rather than a fixture date left over from testing.

Next Steps

Once the core validator runs against your catalogue, the natural extensions are:

  • Version the reference table. Store which ruleset version produced a report, so you can explain why a product that passed in March fails in August.
  • Run it in CI. The non-zero exit code in Step 9 means a pre-shipment pipeline can block on blockers.
  • Push results back into the ERP. A per-SKU compliance status is more useful in the system where purchasing happens than in a terminal.
  • Add a renewal calendar. Sort by pcocExpiry and you have a certification roadmap instead of a recurring emergency.

Related reading on this site:

Conclusion

The absence of a Saber submission API reads like a limitation until you notice what it actually implies. If the platform will not tell you in bulk whether your data is acceptable, then the only leverage available is upstream, in the catalogue you already control.

That reframing is worth more than the code. Most importers treat rejections as a customs problem and buy their way past each one with a clearance agent. The rejections are a data quality problem, they are visible weeks before a container sails, and they are the same handful of failure modes repeating: codes at the wrong precision, certificates expiring mid-voyage, invoices that stopped matching registrations when someone renamed a product.

Every one of those is checkable in a few hundred lines of TypeScript against data you already have.

If you are running a Saudi import operation on top of an ERP that was never designed for SASO compliance, and you want a validation layer wired into the systems you already run rather than a script somebody maintains on a laptop — tell us what your catalogue looks like. We will tell you honestly which of your rejections are preventable and which are not.