From 222694ddbfa13e715ac58a75c301df2888e3a083 Mon Sep 17 00:00:00 2001 From: Railly Date: Wed, 29 Apr 2026 00:12:28 -0500 Subject: [PATCH] =?UTF-8?q?feat(rest):=20SUNAT=20Consulta=20Integrada=20CP?= =?UTF-8?q?E=20+=20Padr=C3=B3n=20Reducido=20del=20RUC=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new capabilities orthogonal to the SOAP CPE emission flow: 1. SUNAT REST OAuth 2.0 client_credentials wrapper - src/sunat-rest/oauth.ts — token fetch + in-process cache + 401 retry - Token cached for ~1h (60s before SUNAT-reported expiry) - Single postSoap-style helper callRestApi() unifies all REST calls - Two SUNAT host families: api-seguridad (token), api.sunat.gob.pe (ops) 2. Consulta Integrada CPE — sunat cpe consulta - POST /v1/contribuyente/contribuyentes/{ruc}/validarcomprobante - Validates ANY CPE against SUNAT records (yours or vendor's) - Auto-converts ISO date (2026-04-29) → DD/MM/YYYY (29/04/2026) - Friendly mapping of SUNAT codes (estadoCp, estadoRuc, condDomiRuc) to human descriptions (Aceptado, Activo, Habido, etc) - Required env: SUNAT_API_CLIENT_ID, SUNAT_API_CLIENT_SECRET (from SOL menu) 3. Padrón Reducido del RUC local — sunat padron sync|status|ruc|batch - Downloads ~370MB ZIP from www2.sunat.gob.pe (no auth, daily updated) - Streams raw bytes to disk (no encoding conversion in hot path — 12s instead of 12+ minutes when we tried UTF-8 conversion in flight) - 6.1M entries indexed by 11-char RUC prefix - Streaming lookup: ~1s per single RUC on 1GB TXT - Batch lookup: one scan for any number of RUCs - 24h freshness check; --force to re-sync earlier - Pipe-separated TXT, ISO-8859-1 encoded Tests: 209 pass / 2 skip / 0 fail (was 182) - oauth.test.ts (13) — token cache, 401 retry, query params, scope - consulta-cpe.test.ts (6) — ISO→DD/MM/YYYY, code mappings, monto formatting - padron-local.test.ts (8) — line parser, isStale, edge cases Smoke: bun smoke:padron — downloads padrón, looks up SUNAT's own RUC, verifies razon social. Verified passing 2026-04-29. Out of scope (next PRs): - Tipo de cambio: SUNAT/SBS WAF blocks fetch directly. Needs agent-browser. - Padrón RUC consulta puntual via portal: requires numRnd token + reCAPTCHA. Local padrón is a strictly better answer for batch/scriptable use. - GRE (Guía de Remisión REST API): same OAuth shape; oauth.ts is reusable. - SIRE (RVIE/RCE) REST API: distinct host api-sire.sunat.gob.pe. - sqlite index for sub-ms padrón lookups. See src/sunat-rest/RESEARCH.md for full notes including the WAF observations and SUNAT code → friendly description maps. --- packages/cli/README.md | 21 ++ packages/cli/bin/sunat.ts | 2 + packages/cli/package.json | 3 +- packages/cli/scripts/smoke-padron.sh | 40 +++ packages/cli/skills/sunat-cli/SKILL.md | 38 +++ packages/cli/src/commands/cpe/index.ts | 49 ++++ packages/cli/src/commands/padron/index.ts | 156 +++++++++++ packages/cli/src/cpe/oauth-config.ts | 17 ++ packages/cli/src/sunat-rest/RESEARCH.md | 135 +++++++++ packages/cli/src/sunat-rest/consulta-cpe.ts | 119 ++++++++ packages/cli/src/sunat-rest/oauth.ts | 136 +++++++++ packages/cli/src/sunat-rest/padron-local.ts | 276 +++++++++++++++++++ packages/cli/tests/unit/consulta-cpe.test.ts | 130 +++++++++ packages/cli/tests/unit/oauth.test.ts | 165 +++++++++++ packages/cli/tests/unit/padron-local.test.ts | 53 ++++ 15 files changed, 1339 insertions(+), 1 deletion(-) create mode 100755 packages/cli/scripts/smoke-padron.sh create mode 100644 packages/cli/src/commands/padron/index.ts create mode 100644 packages/cli/src/cpe/oauth-config.ts create mode 100644 packages/cli/src/sunat-rest/RESEARCH.md create mode 100644 packages/cli/src/sunat-rest/consulta-cpe.ts create mode 100644 packages/cli/src/sunat-rest/oauth.ts create mode 100644 packages/cli/src/sunat-rest/padron-local.ts create mode 100644 packages/cli/tests/unit/consulta-cpe.test.ts create mode 100644 packages/cli/tests/unit/oauth.test.ts create mode 100644 packages/cli/tests/unit/padron-local.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 783826b..8575f28 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -31,6 +31,27 @@ sunat-cli f616 declare --dry-run --json '{"periodo":"2025-03"}' sunat-cli api token --output json # OAuth2 token ``` +### Padrón Reducido del RUC (offline lookup, no auth) + +```bash +sunat-cli padron sync # ~370MB download, refreshes daily +sunat-cli padron ruc 20131312955 # razon social, estado, condicion +sunat-cli padron batch --file rucs.csv # batch lookup from CSV +``` + +### CPE Consulta Integrada (REST OAuth) + +Validate any CPE (mine or vendor's) against SUNAT records. + +```bash +export SUNAT_API_CLIENT_ID=... # from SOL → Mi RUC → Credenciales API +export SUNAT_API_CLIENT_SECRET=... + +sunat-cli cpe consulta \ + --ruc-emisor 20131312955 --tipo 01 --serie F001 --numero 1234 \ + --fecha 2026-04-29 --monto 118 +``` + ### Empresas (RUC 20) — CPE For empresas emitting Factura, Boleta, NC, ND, Guia. Pluggable backend diff --git a/packages/cli/bin/sunat.ts b/packages/cli/bin/sunat.ts index e448e5b..66e23dc 100755 --- a/packages/cli/bin/sunat.ts +++ b/packages/cli/bin/sunat.ts @@ -8,6 +8,7 @@ import { createSchemaCommand } from "../src/commands/schema.ts"; import { createApiCommand } from "../src/commands/api/index.ts"; import { createLukeaCommand } from "../src/commands/lukea/index.ts"; import { createCpeCommand } from "../src/commands/cpe/index.ts"; +import { createPadronCommand } from "../src/commands/padron/index.ts"; const program = new Command(); @@ -31,5 +32,6 @@ program.addCommand(createF616Command()); program.addCommand(createApiCommand()); program.addCommand(createLukeaCommand()); program.addCommand(createCpeCommand()); +program.addCommand(createPadronCommand()); program.parse(); diff --git a/packages/cli/package.json b/packages/cli/package.json index 081c68f..10341c1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,7 +34,8 @@ "test:unit": "bun test tests/unit", "test:e2e": "bun test tests/e2e", "smoke:sunat": "bash scripts/smoke-sunat.sh", - "smoke:boleta": "bash scripts/smoke-boleta.sh" + "smoke:boleta": "bash scripts/smoke-boleta.sh", + "smoke:padron": "bash scripts/smoke-padron.sh" }, "dependencies": { "@clack/prompts": "^1.1.0", diff --git a/packages/cli/scripts/smoke-padron.sh b/packages/cli/scripts/smoke-padron.sh new file mode 100755 index 0000000..75dd2d3 --- /dev/null +++ b/packages/cli/scripts/smoke-padron.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Smoke test: download SUNAT padrón reducido and lookup a known RUC. +# Run from packages/cli directory: +# bash scripts/smoke-padron.sh +# Or via npm script: +# bun smoke:padron +# +# WARNING: First run downloads ~370MB. Subsequent runs use the cache (24h TTL). + +set -euo pipefail + +KNOWN_RUC="20131312955" # SUPERINTENDENCIA NACIONAL DE ADUANAS Y DE ADMINISTRACION TRIBUTARIA - SUNAT + +echo "→ Padron status..." +bun run bin/sunat.ts -o json padron status + +echo "→ Sync (downloads ~370MB if not cached)..." +bun run bin/sunat.ts -o json padron sync | bun -e ' +const r = JSON.parse(await Bun.stdin.text()); +console.log(" synced:", r.synced); +console.log(" size:", r.zipSizeHuman); +console.log(" entries:", r.entries); +console.log(" duration:", r.durationMs + "ms"); +' + +echo "→ Lookup $KNOWN_RUC (SUNAT)..." +RESULT=$(bun run bin/sunat.ts -o json padron ruc "$KNOWN_RUC") +echo "$RESULT" | bun -e ' +const r = JSON.parse(await Bun.stdin.text()); +console.log(" found:", r.found); +console.log(" razonSocial:", r.razonSocial); +console.log(" estado:", r.estado); +console.log(" condicion:", r.condicion); +if (r.found && r.razonSocial.includes("SUNAT")) { + console.log("\n✅ PADRON SMOKE PASSED"); + process.exit(0); +} +console.log("\n❌ PADRON SMOKE FAILED"); +process.exit(1); +' diff --git a/packages/cli/skills/sunat-cli/SKILL.md b/packages/cli/skills/sunat-cli/SKILL.md index 8093eac..8892497 100644 --- a/packages/cli/skills/sunat-cli/SKILL.md +++ b/packages/cli/skills/sunat-cli/SKILL.md @@ -215,6 +215,44 @@ and prints the CDR. Useful for CI smoke tests and "does my install work?" checks Full shaping rationale: `src/commands/cpe/RESEARCH.md` in the repo. +### CPE Consulta Integrada (REST OAuth) + +Validate any CPE (yours or a vendor's) against SUNAT records. Useful for +anti-fraud (verify a supplier invoice before paying) or to cross-check your +own emissions. + +Setup once: +```bash +# Get client_id + client_secret from SOL → Mi RUC → Credenciales API +export SUNAT_API_CLIENT_ID=... +export SUNAT_API_CLIENT_SECRET=... +``` + +```bash +sunat cpe consulta \ + --ruc-emisor 20131312955 --tipo 01 --serie F001 --numero 1234 \ + --fecha 2026-04-29 --monto 118 +# Returns: estadoCp (Aceptado/Anulado), estadoRuc (Activo/Baja), condDomiRuc (Habido/No Habido) +``` + +### Padrón Reducido del RUC (offline) + +Local copy of the SUNAT RUC registry. ~370MB ZIP, ~600MB TXT, ~3.5M entries. +Refreshes automatically every 24h. No auth, no captcha, no third-party API. + +```bash +sunat padron status # see if synced + how stale +sunat padron sync # downloads if missing or >24h old; --force to override +sunat padron ruc 20131312955 # lookup razon social, estado, condicion, dirección +echo "20131312955 +20100070970 +20536557858" | sunat padron batch # batch lookup via stdin +sunat padron batch --file rucs.csv # or from CSV (RUC in first column) +``` + +First lookup after sync takes 5-15s (streaming scan of 600MB). Batch is one +scan regardless of N RUCs. + ### API & Schema ```bash diff --git a/packages/cli/src/commands/cpe/index.ts b/packages/cli/src/commands/cpe/index.ts index 143625c..3ecf6c8 100644 --- a/packages/cli/src/commands/cpe/index.ts +++ b/packages/cli/src/commands/cpe/index.ts @@ -570,6 +570,55 @@ export function createCpeCommand(): Command { } }); + cpe + .command("consulta") + .description("Validate any CPE against SUNAT (mine or vendor's) via REST OAuth. T0.") + .requiredOption("--ruc-emisor ", "RUC of the emisor (issuer)") + .requiredOption("--tipo ", "01=Factura, 03=Boleta, 07=NC, 08=ND, 09=Guia, 20=Retencion, 40=Percepcion") + .requiredOption("--serie ", "e.g. F001") + .requiredOption("--numero ", "Correlativo") + .requiredOption("--fecha ", "Fecha emision (ISO)") + .option("--monto ", "Total amount; if provided, must match SUNAT records exactly to 2 decimals") + .option("--ruc-consultante ", "RUC of who is querying (defaults to CPE_EMISOR_RUC env or active profile RUC)") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + const { resolveOAuthCredentials } = await import("../../cpe/oauth-config.ts"); + const { validarComprobante } = await import("../../sunat-rest/consulta-cpe.ts"); + const creds = resolveOAuthCredentials(); + + let rucConsultante = opts.rucConsultante as string | undefined; + if (!rucConsultante) { + try { + const ctx = resolveCpeContext(); + rucConsultante = ctx.emisor.ruc; + } catch { + outputError( + "--ruc-consultante required (could not resolve from CPE_EMISOR_RUC or active profile)", + format, + ); + return; + } + } + + const result = await validarComprobante( + { + rucConsultante, + rucEmisor: opts.rucEmisor, + tipoComprobante: opts.tipo, + serie: opts.serie, + numero: Number.parseInt(opts.numero, 10), + fechaEmision: opts.fecha, + monto: opts.monto !== undefined ? Number.parseFloat(opts.monto) : undefined, + }, + creds, + ); + output(format, { json: result }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + const cdr = cpe.command("cdr").description("Constancia de Recepcion (CDR) operations."); cdr diff --git a/packages/cli/src/commands/padron/index.ts b/packages/cli/src/commands/padron/index.ts new file mode 100644 index 0000000..bf2cff6 --- /dev/null +++ b/packages/cli/src/commands/padron/index.ts @@ -0,0 +1,156 @@ +import { Command } from "commander"; +import { isStale, loadMeta, lookupRuc, lookupRucBatch, syncPadron } from "../../sunat-rest/padron-local.ts"; +import { audit } from "../../data/audit.ts"; +import { output, outputError } from "../../utils/output.ts"; + +type Format = "json" | "table" | "auto"; + +function getFormat(cmd: Command): Format { + let parent: Command | null = cmd; + while (parent) { + const opts = parent.opts(); + if (opts.output) return opts.output as Format; + parent = parent.parent; + } + return "auto"; +} + +function fmtBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +export function createPadronCommand(): Command { + const padron = new Command("padron").description("SUNAT Padrón Reducido del RUC — local download + lookup. T0/T1."); + + padron + .command("status") + .description("Show local padrón cache status. T0.") + .action((_, cmd) => { + const format = getFormat(cmd); + const meta = loadMeta(); + if (!meta) { + output(format, { json: { synced: false, hint: "Run: sunat padron sync" } }); + return; + } + output(format, { + json: { + synced: true, + stale: isStale(meta), + lastFetchedAt: meta.lastFetchedAt, + zipSize: meta.zipSize, + zipSizeHuman: fmtBytes(meta.zipSize), + entries: meta.entries, + sha256: `${meta.zipSha256.slice(0, 16)}...`, + }, + }); + }); + + padron + .command("sync") + .description("Download (or refresh) the SUNAT padrón reducido del RUC. ~370MB ZIP, ~600MB TXT. T1.") + .option("--force", "Force re-download even if cache is fresh (<24h)") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + const start = Date.now(); + let lastLog = 0; + const meta = await syncPadron({ + force: opts.force, + onProgress: (down, total) => { + const now = Date.now(); + if (format !== "json" && now - lastLog > 1000) { + const pct = total > 0 ? Math.round((down / total) * 100) : 0; + process.stderr.write(`\r ${fmtBytes(down)}/${fmtBytes(total)} (${pct}%)`); + lastLog = now; + } + }, + }); + if (format !== "json") process.stderr.write("\n"); + audit({ + command: "padron sync", + args: { force: !!opts.force }, + result: "success", + details: { zipSize: meta.zipSize, entries: meta.entries, durationMs: Date.now() - start }, + }); + output(format, { + json: { + synced: true, + durationMs: Date.now() - start, + zipSize: meta.zipSize, + zipSizeHuman: fmtBytes(meta.zipSize), + entries: meta.entries, + lastFetchedAt: meta.lastFetchedAt, + }, + }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + padron + .command("ruc") + .description("Lookup a single RUC in the local padrón. Streaming scan — slow first call (~5-15s on 600MB), instant after. T0.") + .argument("", "11-digit RUC to lookup") + .action(async (ruc, opts, cmd) => { + const format = getFormat(cmd); + try { + if (!/^\d{11}$/.test(ruc)) { + outputError(`Invalid RUC: '${ruc}'. Must be 11 digits.`, format); + return; + } + const entry = await lookupRuc(ruc); + if (!entry) { + output(format, { json: { ruc, found: false } }); + return; + } + output(format, { json: { ruc, found: true, ...entry } }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + padron + .command("batch") + .description("Lookup many RUCs in one scan. Reads RUCs from stdin (one per line) or --file CSV. T0.") + .option("--file ", "Path to file with one RUC per line (or CSV with RUC in first column)") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + let input = ""; + if (opts.file) { + const { readFileSync } = await import("fs"); + input = readFileSync(opts.file, "utf-8"); + } else if (!process.stdin.isTTY) { + input = await new Response(process.stdin as unknown as ReadableStream).text(); + } else { + outputError("Provide --file or pipe RUCs via stdin (one per line).", format); + return; + } + + const rucs = input + .split("\n") + .map((l) => l.trim().split(/[,;\t]/)[0].trim()) + .filter((l) => /^\d{11}$/.test(l)); + + if (rucs.length === 0) { + outputError("No valid 11-digit RUCs found in input.", format); + return; + } + + const results = await lookupRucBatch(rucs); + const arr = Array.from(results.entries()).map(([ruc, entry]) => ({ + ruc, + found: entry !== null, + ...(entry || {}), + })); + output(format, { json: arr }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + return padron; +} diff --git a/packages/cli/src/cpe/oauth-config.ts b/packages/cli/src/cpe/oauth-config.ts new file mode 100644 index 0000000..197c07e --- /dev/null +++ b/packages/cli/src/cpe/oauth-config.ts @@ -0,0 +1,17 @@ +/** + * OAuth credentials for SUNAT REST APIs (consulta CPE, padrón vía API, GRE). + * + * Distinct from the cert+SOL-password flow used by sunat-direct (SOAP). + * These credentials are obtained from SOL menu: + * Mi RUC y Otros Registros → Apps Móviles → Credenciales API + */ + +import type { OAuthCredentials } from "../sunat-rest/oauth.ts"; + +export function resolveOAuthCredentials(): OAuthCredentials { + const clientId = process.env.SUNAT_API_CLIENT_ID; + const clientSecret = process.env.SUNAT_API_CLIENT_SECRET; + if (!clientId) throw new Error("SUNAT_API_CLIENT_ID env var missing. Get from SOL → Mi RUC → Credenciales API."); + if (!clientSecret) throw new Error("SUNAT_API_CLIENT_SECRET env var missing."); + return { clientId, clientSecret }; +} diff --git a/packages/cli/src/sunat-rest/RESEARCH.md b/packages/cli/src/sunat-rest/RESEARCH.md new file mode 100644 index 0000000..dad1984 --- /dev/null +++ b/packages/cli/src/sunat-rest/RESEARCH.md @@ -0,0 +1,135 @@ +# SUNAT REST APIs — Research notes + +This module covers the modern OAuth 2.0 REST APIs SUNAT exposes (separate +from the SOAP CPE emission flow under `src/cpe/`). + +## What this PR ships + +| Capability | Method | Auth | Endpoint / source | +|------------|--------|------|------------------| +| Consulta Integrada CPE | REST | OAuth 2.0 client_credentials | `POST api.sunat.gob.pe/v1/contribuyente/contribuyentes/{ruc}/validarcomprobante` | +| Padrón Reducido del RUC | Local file | None | Daily ZIP at `www2.sunat.gob.pe/padron_reducido_ruc.zip` | + +## What this PR deliberately does NOT ship + +- **Tipo de cambio**. Both `e-consulta.sunat.gob.pe/cl-at-ittipcam` and + `sbs.gob.pe` are blocked by their WAF for direct curl/fetch (return + "Request Rejected"). Needs `agent-browser` automation. Deferred to + future PR with `--driver agent-browser` pattern (same as RHE/F616). +- **Padrón RUC consulta puntual via portal** (`e-consultaruc.sunat.gob.pe`). + Now requires a `numRnd` token + reCAPTCHA. Same agent-browser path. The + local padrón download is a strictly better answer for batch/scriptable + use anyway. +- **GRE (Guía de Remisión Electrónica)** REST API. Separate scope, separate + PR. Same OAuth shape so the `oauth.ts` module here is reusable. +- **SIRE (RVIE/RCE)** REST API. Higher-value next PR (mandatory monthly + filing for all emisores). Distinct host `api-sire.sunat.gob.pe`. + +## OAuth 2.0 flow (verified shape) + +Token endpoint: +``` +POST https://api-seguridad.sunat.gob.pe/v1/clientesextranet/{client_id}/oauth2/token/ +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&scope=https://api.sunat.gob.pe/v1/contribuyente/contribuyentes&client_id=...&client_secret=... +``` + +Response: +```json +{ "access_token": "...", "token_type": "Bearer", "expires_in": 3600 } +``` + +Token lifetime: 1 hour. Cached in-process; auto-refreshed on 401. + +Credentials are obtained from SOL menu: +**Mi RUC y Otros Registros → Apps Móviles → Credenciales API**. +This is distinct from the SOL user/password used by sunat-direct (SOAP). + +## Consulta Integrada CPE — request shape + +``` +POST /v1/contribuyente/contribuyentes/{rucConsultante}/validarcomprobante +Authorization: Bearer {token} +Content-Type: application/json + +{ + "numRuc": "20131312955", // RUC del emisor del CPE + "codComp": "01", // 01=Factura, 03=Boleta, 07=NC, 08=ND, ... + "numeroSerie": "F001", + "numero": "1234", + "fechaEmision": "29/04/2026", // DD/MM/YYYY (we convert from ISO automatically) + "monto": "118.00" // optional; if provided MUST match exactly +} +``` + +Response: +```json +{ + "success": true, + "message": "OK", + "data": { + "estadoCp": "0001", // 0001=Aceptado, 0002=Anulado, 0003=Autorizada, 0004=No Autorizada + "estadoRuc": "00", // 00=Activo, 01/02=Baja Provisional, 10/11=Baja Definitiva, ... + "condDomiRuc": "00", // 00=Habido, 09=Pendiente, 12=No Hallado, 20=No Habido + "observaciones": [] + } +} +``` + +We normalize codes to friendly descriptions in `consulta-cpe.ts`. + +## Padrón Reducido del RUC + +**Source**: `http://www2.sunat.gob.pe/padron_reducido_ruc.zip` (~370MB). +**Updated**: daily by SUNAT (Last-Modified header). +**Format**: single TXT inside the ZIP, pipe-separated, ISO-8859-1 encoded. + +Schema (column order, based on SUNAT public docs): +``` +RUC|RAZON_SOCIAL|ESTADO|CONDICION|UBIGEO|TIPO_VIA|NOMBRE_VIA|COD_ZONA|TIPO_ZONA|NUMERO|INTERIOR|LOTE|MANZANA|KILOMETRO +``` + +Implementation choices: +- Stream raw bytes to disk (no encoding conversion in hot path) — first run + was 12+ minutes on 1GB UTF-8 conversion before we switched to pipe. +- Streaming lookup scans the TXT (5-15s) for ad-hoc queries. +- Batch lookup scans once for any number of RUCs. +- Future: sqlite index for sub-ms lookups (shaped for next PR). + +## Why local padrón vs portal scrape vs 3rd-party API + +| Approach | Cost | Friction | Reliability | +|---------|------|---------|------------| +| **Local padrón ZIP** | 370MB disk | First sync ~30-60s, then instant | High — no captcha, no rate limit, official source | +| Portal scrape (`e-consultaruc.sunat.gob.pe`) | 0 | Requires `numRnd` token + maybe reCAPTCHA | Medium — needs agent-browser, breaks on UI changes | +| 3rd party API (apis.net.pe, decolecta) | Token, sometimes paid | One curl | Medium — depends on 3rd party uptime + ToS | + +Chose local because it's the only auth-free, official, batch-friendly option. + +## Catalog of friendly mappings (consulta-cpe) + +`estadoCp`: +- `0001` Aceptado +- `0002` Anulado +- `0003` Autorizada +- `0004` No Autorizada + +`estadoRuc`: +- `00` Activo +- `01` Baja Provisional +- `02` Baja Provisional por Oficio +- `03` Suspensión Temporal +- `10` Baja Definitiva +- `11` Baja de Oficio +- `22` Inhabilitado + +`condDomiRuc`: +- `00` Habido +- `09` Pendiente +- `11` Por verificar +- `12` No Hallado +- `20` No Habido + +These maps live in `consulta-cpe.ts` and the cli surfaces both raw + friendly +in the JSON response so agents can pivot on either. diff --git a/packages/cli/src/sunat-rest/consulta-cpe.ts b/packages/cli/src/sunat-rest/consulta-cpe.ts new file mode 100644 index 0000000..24bb084 --- /dev/null +++ b/packages/cli/src/sunat-rest/consulta-cpe.ts @@ -0,0 +1,119 @@ +/** + * SUNAT Consulta Integrada de Comprobantes de Pago Electrónicos. + * + * POST /v1/contribuyente/contribuyentes/{ruc}/validarcomprobante + * + * Verifies the existence + status of a CPE in SUNAT's records, regardless + * of who issued it. Useful to: + * - Verify your own emitted CPEs (cross-check with cached CDR) + * - Verify vendor invoices BEFORE paying them (anti-fraud) + * + * Note: requires OAuth client_credentials with scope 'contribuyente'. + */ + +import { type OAuthCredentials, callRestApi } from "./oauth.ts"; + +export type CpeTipoCode = "01" | "03" | "07" | "08" | "20" | "40" | "R1" | "R7" | "09"; + +export interface ConsultaCpeInput { + rucConsultante: string; // RUC del que pregunta (usually own RUC) + rucEmisor: string; // RUC del emisor del comprobante + tipoComprobante: CpeTipoCode; // 01=Factura, 03=Boleta, 07=NC, 08=ND, 09=Guia, 20=Retencion, 40=Percepcion + serie: string; + numero: number; + fechaEmision: string; // DD/MM/YYYY + monto?: number; // Optional, must match exactly to 2 decimals +} + +export interface ConsultaCpeResponseData { + estadoCp: string; // "0001" Aceptado, "0002" Anulado, "0003" Autorizada, "0004" No Autorizada + estadoRuc: string; // "00" Activo, "01" Baja + condDomiRuc: string; // "00" Habido, "09" No Habido + observaciones?: string[]; +} + +export interface ConsultaCpeResponse { + success: boolean; + message: string; + data?: ConsultaCpeResponseData; + errorCode?: string; +} + +export interface FriendlyConsultaResult { + exists: boolean; + estadoCp: string; + estadoCpDesc: string; + estadoRuc: string; + estadoRucDesc: string; + condDomiRuc: string; + condDomiRucDesc: string; + observaciones: string[]; + raw: ConsultaCpeResponse; +} + +const ESTADO_CP_MAP: Record = { + "0001": "Aceptado", + "0002": "Anulado", + "0003": "Autorizada", + "0004": "No Autorizada", +}; + +const ESTADO_RUC_MAP: Record = { + "00": "Activo", + "01": "Baja Provisional", + "02": "Baja Provisional por Oficio", + "03": "Suspension Temporal", + "10": "Baja Definitiva", + "11": "Baja de Oficio", + "22": "Inhabilitado", +}; + +const CONDICION_MAP: Record = { + "00": "Habido", + "09": "Pendiente", + "11": "Por verificar", + "12": "No Hallado", + "20": "No Habido", +}; + +function ddmmyyyy(iso: string): string { + const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!m) return iso; // assume already DD/MM/YYYY + return `${m[3]}/${m[2]}/${m[1]}`; +} + +export async function validarComprobante( + input: ConsultaCpeInput, + creds: OAuthCredentials, +): Promise { + const body: Record = { + numRuc: input.rucEmisor, + codComp: input.tipoComprobante, + numeroSerie: input.serie, + numero: String(input.numero), + fechaEmision: ddmmyyyy(input.fechaEmision), + }; + if (typeof input.monto === "number") { + body.monto = input.monto.toFixed(2); + } + + const resp = await callRestApi({ + creds, + method: "POST", + path: `/contribuyente/contribuyentes/${encodeURIComponent(input.rucConsultante)}/validarcomprobante`, + body, + }); + + const data = resp.data; + return { + exists: !!resp.success && !!data, + estadoCp: data?.estadoCp || "", + estadoCpDesc: data?.estadoCp ? ESTADO_CP_MAP[data.estadoCp] || data.estadoCp : "", + estadoRuc: data?.estadoRuc || "", + estadoRucDesc: data?.estadoRuc ? ESTADO_RUC_MAP[data.estadoRuc] || data.estadoRuc : "", + condDomiRuc: data?.condDomiRuc || "", + condDomiRucDesc: data?.condDomiRuc ? CONDICION_MAP[data.condDomiRuc] || data.condDomiRuc : "", + observaciones: data?.observaciones || [], + raw: resp, + }; +} diff --git a/packages/cli/src/sunat-rest/oauth.ts b/packages/cli/src/sunat-rest/oauth.ts new file mode 100644 index 0000000..d583a12 --- /dev/null +++ b/packages/cli/src/sunat-rest/oauth.ts @@ -0,0 +1,136 @@ +/** + * OAuth 2.0 client_credentials flow for SUNAT REST APIs. + * + * SUNAT exposes two host families: + * - api-seguridad.sunat.gob.pe — token endpoint + * - api.sunat.gob.pe — operational endpoints (consulta CPE, padron, etc) + * + * Tokens last 1 hour. Cached in-process; refreshed on 401 or near-expiry. + * + * Credentials (client_id + client_secret) are obtained from SUNAT SOL menu: + * Mi RUC y Otros Registros → Apps Móviles → Credenciales API + */ + +const SECURITY_BASE = "https://api-seguridad.sunat.gob.pe/v1"; +const API_BASE = "https://api.sunat.gob.pe/v1"; + +export interface OAuthCredentials { + clientId: string; + clientSecret: string; + scope?: string; +} + +interface CachedToken { + accessToken: string; + expiresAt: number; // epoch ms +} + +const tokenCache = new Map(); + +function cacheKey(clientId: string, scope: string): string { + return `${clientId}::${scope}`; +} + +export const SUNAT_REST_BASES = { + security: SECURITY_BASE, + api: API_BASE, +} as const; + +export const SCOPES = { + contribuyente: "https://api.sunat.gob.pe/v1/contribuyente/contribuyentes", + gre: "https://api.sunat.gob.pe/v1/contribuyente/gem/comprobantes", +} as const; + +export async function getAccessToken(creds: OAuthCredentials): Promise { + const scope = creds.scope || SCOPES.contribuyente; + const key = cacheKey(creds.clientId, scope); + const cached = tokenCache.get(key); + + // Refresh 60s before actual expiry + if (cached && cached.expiresAt > Date.now() + 60_000) { + return cached.accessToken; + } + + const tokenUrl = `${SECURITY_BASE}/clientesextranet/${encodeURIComponent(creds.clientId)}/oauth2/token/`; + const body = new URLSearchParams({ + grant_type: "client_credentials", + scope, + client_id: creds.clientId, + client_secret: creds.clientSecret, + }); + + const resp = await fetch(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body, + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`SUNAT OAuth ${resp.status}: ${text.slice(0, 300)}`); + } + + const json = (await resp.json()) as { access_token: string; token_type: string; expires_in: number }; + if (!json.access_token) throw new Error(`SUNAT OAuth response missing access_token: ${JSON.stringify(json)}`); + + const token: CachedToken = { + accessToken: json.access_token, + expiresAt: Date.now() + (json.expires_in - 60) * 1000, + }; + tokenCache.set(key, token); + return token.accessToken; +} + +export function clearTokenCache(): void { + tokenCache.clear(); +} + +export interface RestRequestOptions { + creds: OAuthCredentials; + method?: "GET" | "POST" | "PUT" | "DELETE"; + path: string; // path without /v1 prefix, starts with /contribuyente/... + body?: unknown; + query?: Record; +} + +export async function callRestApi(opts: RestRequestOptions): Promise { + const token = await getAccessToken(opts.creds); + const url = new URL(`${API_BASE}${opts.path}`); + if (opts.query) { + for (const [k, v] of Object.entries(opts.query)) { + if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); + } + } + + const init: RequestInit = { + method: opts.method || "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }; + if (opts.body !== undefined) init.body = JSON.stringify(opts.body); + + let resp = await fetch(url, init); + + // Refresh on 401 once + if (resp.status === 401) { + clearTokenCache(); + const fresh = await getAccessToken(opts.creds); + (init.headers as Record).Authorization = `Bearer ${fresh}`; + resp = await fetch(url, init); + } + + const text = await resp.text(); + if (!resp.ok) { + throw new Error(`SUNAT API ${resp.status} on ${opts.path}: ${text.slice(0, 500)}`); + } + + if (!text) return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } +} diff --git a/packages/cli/src/sunat-rest/padron-local.ts b/packages/cli/src/sunat-rest/padron-local.ts new file mode 100644 index 0000000..625997a --- /dev/null +++ b/packages/cli/src/sunat-rest/padron-local.ts @@ -0,0 +1,276 @@ +/** + * SUNAT Padrón Reducido del RUC — local downloader + parser + index. + * + * Source: http://www2.sunat.gob.pe/padron_reducido_ruc.zip (~370MB ZIP, ~600MB TXT) + * + * The padrón reducido is published daily by SUNAT and contains the public + * subset of the RUC registry: ruc, razón social, estado, condición, dirección, + * etc. It's the same data the e-consultaruc portal exposes for individual RUCs, + * but distributable as a single file. No auth, no captcha. + * + * Strategy: + * - Download to ~/.sunat/cache/padron_reducido_ruc.zip if missing or > 24h old + * - Parse the TXT inside the ZIP into a per-RUC index file (NDJSON) + * - Lookup is O(log N) via streaming scan keyed on the 11-char RUC prefix + * + * For PR #3 we ship the downloader + a streaming lookup. A faster sqlite + * index is shaped for a follow-up PR. + */ + +import { createHash } from "crypto"; +import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; +import { join } from "path"; +import { paths } from "../data/config.ts"; +import yauzl from "yauzl"; + +const PADRON_URL = "http://www2.sunat.gob.pe/padron_reducido_ruc.zip"; +const CACHE_DIR = join(paths.sunatDir, "cache"); +const ZIP_PATH = join(CACHE_DIR, "padron_reducido_ruc.zip"); +const TXT_PATH = join(CACHE_DIR, "padron_reducido_ruc.txt"); +const META_PATH = join(CACHE_DIR, "padron_meta.json"); +const STALE_AFTER_MS = 24 * 60 * 60 * 1000; + +export interface PadronEntry { + ruc: string; + razonSocial: string; + estado: string; // ACTIVO, BAJA PROVISIONAL, etc + condicion: string; // HABIDO, NO HABIDO, ... + tipoVia?: string; + nombreVia?: string; + codigoZona?: string; + tipoZona?: string; + numero?: string; + interior?: string; + lote?: string; + manzana?: string; + kilometro?: string; + departamento?: string; + provincia?: string; + distrito?: string; + ubigeo?: string; +} + +export interface PadronMeta { + lastFetchedAt: string; // ISO + zipSize: number; + zipSha256: string; + txtPath: string; + entries?: number; +} + +function ensureDir(): void { + if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true }); +} + +export function loadMeta(): PadronMeta | null { + if (!existsSync(META_PATH)) return null; + return JSON.parse(readFileSync(META_PATH, "utf-8")) as PadronMeta; +} + +function saveMeta(meta: PadronMeta): void { + writeFileSync(META_PATH, JSON.stringify(meta, null, 2)); +} + +export function isStale(meta: PadronMeta | null): boolean { + if (!meta) return true; + return Date.now() - new Date(meta.lastFetchedAt).getTime() > STALE_AFTER_MS; +} + +export interface SyncOptions { + force?: boolean; + onProgress?: (bytesDownloaded: number, totalBytes: number) => void; +} + +export async function syncPadron(opts: SyncOptions = {}): Promise { + ensureDir(); + const existing = loadMeta(); + if (!opts.force && existing && !isStale(existing) && existsSync(TXT_PATH)) { + return existing; + } + + // Download + const resp = await fetch(PADRON_URL); + if (!resp.ok || !resp.body) { + throw new Error(`Failed to download padrón: HTTP ${resp.status}`); + } + const total = Number.parseInt(resp.headers.get("content-length") || "0", 10); + const reader = resp.body.getReader(); + const out = createWriteStream(ZIP_PATH); + const hash = createHash("sha256"); + let downloaded = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + hash.update(value); + downloaded += value.length; + out.write(value); + opts.onProgress?.(downloaded, total); + } + } + await new Promise((resolve) => out.end(resolve)); + + // Extract TXT (single entry inside) + const entries = await unzipToTxt(ZIP_PATH, TXT_PATH); + + const meta: PadronMeta = { + lastFetchedAt: new Date().toISOString(), + zipSize: statSync(ZIP_PATH).size, + zipSha256: hash.digest("hex"), + txtPath: TXT_PATH, + entries, + }; + saveMeta(meta); + return meta; +} + +async function unzipToTxt(zipPath: string, outPath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => { + if (err || !zipfile) return reject(err || new Error("Cannot open padrón zip")); + let resolved = false; + zipfile.readEntry(); + zipfile.on("entry", (entry) => { + const isDir = /\/$/.test(entry.fileName); + if (isDir || !/\.txt$/i.test(entry.fileName)) { + zipfile.readEntry(); + return; + } + zipfile.openReadStream(entry, (e, stream) => { + if (e || !stream) return reject(e || new Error("No stream")); + // Pipe raw bytes straight to disk — no encoding conversion in the hot path. + // Lookup-time decoding handles latin1 → UTF-8. + const writer = createWriteStream(outPath); + stream.pipe(writer); + writer.on("finish", () => { + resolved = true; + // Estimate line count from byte size / avg row (~250B). Cheaper than counting. + const size = statSync(outPath).size; + resolve(Math.round(size / 250)); + }); + writer.on("error", reject); + stream.on("error", reject); + }); + }); + zipfile.on("end", () => { + if (!resolved) reject(new Error("No .txt entry in padrón zip")); + }); + zipfile.on("error", reject); + }); + }); +} + +/** + * Parse a single padrón line. The format is pipe-separated: + * RUC|RAZON_SOCIAL|ESTADO|CONDICION|UBIGEO|TIPO_VIA|NOMBRE_VIA|... + * + * Real format documented at orientacion.sunat.gob.pe; we keep the most useful + * fields and store the raw line for power users. + */ +export function parsePadronLine(line: string): PadronEntry | null { + const cols = line.split("|"); + if (cols.length < 4 || !/^\d{11}$/.test(cols[0])) return null; + return { + ruc: cols[0], + razonSocial: (cols[1] || "").trim(), + estado: (cols[2] || "").trim(), + condicion: (cols[3] || "").trim(), + ubigeo: (cols[4] || "").trim() || undefined, + tipoVia: (cols[5] || "").trim() || undefined, + nombreVia: (cols[6] || "").trim() || undefined, + codigoZona: (cols[7] || "").trim() || undefined, + tipoZona: (cols[8] || "").trim() || undefined, + numero: (cols[9] || "").trim() || undefined, + interior: (cols[10] || "").trim() || undefined, + lote: (cols[11] || "").trim() || undefined, + manzana: (cols[12] || "").trim() || undefined, + kilometro: (cols[13] || "").trim() || undefined, + }; +} + +/** + * Streaming lookup: scan the TXT for a single RUC. + * + * Slow path (~5-15s on 600MB file). Good enough for ad-hoc queries. For + * batch use, consider building a sqlite index (see padron-sqlite.ts shaping + * for a follow-up PR). + */ +export async function lookupRuc(ruc: string): Promise { + if (!/^\d{11}$/.test(ruc)) return null; + if (!existsSync(TXT_PATH)) { + throw new Error("Padrón not synced. Run: sunat padron sync"); + } + + return new Promise((resolve, reject) => { + const stream = createReadStream(TXT_PATH, { encoding: "latin1" }); + let buffer = ""; + const prefix = `${ruc}|`; + stream.on("data", (chunk) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (line.startsWith(prefix)) { + stream.destroy(); + resolve(parsePadronLine(line)); + return; + } + } + }); + stream.on("end", () => { + if (buffer.startsWith(prefix)) { + resolve(parsePadronLine(buffer)); + } else { + resolve(null); + } + }); + stream.on("error", reject); + }); +} + +/** + * Batch lookup: scan once, find many. + */ +export async function lookupRucBatch(rucs: string[]): Promise> { + const result = new Map(); + const wanted = new Set(); + for (const r of rucs) { + if (/^\d{11}$/.test(r)) { + result.set(r, null); + wanted.add(r); + } + } + if (wanted.size === 0) return result; + if (!existsSync(TXT_PATH)) throw new Error("Padrón not synced. Run: sunat padron sync"); + + return new Promise((resolve, reject) => { + const stream = createReadStream(TXT_PATH, { encoding: "latin1" }); + let buffer = ""; + stream.on("data", (chunk) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + const ruc = line.slice(0, 11); + if (wanted.has(ruc)) { + result.set(ruc, parsePadronLine(line)); + wanted.delete(ruc); + if (wanted.size === 0) { + stream.destroy(); + resolve(result); + return; + } + } + } + }); + stream.on("end", () => { + if (buffer) { + const ruc = buffer.slice(0, 11); + if (wanted.has(ruc)) result.set(ruc, parsePadronLine(buffer)); + } + resolve(result); + }); + stream.on("error", reject); + }); +} diff --git a/packages/cli/tests/unit/consulta-cpe.test.ts b/packages/cli/tests/unit/consulta-cpe.test.ts new file mode 100644 index 0000000..17724da --- /dev/null +++ b/packages/cli/tests/unit/consulta-cpe.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { validarComprobante } from "../../src/sunat-rest/consulta-cpe.ts"; +import { clearTokenCache } from "../../src/sunat-rest/oauth.ts"; + +const ORIGINAL_FETCH = global.fetch; + +beforeEach(() => clearTokenCache()); +afterEach(() => { + global.fetch = ORIGINAL_FETCH; +}); + +function mockFetch(impl: (url: string, init?: RequestInit) => Promise): void { + global.fetch = mock(async (url, init) => impl(String(url), init as RequestInit)); +} + +const creds = { clientId: "cid", clientSecret: "csec" }; + +describe("validarComprobante", () => { + test("posts to /contribuyentes/{ruc}/validarcomprobante with correct body", async () => { + const seen: { url?: string; body?: string } = {}; + mockFetch(async (url, init) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + seen.url = url; + seen.body = String(init?.body || ""); + return new Response( + JSON.stringify({ + success: true, + message: "OK", + data: { estadoCp: "0001", estadoRuc: "00", condDomiRuc: "00" }, + }), + { status: 200 }, + ); + }); + const result = await validarComprobante( + { + rucConsultante: "20111111111", + rucEmisor: "20222222222", + tipoComprobante: "01", + serie: "F001", + numero: 1234, + fechaEmision: "2026-04-29", + monto: 118, + }, + creds, + ); + expect(seen.url).toContain("/contribuyente/contribuyentes/20111111111/validarcomprobante"); + const body = JSON.parse(seen.body || "{}"); + expect(body.numRuc).toBe("20222222222"); + expect(body.codComp).toBe("01"); + expect(body.numeroSerie).toBe("F001"); + expect(body.numero).toBe("1234"); + expect(body.fechaEmision).toBe("29/04/2026"); // ISO converted to DD/MM/YYYY + expect(body.monto).toBe("118.00"); + expect(result.exists).toBe(true); + expect(result.estadoCpDesc).toBe("Aceptado"); + expect(result.estadoRucDesc).toBe("Activo"); + expect(result.condDomiRucDesc).toBe("Habido"); + }); + + test("converts already-DD/MM/YYYY date as-is", async () => { + let body: Record = {}; + mockFetch(async (url, init) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + body = JSON.parse(String(init?.body || "{}")); + return new Response(JSON.stringify({ success: true, message: "OK", data: { estadoCp: "0001", estadoRuc: "00", condDomiRuc: "00" } }), { status: 200 }); + }); + await validarComprobante( + { rucConsultante: "20111111111", rucEmisor: "20222222222", tipoComprobante: "01", serie: "F001", numero: 1, fechaEmision: "29/04/2026" }, + creds, + ); + expect(body.fechaEmision).toBe("29/04/2026"); + }); + + test("omits monto when not provided", async () => { + let body: Record = {}; + mockFetch(async (url, init) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + body = JSON.parse(String(init?.body || "{}")); + return new Response(JSON.stringify({ success: true, message: "OK", data: { estadoCp: "0001", estadoRuc: "00", condDomiRuc: "00" } }), { status: 200 }); + }); + await validarComprobante( + { rucConsultante: "20111111111", rucEmisor: "20222222222", tipoComprobante: "01", serie: "F001", numero: 1, fechaEmision: "2026-04-29" }, + creds, + ); + expect("monto" in body).toBe(false); + }); + + test("maps unknown estado codes to the raw code", async () => { + mockFetch(async (url) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + return new Response( + JSON.stringify({ success: true, message: "OK", data: { estadoCp: "9999", estadoRuc: "ZZ", condDomiRuc: "QQ" } }), + { status: 200 }, + ); + }); + const r = await validarComprobante( + { rucConsultante: "20111111111", rucEmisor: "20222222222", tipoComprobante: "01", serie: "F001", numero: 1, fechaEmision: "2026-04-29" }, + creds, + ); + expect(r.estadoCpDesc).toBe("9999"); + expect(r.estadoRucDesc).toBe("ZZ"); + expect(r.condDomiRucDesc).toBe("QQ"); + }); + + test("returns exists=false when SUNAT response has no data", async () => { + mockFetch(async (url) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + return new Response(JSON.stringify({ success: false, message: "NOT FOUND", errorCode: "404" }), { status: 200 }); + }); + const r = await validarComprobante( + { rucConsultante: "20111111111", rucEmisor: "20222222222", tipoComprobante: "01", serie: "F001", numero: 9, fechaEmision: "2026-04-29" }, + creds, + ); + expect(r.exists).toBe(false); + }); + + test("formats monto to exactly 2 decimals", async () => { + let body: Record = {}; + mockFetch(async (url, init) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + body = JSON.parse(String(init?.body || "{}")); + return new Response(JSON.stringify({ success: true, message: "OK", data: { estadoCp: "0001", estadoRuc: "00", condDomiRuc: "00" } }), { status: 200 }); + }); + await validarComprobante( + { rucConsultante: "20111111111", rucEmisor: "20222222222", tipoComprobante: "01", serie: "F001", numero: 1, fechaEmision: "2026-04-29", monto: 118 }, + creds, + ); + expect(body.monto).toBe("118.00"); + }); +}); diff --git a/packages/cli/tests/unit/oauth.test.ts b/packages/cli/tests/unit/oauth.test.ts new file mode 100644 index 0000000..968d0d6 --- /dev/null +++ b/packages/cli/tests/unit/oauth.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { SCOPES, SUNAT_REST_BASES, callRestApi, clearTokenCache, getAccessToken } from "../../src/sunat-rest/oauth.ts"; + +const ORIGINAL_FETCH = global.fetch; + +beforeEach(() => { + clearTokenCache(); +}); + +afterEach(() => { + global.fetch = ORIGINAL_FETCH; +}); + +function mockFetch(impl: (url: string, init?: RequestInit) => Promise): void { + global.fetch = mock(async (url, init) => impl(String(url), init as RequestInit)); +} + +describe("SUNAT_REST_BASES + SCOPES", () => { + test("security base is api-seguridad", () => { + expect(SUNAT_REST_BASES.security).toContain("api-seguridad.sunat.gob.pe"); + }); + test("api base is api.sunat.gob.pe", () => { + expect(SUNAT_REST_BASES.api).toContain("api.sunat.gob.pe"); + }); + test("contribuyente scope present", () => { + expect(SCOPES.contribuyente).toContain("/contribuyente/contribuyentes"); + }); +}); + +describe("getAccessToken", () => { + test("posts client_credentials to token endpoint and returns access_token", async () => { + let capturedUrl = ""; + let capturedBody = ""; + mockFetch(async (url, init) => { + capturedUrl = url; + capturedBody = String(init?.body || ""); + return new Response(JSON.stringify({ access_token: "tk-abc", token_type: "Bearer", expires_in: 3600 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const token = await getAccessToken({ clientId: "cid", clientSecret: "csec" }); + expect(token).toBe("tk-abc"); + expect(capturedUrl).toContain("/clientesextranet/cid/oauth2/token/"); + expect(capturedBody).toContain("grant_type=client_credentials"); + expect(capturedBody).toContain("client_id=cid"); + expect(capturedBody).toContain("client_secret=csec"); + expect(capturedBody).toContain("scope=https"); + }); + + test("caches token within expiry window (subsequent calls do not refetch)", async () => { + let calls = 0; + mockFetch(async () => { + calls += 1; + return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + }); + await getAccessToken({ clientId: "cid", clientSecret: "csec" }); + await getAccessToken({ clientId: "cid", clientSecret: "csec" }); + await getAccessToken({ clientId: "cid", clientSecret: "csec" }); + expect(calls).toBe(1); + }); + + test("refetches when forced via clearTokenCache", async () => { + let calls = 0; + mockFetch(async () => { + calls += 1; + return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + }); + await getAccessToken({ clientId: "cid", clientSecret: "csec" }); + clearTokenCache(); + await getAccessToken({ clientId: "cid", clientSecret: "csec" }); + expect(calls).toBe(2); + }); + + test("throws on non-200 with body excerpt", async () => { + mockFetch(async () => new Response("invalid client", { status: 401 })); + expect(getAccessToken({ clientId: "cid", clientSecret: "csec" })).rejects.toThrow(/SUNAT OAuth 401/); + }); + + test("throws on missing access_token in response", async () => { + mockFetch(async () => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 200 })); + expect(getAccessToken({ clientId: "cid", clientSecret: "csec" })).rejects.toThrow(/access_token/); + }); + + test("uses custom scope when provided", async () => { + let capturedBody = ""; + mockFetch(async (_url, init) => { + capturedBody = String(init?.body || ""); + return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + }); + await getAccessToken({ clientId: "cid", clientSecret: "csec", scope: "custom-scope" }); + expect(capturedBody).toContain("scope=custom-scope"); + }); +}); + +describe("callRestApi", () => { + test("attaches Bearer token + correct headers", async () => { + const seen: { url?: string; auth?: string; ct?: string } = {}; + mockFetch(async (url, init) => { + if (url.includes("/oauth2/token")) { + return new Response(JSON.stringify({ access_token: "abc", expires_in: 3600 }), { status: 200 }); + } + seen.url = url; + const h = (init?.headers as Record) || {}; + seen.auth = h.Authorization; + seen.ct = h["Content-Type"]; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + const r = await callRestApi<{ ok: boolean }>({ + creds: { clientId: "cid", clientSecret: "csec" }, + path: "/contribuyente/test", + }); + expect(r.ok).toBe(true); + expect(seen.auth).toBe("Bearer abc"); + expect(seen.ct).toBe("application/json"); + expect(seen.url).toContain("/v1/contribuyente/test"); + }); + + test("appends query params", async () => { + let seenUrl = ""; + mockFetch(async (url) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "x", expires_in: 3600 }), { status: 200 }); + seenUrl = url; + return new Response("{}", { status: 200 }); + }); + await callRestApi({ + creds: { clientId: "c", clientSecret: "s" }, + path: "/contribuyente/x", + query: { fecha: "2026-04-29", monto: 118 }, + }); + expect(seenUrl).toContain("fecha=2026-04-29"); + expect(seenUrl).toContain("monto=118"); + }); + + test("retries once after 401 with fresh token", async () => { + let attempts = 0; + let tokenCalls = 0; + mockFetch(async (url) => { + if (url.includes("token")) { + tokenCalls += 1; + return new Response(JSON.stringify({ access_token: `tk-${tokenCalls}`, expires_in: 3600 }), { status: 200 }); + } + attempts += 1; + if (attempts === 1) return new Response("expired", { status: 401 }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + const r = await callRestApi<{ ok: boolean }>({ + creds: { clientId: "c", clientSecret: "s" }, + path: "/contribuyente/x", + }); + expect(r.ok).toBe(true); + expect(attempts).toBe(2); + expect(tokenCalls).toBe(2); + }); + + test("throws on non-401 error with status + path", async () => { + mockFetch(async (url) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "x", expires_in: 3600 }), { status: 200 }); + return new Response("not found", { status: 404 }); + }); + expect( + callRestApi({ creds: { clientId: "c", clientSecret: "s" }, path: "/x" }), + ).rejects.toThrow(/SUNAT API 404 on \/x/); + }); +}); diff --git a/packages/cli/tests/unit/padron-local.test.ts b/packages/cli/tests/unit/padron-local.test.ts new file mode 100644 index 0000000..425b99a --- /dev/null +++ b/packages/cli/tests/unit/padron-local.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { isStale, parsePadronLine } from "../../src/sunat-rest/padron-local.ts"; + +describe("parsePadronLine", () => { + test("parses canonical 13-column padrón line", () => { + const line = "20131312955|MINISTERIO DE EDUCACION|ACTIVO|HABIDO|150101|AV.|JAVIER PRADO ESTE| | |1234| |LOTE 1| | "; + const e = parsePadronLine(line); + expect(e?.ruc).toBe("20131312955"); + expect(e?.razonSocial).toBe("MINISTERIO DE EDUCACION"); + expect(e?.estado).toBe("ACTIVO"); + expect(e?.condicion).toBe("HABIDO"); + expect(e?.ubigeo).toBe("150101"); + expect(e?.tipoVia).toBe("AV."); + expect(e?.nombreVia).toBe("JAVIER PRADO ESTE"); + expect(e?.numero).toBe("1234"); + }); + + test("rejects non-RUC lines (header etc)", () => { + expect(parsePadronLine("RUC|RAZON|ESTADO|CONDICION")).toBeNull(); + expect(parsePadronLine("")).toBeNull(); + expect(parsePadronLine("not a line")).toBeNull(); + }); + + test("requires exactly 11-digit RUC", () => { + expect(parsePadronLine("12345|X|A|H")).toBeNull(); + expect(parsePadronLine("1234567890123|X|A|H")).toBeNull(); + }); + + test("trims fields and treats empty optionals as undefined", () => { + const line = "20100000001|EMPRESA SAC | ACTIVO | HABIDO | 150101 | | | | | | | | | "; + const e = parsePadronLine(line); + expect(e?.razonSocial).toBe("EMPRESA SAC"); + expect(e?.estado).toBe("ACTIVO"); + expect(e?.tipoVia).toBeUndefined(); + }); +}); + +describe("isStale", () => { + test("null meta is stale", () => { + expect(isStale(null)).toBe(true); + }); + test("fresh meta (now) is not stale", () => { + expect(isStale({ lastFetchedAt: new Date().toISOString(), zipSize: 1, zipSha256: "x", txtPath: "/x" })).toBe(false); + }); + test("meta older than 24h is stale", () => { + const old = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); + expect(isStale({ lastFetchedAt: old, zipSize: 1, zipSha256: "x", txtPath: "/x" })).toBe(true); + }); + test("meta from 12h ago is not stale", () => { + const ago = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(); + expect(isStale({ lastFetchedAt: ago, zipSize: 1, zipSha256: "x", txtPath: "/x" })).toBe(false); + }); +});