/** * Direct API sandbox example. Tested with mocked HTTP responses only. * It is not evidence of an observed sandbox result or live KRA acceptance. * Requires Node.js 22.6+ and a Direct API sandbox key with invoices:write/read. * * Set these in your local environment; never paste a key into source control: * RISITI_SANDBOX_KEY rsk_test_... (your actual sandbox key) * RISITI_SALE_ID your stable identifier for this fictional sale * RISITI_IDEMPOTENCY_KEY your stable key for this exact request body * Optional RISITI_INVOICE_ID resumes with GET only; retain the same SALE_ID. * Optional RISITI_API_BASE_URL must equal the sandbox Direct API URL below. * * Run from a standalone folder (no parent package.json), or an ES-module * project with "type": "module". An explicit CommonJS project is not supported. * Run: node --experimental-strip-types --experimental-default-type=module risiti-sandbox-invoice.ts * Exit: 0 = sandbox completed; 2 = pending/reconcile again; 1 = action/error. * Replays must retain the same sale ID, key and body. Do not change the * fixture between replays. Idempotency lasts 30 days; retain your durable * sale-to-invoice mapping and use GET-only reconciliation beyond that window. * This example does not register items, handle webhooks or issue credit notes. */ import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; export const SANDBOX_BASE_URL = "https://sandbox-api.getrisiti.com/v1/direct"; type Environment = Record; type State = "queued" | "submitting_to_kra" | "waiting_for_kra" | "completed" | "action_required"; type Config = { apiKey: string; saleId: string; idempotencyKey: string; invoiceId?: string }; type Invoice = { invoiceId: string; externalId: string; requestId: string; state: State }; export type SandboxResult = Invoice & { outcome: "sandbox_completed" | "pending" | "action_required" | "reconcile_again"; }; export class SandboxExampleError extends Error {} function stop(message: string): never { throw new SandboxExampleError(message); } const identifier = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value); const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : stop("Unexpected sandbox response. Keep the original sale and invoice identifiers."); export function readConfig(env: Environment): Config { const base = (env.RISITI_API_BASE_URL ?? SANDBOX_BASE_URL).replace(/\/$/, ""); if (base !== SANDBOX_BASE_URL) stop("Only the exact HTTPS sandbox Direct API URL is permitted."); if (!env.RISITI_SANDBOX_KEY || !/^rsk_test_[a-f0-9]{64}$/.test(env.RISITI_SANDBOX_KEY)) { stop("Set RISITI_SANDBOX_KEY to a valid sandbox key. Production keys are refused."); } if (!identifier(env.RISITI_SALE_ID)) stop("Set a stable RISITI_SALE_ID (1–128 letters, digits, dots, underscores, colons or hyphens)."); if (!identifier(env.RISITI_IDEMPOTENCY_KEY)) stop("Set a stable RISITI_IDEMPOTENCY_KEY for this unchanged request body."); if (env.RISITI_INVOICE_ID !== undefined && !identifier(env.RISITI_INVOICE_ID)) { stop("RISITI_INVOICE_ID must be the previously returned invoice identifier."); } return { apiKey: env.RISITI_SANDBOX_KEY, saleId: env.RISITI_SALE_ID, idempotencyKey: env.RISITI_IDEMPOTENCY_KEY, invoiceId: env.RISITI_INVOICE_ID, }; } function parseInvoice(payload: unknown, saleId: string, expectedId?: string): Invoice { const envelope = object(payload); const data = object(envelope.data); const submission = object(data.submission); if (!identifier(data.invoice_id) || data.external_id !== saleId || !identifier(envelope.request_id)) { stop("Sandbox response identifiers did not match this sale. Keep the original request."); } if (expectedId && data.invoice_id !== expectedId) stop("Reconciliation returned a different invoice. Stop and investigate."); const state = submission.state; if (!["queued", "submitting_to_kra", "waiting_for_kra", "completed", "action_required"].includes(String(state))) { stop("Missing or unknown canonical submission state; legacy status is not a completion signal."); } const terminal = state === "completed" || state === "action_required"; if ( submission.accepted_by_risiti !== true || submission.terminal !== terminal || submission.succeeded !== (state === "completed") || typeof submission.will_retry_automatically !== "boolean" || (terminal && submission.will_retry_automatically !== false) ) stop("Inconsistent canonical submission flags; do not mark this sale complete."); return { invoiceId: data.invoice_id, externalId: saleId, requestId: envelope.request_id, state: state as State }; } async function request( config: Config, fetcher: typeof fetch, path: string, method: "POST" | "GET", body?: string, ): Promise { let response: Response; try { response = await fetcher(`${SANDBOX_BASE_URL}${path}`, { method, headers: { Authorization: `Bearer ${config.apiKey}`, Accept: "application/json", ...(method === "POST" ? { "Content-Type": "application/json", "Idempotency-Key": config.idempotencyKey } : {}), }, ...(body === undefined ? {} : { body }), redirect: "error", signal: AbortSignal.timeout(15_000), }); } catch { stop("Sandbox transport failed or timed out. The outcome may be uncertain; retain the same sale ID, idempotency key and body."); } if (response.status !== (method === "POST" ? 202 : 200)) { if (response.status === 409) stop("HTTP 409: idempotency conflict. Recover the original request; do not blindly choose another key."); if (response.status === 429) stop("HTTP 429: rate limited. Honor Retry-After before retrying the unchanged request."); stop(`Sandbox returned HTTP ${response.status}. Inspect the request in your dashboard; credentials and response bodies are not printed.`); } try { return await response.json(); } catch { stop("Sandbox returned unreadable JSON. Keep the original identifiers and reconcile the saved request."); } } /** Performs at most one POST and one GET. Pass a mock fetcher for offline tests. */ export async function runSandboxExample(env: Environment, fetcher: typeof fetch = fetch): Promise { const config = readConfig(env); let created: Invoice | undefined; let invoiceId = config.invoiceId; if (!invoiceId) { const body = JSON.stringify({ external_id: config.saleId, payment_method: "06", buyer: { name: "Fictional sandbox buyer" }, items: [{ external_id: "sandbox-example-service", name: "Fictional non-VAT service", quantity: 1, unit_price: 1000, tax_type: "D" }], }); created = parseInvoice(await request(config, fetcher, "/invoices", "POST", body), config.saleId); invoiceId = created.invoiceId; } let current: Invoice; try { current = parseInvoice( await request(config, fetcher, `/invoices/${encodeURIComponent(invoiceId)}`, "GET"), config.saleId, invoiceId, ); } catch (error) { // Preserve the known ID after a failed GET; never submit another invoice here. if (created) return { ...created, outcome: "reconcile_again" }; throw error; } return { ...current, outcome: current.state === "completed" ? "sandbox_completed" : current.state === "action_required" ? "action_required" : "pending", }; } export function formatSandboxResult(result: SandboxResult, apiKey: string): string { return JSON.stringify({ ...result, evidence: "Sandbox example only; not a KRA-signed production receipt." }, null, 2) .replaceAll(apiKey, "[REDACTED]"); } async function main() { try { const result = await runSandboxExample(process.env); console.log(formatSandboxResult(result, process.env.RISITI_SANDBOX_KEY ?? "")); process.exitCode = result.outcome === "sandbox_completed" ? 0 : result.outcome === "action_required" ? 1 : 2; } catch (error) { console.error(error instanceof SandboxExampleError ? error.message : "The sandbox example stopped. Keep the original identifiers and inspect the configuration."); process.exitCode = 1; } } // Importing this example performs no request and changes no process state. if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { await main(); }