HANDOFF — Automated ZATCA Onboarding Pipeline (CCSID → Compliance → PCSID)
For the next engineer/AI continuing this build. This document is self-contained: current state, exactly what already exists, the precise next task, ZATCA mechanics, gotchas, and how to test. Read it top to bottom before writing code.
- Repo:
github.com/ak1458/fatooralite· branchfeature/production-readiness - Stack: Next.js 16 (App Router) + Prisma 6 + Neon Postgres (pgvector) on Vercel
- App root:
fatooralite/(all paths below are relative to it unless noted) - Last verified: tsc clean · 89 unit tests pass ·
next buildgreen · ZATCA signature validation 6/6 (npm run zatca:validate)
0. Current state (do NOT redo these)
The four Phase-2 crypto/architecture blockers are done, committed, pushed, and
validated (commit b4e2e60):
| Done | Where |
|---|---|
| C14N-11 signing (in-context SignedInfo, 3 exclusion transforms: UBLExtensions + cac:Signature + QR) | lib/zatca/canonicalize.ts, lib/zatca/xades.ts (finalizeSignatureValue) |
PIH concurrency lock (SELECT … FOR UPDATE on InvoiceCounter, interactive tx) | lib/db/repo.ts (nextChainSlot/commitChainHash), lib/services/invoice-service.ts |
| QR Tag 9 from CSID (node-forge extracts CA signature) | lib/zatca/tag9.ts |
| 24h B2C reporting cron | app/api/cron/zatca-reporting/route.ts, vercel.json, schema Invoice.reportingState/reportingDeadline |
The signing pipeline is provably self-consistent under ZATCA's canonicalization. What has NOT been done: a live gateway round-trip, because that needs a real compliance CSID issued from a Fatoora-portal OTP (see §5). That is exactly what this next task unblocks.
1. The task
Build the Automated Onboarding Pipeline for multi-tenant SaaS. When a Saudi merchant signs up, they generate a 6-digit OTP on their ZATCA Fatoora portal and paste it into the app. The backend must orchestrate this strict sequence within the 60-minute OTP validity window:
- Acquire Compliance CSID (CCSID) —
POST {gw}/complianceGenerate a CSR (merchant VAT + EGS serial), submit with the OTP. ZATCA returnsbinarySecurityToken(CCSID),secret,requestID. - Pass compliance scenarios —
POST {gw}/compliance/invoicesGenerate + sign (with the CCSID key) + submit the required dummy invoices (Standard, Simplified, Credit, Debit). All must be accepted. - Acquire Production CSID (PCSID) —
POST {gw}/production/csidsSubmit therequestIDfrom step 1. ZATCA returns the productionbinarySecurityToken(PCSID) + productionsecret. Persist in Neon.
The PCSID is the certificate lib/zatca/tag9.ts parses for QR Tag 9, the key that
signs all live invoices, and the credential (token:secret Basic auth) for the
clearance/reporting endpoints.
2. What ALREADY EXISTS (~80% built — orchestrate + harden, don't rebuild)
Do not start from scratch. Each step already has a service function + a route:
| Step | Service (lib/services/onboarding-service.ts) | Gateway client (lib/zatca/onboarding.ts) | Route |
|---|---|---|---|
| 1. CCSID | startOnboarding({companyId, otp, commonName, organizationalUnit, mode}) | requestComplianceCsid(csrBase64, otp, mode) | POST /api/onboarding/start |
| 2. Compliance checks | runComplianceChecks(companyId, mode, db) — generates 4 samples (standard/simplified/credit/debit), signs with the CCSID key, submits | submitComplianceInvoice({signedXmlBase64, invoiceHash, uuid}, {token, secret}, mode) | (called inside step 3) |
| 3. PCSID | completeOnboarding(companyId, mode) — runs the compliance checks and refuses to proceed unless all pass, then requests the PCSID | requestProductionCsid({token, secret, requestId}, mode) | POST /api/onboarding/complete |
Supporting:
- CSR builder:
lib/zatca/csr.ts—generateCsr(privateKeyPem, publicKeyPem, subject, zatcaExtensions). Already emits the ZATCA custom extensions:templateNameOID1.3.6.1.4.1.311.20.2(BMPString) and SAN2.5.29.17with the EGS serial/VAT/invoiceType/location/industry. Signed with ECDSA-SHA256 over secp256k1. - Keys:
lib/zatca/keys.ts—generateKeyPair()(secp256k1, the ZATCA curve — correct, do not change to P-256). - Cert storage: Prisma
Certificatemodel —kind=compliance | production | local; fieldstoken(binarySecurityToken/CSID, base64),secret,requestId,privateKey(EC PEM, encrypted at rest vialib/crypto/encrypt.ts),publicKey,status,issuedAt,expiresAt. - Gateway base URLs:
lib/zatca/client.tsgatewayBaseUrl(mode)— sandbox =…/developer-portal, production =…/core. Set viaZATCA_SANDBOX_BASE_URL/ZATCA_PRODUCTION_BASE_URL, mode viaZATCA_MODE. - Sample signing uses the fixed pipeline:
runComplianceCheckscallsgenerateSignedInvoice(lib/zatca/index.ts), which now includesfinalizeSignatureValue(the C14N fix) and Tag 9. So compliance samples are signed identically to live invoices. Good.
3. What is MISSING / must be built or hardened
-
Single orchestration endpoint —
POST /api/onboarding/activateOne call:{companyId, otp}→ runs step 1 → step 2 → step 3 sequentially, returns per-step status. Today it's two calls (start, thencomplete). The OTP is consumed in step 1; the whole race must finish inside 60 min, so a single server-orchestrated call is the right UX (merchant pastes OTP once).- Reuse
startOnboardingthencompleteOnboarding(which already runs the checks). Wrap in one handler with clear per-step error surfacing. - Return shape:
{ step: "ccsid"|"compliance"|"pcsid"|"done", ccsid?, complianceResults?, error? }.
- Reuse
-
OTP lifecycle + retry semantics
- OTP is single-use and expires in ~60 min. If step 1 succeeds but step 2/3
fail, do not require a new OTP — the CCSID +
requestIdare already stored; allow re-running steps 2–3 against the stored compliance cert.completeOnboardingalready reads the stored compliance cert, so expose a "resume" path that skips step 1. - Surface a precise error when ZATCA rejects the OTP (expired/invalid) so the merchant knows to generate a fresh one.
- OTP is single-use and expires in ~60 min. If step 1 succeeds but step 2/3
fail, do not require a new OTP — the CCSID +
-
Compliance scenario coverage — verify against the real gateway
runComplianceCheckscurrently submits 4 fixed samples. ZATCA's required set depends on the CSR'sinvoiceType:1100(standard and simplified) → must pass BOTH standard clearance and simplified reporting samples, plus credit + debit notes for each type actually enabled. Confirm the exact required matrix against the sandbox response — ZATCA returns which scenarios are stillNOT_PASSED.- Each sample must chain (PIH) correctly and carry a valid signature. They now
do (shared pipeline), but the compliance samples must use the compliance
private key, which
runComplianceChecksalready decrypts. Verify.
-
CSR extension format — the #1 real rejection cause after signing
lib/zatca/csr.tsbuilds the extensions, but the exact byte format is a known ZATCA gotcha and has not been round-tripped against the live gateway. Verify against the sandbox:templateNamevalue must bePREZATCA-Code-Signingfor the compliance (sandbox) CSID andZATCA-Code-Signingfor production. CheckgenerateCsr/onboarding-service.tspass the right one permode.- SAN must contain the EGS serial (
1-<solutionName>|2-<model>|3-<uuid>), the VAT (2.5.4.5-style), the title (invoiceType0100/1000/1100), and the industry — in ZATCA's exact directoryName/otherName structure. - If the gateway returns a CSR-format error on
POST /compliance, this is where to look first.
-
Persistence + activation of the PCSID
- On step 3 success,
completeOnboardingalready deactivates the old production cert and stores the new one askind=production, status=active. ConfirmgetActiveCertificate(inlib/db/repo.ts) returns it and thatsubmitInvoice(lib/services/clearance-service.ts) usestoken:secretfor Basic auth. It rejectssecret === "LOCAL-DEV-SECRET"— make sure a real PCSID isn't accidentally flagged local. - Set
Company.onboardingStatus = "complete"so the first-run wizard guard releases (checkproxy.ts/ onboarding UI).
- On step 3 success,
-
Concurrency / idempotency
- Guard against double-submission (merchant clicks twice): a company already mid-onboarding shouldn't fire two CCSID requests. Use a status flag or a unique in-flight lock.
-
UI —
app/onboarding/wizard: OTP input → progress (step 1/2/3 with live status) → success. There is an existing onboarding wizard; wire it to the new/api/onboarding/activateand show per-step progress + the 60-min countdown.
4. ZATCA API mechanics (exact wire details)
All calls: Accept-Version: V2. Base URL from gatewayBaseUrl(mode).
Step 1 — POST /compliance
- Headers:
OTP: <6-digit>,Content-Type: application/json,Accept: application/json. - Body:
{ "csr": "<base64 of PEM CSR>" }. - Response:
{ requestID, binarySecurityToken, secret, dispositionMessage }. binarySecurityToken= the CCSID (base64 DER cert). Store asCertificate.token.
Step 2 — POST /compliance/invoices (repeat per sample)
- Auth:
Authorization: Basic base64(binarySecurityToken:secret)(the CCSID). - Headers:
Accept-Version: V2,Accept-Language: en. - Body:
{ "invoiceHash", "uuid", "invoice": "<base64 signed XML>" }. - Response:
{ clearanceStatus | reportingStatus, validationResults }.- Standard sample → expect
clearanceStatus: "CLEARED". - Simplified sample → expect
reportingStatus: "REPORTED". - Any
NOT_CLEARED/NOT_REPORTEDorvalidationResults.status: "ERROR"= fail; readvalidationResults.errorMessages[*].code(BR-KSA-*) for the reason.
- Standard sample → expect
Step 3 — POST /production/csids
- Auth:
Authorization: Basic base64(CCSID_token:CCSID_secret). - Body:
{ "compliance_request_id": "<requestID from step 1>" }. - Response:
{ requestID, binarySecurityToken, secret }= the PCSID. Store asCertificate.kind=production, status=active.
Live invoicing afterward (already implemented in clearance-service.ts):
- Clearance (standard):
POST /invoices/clearance/single, headerClearance-Status: 1. - Reporting (simplified):
POST /invoices/reporting/single. - Auth:
Basic base64(PCSID_token:PCSID_secret).
5. How to test (the ONLY way to truly validate)
Local crypto is already proven (npm run zatca:validate → 6/6, gateway reachable
HTTP 401). A real end-to-end test needs a Fatoora sandbox/simulation portal
account to generate an OTP:
- Register on the ZATCA Fatoora simulation portal, create an EGS unit, get a 6-digit OTP (valid 60 min).
- Point env at simulation:
ZATCA_MODE=sandbox,ZATCA_SANDBOX_BASE_URL=https://gw-fatoora.zatca.gov.sa/e-invoicing/simulation(confirm the current simulation base URL — ZATCA moved it before). - Call
POST /api/onboarding/start(or the new/activate) with the OTP. - Watch for: CSR accepted (step 1), all compliance samples
CLEARED/REPORTED(step 2), PCSID issued (step 3). - Then issue a real simplified invoice and confirm Tag 9 is populated from the PCSID and the gateway reports it.
scripts/validate-zatca.ts already submits a signed sample to
/compliance/invoices if ZATCA_COMPLIANCE_TOKEN/ZATCA_COMPLIANCE_SECRET are
set — extend it, or add scripts/onboard-sandbox.ts, to drive the full 3-step
flow with a pasted OTP for repeatable testing.
6. Env vars
Required already: DATABASE_URL, DIRECT_URL, AUTH_SECRET, AUTH_ENFORCE=true,
ENCRYPTION_KEY (encrypts cert private keys — must be stable, rotating it
orphans stored keys).
ZATCA: ZATCA_MODE (sandbox|simulation|production),
ZATCA_SANDBOX_BASE_URL, ZATCA_PRODUCTION_BASE_URL.
Cron: CRON_SECRET (Vercel Cron bearer). Reporting cron already wired.
7. Gotchas / landmines
- secp256k1 is correct — ZATCA mandates it. Do not "fix" to P-256/secp256r1.
ENCRYPTION_KEYstability — cert private keys are AES-encrypted at rest; changing the key forces every merchant to re-onboard.- Interactive transactions on Neon —
issueInvoiceholds aFOR UPDATElock across signing. Use the pooledDATABASE_URL; keep tx work fast. - Compliance samples share the live signing pipeline — any change to
canonicalize.ts/xades.tsaffects both. Re-runnpm run zatca:validateafter touching them. - OTP is single-use, ~60 min — never re-request the CCSID on a step-2/3 retry.
- CSR templateName differs sandbox vs prod (
PREZATCA-vsZATCA-Code-Signing). - Don't log OTP or secrets. Redact in any error surfaced to the client/logs.
next buildruns with NODE_ENV=production —lib/env.tsskips the prod-only secret requirements during the build phase (NEXT_PHASE === "phase-production-build"). Keep that guard if you touch env validation.
8. First moves for the next session
- Read
lib/services/onboarding-service.tsandlib/zatca/onboarding.tsin full (they're short) — that's the whole pipeline. - Build
POST /api/onboarding/activateorchestrating start → complete, with a resume path that skips step 1 when a compliance cert already exists. - Get a simulation OTP and run the full flow against the sandbox — fix CSR extension / compliance-scenario issues the gateway reports (§3.3, §3.4).
- Wire the onboarding UI to
/activatewith per-step progress + 60-min countdown. - After a green sandbox onboarding, issue a live simplified invoice and confirm Tag 9 + reporting.
Verification commands: npx tsc --noEmit · npx vitest run · npm run build ·
npm run zatca:validate.