diff --git a/packages/cli/README.md b/packages/cli/README.md index 8575f28..82ef444 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -31,6 +31,29 @@ sunat-cli f616 declare --dry-run --json '{"periodo":"2025-03"}' sunat-cli api token --output json # OAuth2 token ``` +### SIRE — Registro de Ventas (RVIE) y Compras (RCE) + +Mandatory monthly tax filing automation. Replaces the SOL portal SIRE workflow. + +```bash +# Setup (once) +export SUNAT_API_CLIENT_ID=... # SOL → Credenciales API SUNAT, URI = "MIGE RCE y RVIE - SIRE" +export SUNAT_API_CLIENT_SECRET=... +export SUNAT_RUC=... +export SUNAT_USER=... +export SUNAT_PASSWORD=... + +# Monthly RVIE (Ventas) +sunat-cli sire ventas periodos +sunat-cli sire ventas propuesta --periodo 202404 --wait --out propuesta-202404.zip +sunat-cli sire ventas aceptar --periodo 202404 --yes +sunat-cli sire ventas descargar --periodo 202404 --wait --out rvie-202404.zip + +# RCE (Compras) — same flow +sunat-cli sire compras periodos +sunat-cli sire compras propuesta --periodo 202404 --wait --out compras-202404.zip +``` + ### Padrón Reducido del RUC (offline lookup, no auth) ```bash diff --git a/packages/cli/bin/sunat.ts b/packages/cli/bin/sunat.ts index 66e23dc..5e02e15 100755 --- a/packages/cli/bin/sunat.ts +++ b/packages/cli/bin/sunat.ts @@ -9,6 +9,7 @@ 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"; +import { createSireCommand } from "../src/commands/sire/index.ts"; const program = new Command(); @@ -33,5 +34,6 @@ program.addCommand(createApiCommand()); program.addCommand(createLukeaCommand()); program.addCommand(createCpeCommand()); program.addCommand(createPadronCommand()); +program.addCommand(createSireCommand()); program.parse(); diff --git a/packages/cli/skills/sunat-cli/SKILL.md b/packages/cli/skills/sunat-cli/SKILL.md index 8892497..9348116 100644 --- a/packages/cli/skills/sunat-cli/SKILL.md +++ b/packages/cli/skills/sunat-cli/SKILL.md @@ -235,6 +235,54 @@ sunat cpe consulta \ # Returns: estadoCp (Aceptado/Anulado), estadoRuc (Activo/Baja), condDomiRuc (Habido/No Habido) ``` +### SIRE — Registro de Ventas (RVIE) y Compras (RCE) electrónicos + +**Mandatory monthly filing** for all CPE emisores in Peru since 2024. SIRE +replaces the old PLE libros and is **the** monthly tax dolor for any +empresa. This automates the SUNAT portal SIRE workflow end-to-end. + +Setup once: +```bash +# Get credenciales API SUNAT from SOL → Mi RUC → Credenciales API SUNAT +# When registering, select URI: "MIGE RCE y RVIE - SIRE" +export SUNAT_API_CLIENT_ID=... +export SUNAT_API_CLIENT_SECRET=... +# SIRE also needs SOL credentials (different OAuth flow vs CPE consulta) +export SUNAT_RUC=20131312955 +export SUNAT_USER=MODDATOS +export SUNAT_PASSWORD='clave-sol' +``` + +Monthly RVIE (Ventas) workflow: +```bash +# 1. See available periodos +sunat sire ventas periodos + +# 2. Download SUNAT's pre-built proposal for the period (async — returns ticket) +sunat sire ventas propuesta --periodo 202404 --wait --out propuesta-202404.zip + +# 3. Review the .zip contents (TXT con todos tus comprobantes) + +# 4a. Accept as-is +sunat sire ventas aceptar --periodo 202404 --yes + +# 4b. Or replace with your own (T2): use --reemplazar (shaped, see RESEARCH) + +# 5. Download the final RVIE PDF/TXT once accepted +sunat sire ventas descargar --periodo 202404 --wait --out rvie-202404.zip +``` + +Same flow for RCE (Compras): +```bash +sunat sire compras periodos +sunat sire compras propuesta --periodo 202404 --wait --out compras-202404.zip +sunat sire compras ticket --num 20240100000123 --wait +``` + +Polling: `--wait` polls getStatus with backoff (2s/4s/8s/16s/30s, max 5min). +Without `--wait`, returns the ticket and you poll independently with +`sunat sire {ventas|compras} ticket --num [--wait]`. + ### Padrón Reducido del RUC (offline) Local copy of the SUNAT RUC registry. ~370MB ZIP, ~600MB TXT, ~3.5M entries. diff --git a/packages/cli/src/commands/sire/index.ts b/packages/cli/src/commands/sire/index.ts new file mode 100644 index 0000000..753719d --- /dev/null +++ b/packages/cli/src/commands/sire/index.ts @@ -0,0 +1,253 @@ +import { Command } from "commander"; +import { writeFileSync } from "fs"; +import { audit } from "../../data/audit.ts"; +import { + COD_LIBRO, + type CodLibro, + aceptarPropuestaRvie, + consultarTicket, + descargarArchivo, + descargarPropuesta, + descargarRvie, + listarPeriodos, + pollTicket, + sireCredentials, +} from "../../sunat-rest/sire.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 resolveSireCreds(): ReturnType { + const clientId = process.env.SUNAT_API_CLIENT_ID; + const clientSecret = process.env.SUNAT_API_CLIENT_SECRET; + const ruc = process.env.SUNAT_RUC || process.env.CPE_EMISOR_RUC; + const solUsuario = process.env.SUNAT_USER || process.env.CPE_SOL_USUARIO; + const solPassword = process.env.SUNAT_PASSWORD || process.env.CPE_SOL_PASSWORD; + if (!clientId) throw new Error("SUNAT_API_CLIENT_ID env var missing (from SOL → Credenciales API SUNAT, MIGE RCE y RVIE - SIRE)"); + if (!clientSecret) throw new Error("SUNAT_API_CLIENT_SECRET env var missing"); + if (!ruc) throw new Error("SUNAT_RUC env var missing"); + if (!solUsuario) throw new Error("SUNAT_USER env var missing (SOL usuario, NOT the password)"); + if (!solPassword) throw new Error("SUNAT_PASSWORD env var missing (Clave SOL)"); + return sireCredentials({ clientId, clientSecret, ruc, solUsuario, solPassword }); +} + +function bookCommand(libroAlias: "ventas" | "compras", codLibro: CodLibro): Command { + const longName = libroAlias === "ventas" ? "Registro de Ventas e Ingresos (RVIE)" : "Registro de Compras (RCE)"; + const sub = new Command(libroAlias).description(`SIRE ${longName} — codLibro=${codLibro}`); + + sub + .command("periodos") + .description("Listar ejercicios y periodos disponibles. T0.") + .action(async (_, cmd) => { + const format = getFormat(cmd); + try { + const creds = resolveSireCreds(); + const ejercicios = await listarPeriodos(codLibro, creds); + output(format, { json: { codLibro, ejercicios } }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + sub + .command("propuesta") + .description("Descargar la propuesta SUNAT del periodo (async — returns ticket). T1.") + .requiredOption("--periodo ", "Periodo tributario, e.g. 202404") + .option("--wait", "Poll ticket until completed/error") + .option("--timeout ", "Polling timeout (default 300000 = 5min)", "300000") + .option("--out ", "When --wait + completed, download the resulting file to this path") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + const creds = resolveSireCreds(); + const numTicket = await descargarPropuesta({ codLibro, perTributario: opts.periodo }, creds); + + if (!opts.wait) { + audit({ command: `sire ${libroAlias} propuesta`, args: { periodo: opts.periodo }, result: "success", details: { numTicket } }); + output(format, { + json: { + numTicket, + hint: `Poll status with: sunat sire ${libroAlias} ticket --num ${numTicket}`, + }, + }); + return; + } + + const result = await pollTicket({ + creds, + numTicket, + timeoutMs: Number.parseInt(opts.timeout, 10), + }); + + if (result.state !== "completed") { + output(format, { json: { numTicket, state: result.state, statusCode: result.statusCode, statusDesc: result.statusDesc } }); + return; + } + + const archivos = result.archivoReporte || []; + if (opts.out && archivos[0]) { + const buf = await descargarArchivo( + { + nomArchivoReporte: archivos[0].nomArchivoReporte, + codTipoArchivoReporte: archivos[0].codTipoArchivoReporte || "0", + codLibro, + perTributario: opts.periodo, + }, + creds, + ); + writeFileSync(opts.out, buf); + output(format, { + json: { + numTicket, + state: result.state, + statusDesc: result.statusDesc, + file: opts.out, + bytes: buf.length, + archivoReporte: archivos[0].nomArchivoReporte, + }, + }); + return; + } + + output(format, { + json: { + numTicket, + state: result.state, + statusDesc: result.statusDesc, + archivoReporte: archivos, + hint: archivos[0] ? `Download with: sunat sire ${libroAlias} archivo --nombre ${archivos[0].nomArchivoReporte} --periodo ${opts.periodo} --out path` : undefined, + }, + }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + sub + .command("ticket") + .description("Consultar estado de un ticket SIRE. T0.") + .requiredOption("--num ", "Número de ticket") + .option("--wait", "Poll until completed/error") + .option("--timeout ", "Polling timeout", "300000") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + const creds = resolveSireCreds(); + if (opts.wait) { + const result = await pollTicket({ creds, numTicket: opts.num, timeoutMs: Number.parseInt(opts.timeout, 10) }); + output(format, { json: { numTicket: opts.num, ...result } }); + } else { + const status = await consultarTicket(opts.num, creds); + output(format, { json: status }); + } + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + sub + .command("archivo") + .description("Descargar un archivo previamente generado por un ticket Terminado. T0.") + .requiredOption("--nombre ", "nomArchivoReporte from a completed ticket") + .requiredOption("--periodo ") + .requiredOption("--out ", "Path to write the file") + .option("--tipo ", "codTipoArchivoReporte (default 0 = TXT)", "0") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + const creds = resolveSireCreds(); + const buf = await descargarArchivo( + { + nomArchivoReporte: opts.nombre, + codTipoArchivoReporte: opts.tipo, + codLibro, + perTributario: opts.periodo, + }, + creds, + ); + writeFileSync(opts.out, buf); + output(format, { json: { file: opts.out, bytes: buf.length } }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + if (libroAlias === "ventas") { + sub + .command("aceptar") + .description("Aceptar la propuesta SUNAT como preliminar (RVIE). T2.") + .requiredOption("--periodo ") + .option("--yes", "Skip T2 confirmation") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + if (!opts.yes) { + outputError("T2 — requires --yes. This commits the proposal to SUNAT as your preliminar registro.", format); + return; + } + const creds = resolveSireCreds(); + const result = await aceptarPropuestaRvie(opts.periodo, creds); + audit({ command: "sire ventas aceptar", args: { periodo: opts.periodo }, result: "success", details: result as unknown as Record }); + output(format, { json: { ...result, hint: `Poll status with: sunat sire ventas ticket --num ${result.numTicket}` } }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + sub + .command("descargar") + .description("Descargar el RVIE generado del periodo (async). T0.") + .requiredOption("--periodo ") + .option("--wait") + .option("--timeout ", "Polling timeout", "300000") + .option("--out ") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + const creds = resolveSireCreds(); + const numTicket = await descargarRvie(opts.periodo, creds); + if (!opts.wait) { + output(format, { json: { numTicket, hint: `Poll: sunat sire ventas ticket --num ${numTicket}` } }); + return; + } + const result = await pollTicket({ creds, numTicket, timeoutMs: Number.parseInt(opts.timeout, 10) }); + if (result.state === "completed" && opts.out && result.archivoReporte?.[0]) { + const buf = await descargarArchivo( + { + nomArchivoReporte: result.archivoReporte[0].nomArchivoReporte, + codTipoArchivoReporte: result.archivoReporte[0].codTipoArchivoReporte || "0", + codLibro, + perTributario: opts.periodo, + }, + creds, + ); + writeFileSync(opts.out, buf); + output(format, { json: { numTicket, ...result, file: opts.out, bytes: buf.length } }); + return; + } + output(format, { json: { numTicket, ...result } }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + } + + return sub; +} + +export function createSireCommand(): Command { + const sire = new Command("sire").description("SUNAT SIRE — Registro de Ventas (RVIE) y Compras (RCE) electrónicos. T0/T1/T2."); + sire.addCommand(bookCommand("ventas", COD_LIBRO.rvie)); + sire.addCommand(bookCommand("compras", COD_LIBRO.rce)); + return sire; +} diff --git a/packages/cli/src/sunat-rest/SIRE-RESEARCH.md b/packages/cli/src/sunat-rest/SIRE-RESEARCH.md new file mode 100644 index 0000000..23f6fe5 --- /dev/null +++ b/packages/cli/src/sunat-rest/SIRE-RESEARCH.md @@ -0,0 +1,97 @@ +# SIRE — Sistema Integrado de Registros Electrónicos + +Research notes for `src/sunat-rest/sire.ts` (added in PR #4). + +## What this PR ships + +| Capability | Method | Verified shape source | +|------------|--------|----------------------| +| OAuth password grant (SIRE-specific) | `POST /v1/clientessol/{cid}/oauth2/token/` | Manual SUNAT v22 page 21 | +| Listar periodos RVIE/RCE | `GET /v1/contribuyente/migeigv/libros/rvierce/padron/web/omisos/{codLibro}/periodos` | Manual page 22 | +| Aceptar propuesta RVIE | `POST /v1/contribuyente/migeigv/libros/rvie/propuesta/web/propuesta/{periodo}/aceptapropuesta` | Manual page 31 | +| Descargar propuesta RVIE (async) | `GET /v1/contribuyente/migeigv/libros/rvierce/gestionprocesosmasivos/web/masivo/exportapropuesta` | Manual page 45 | +| Descargar propuesta RCE (async) | `GET /v1/contribuyente/migeigv/libros/rce/propuesta/web/propuesta/{periodo}/exportacioncomprobantepropuesta` | Manual SIRE Compras | +| Descargar RVIE generado (async) | `GET /v1/contribuyente/migeigv/libros/rvierce/gestionprocesosmasivos/web/masivo/exportarregistropropuesta` | Manual page 57 | +| Consultar estado ticket | `GET /v1/contribuyente/migeigv/libros/rvierce/gestionprocesosmasivos/web/masivo/consultaestadotickets` | Manual page 40 | +| Descargar archivo generado | `GET /v1/contribuyente/migeigv/libros/rvierce/gestionprocesosmasivos/web/masivo/archivoreporte` | Manual page 43 | + +## What this PR does NOT ship + +- **Reemplazar propuesta** — uses TUS.IO protocol (resumable file upload) which + needs Java per SUNAT's own note. Shaped for follow-up PR with a TUS client. +- **Importar nuevos comprobantes propuesta/preliminar** — also TUS.IO. +- **Importar ajustes posteriores** — also TUS.IO. +- **Tipo de cambio masivo** — JSON POST, easy to add but not the highest-value flow. +- **Reportes complementarios** (resumen, inconsistencias, CAR, casillas, etc) — + add as needed; same async ticket pattern. +- **SUNAT's note**: "los servicios del API SIRE no deben ser consumidos desde un + cliente Web, en caso de utilizar un cliente Web se producirá error de CORS". + Server-side only. CLI is server-side, so we're fine. + +## OAuth flow (SIRE password grant) + +Different from CPE consulta (PR #3) which uses `client_credentials`: + +``` +POST https://api-seguridad.sunat.gob.pe/v1/clientessol/{client_id}/oauth2/token/ +Content-Type: application/x-www-form-urlencoded + +grant_type=password +&scope=https://api-sire.sunat.gob.pe +&client_id={client_id} +&client_secret={client_secret} +&username={RUC}{SOL_USER} ← concatenated, e.g. "20131312955MODDATOS" +&password={SOL_PASSWORD} ← Clave SOL real +``` + +Response same as PR #3: +```json +{ "access_token": "...", "token_type": "Bearer", "expires_in": 3600 } +``` + +`oauth.ts` was extended with an optional `username/password` pair — when both +are set, it switches grant type and endpoint automatically. CPE consulta calls +keep working unchanged. + +## Async ticket pattern + +Most write/heavy operations return a ticket. Flow: + +1. Trigger op (e.g. `descargarPropuesta`) → returns `numTicket` +2. Poll `consultarTicket(numTicket)` until `codEstadoProceso == "06"` (Terminado) +3. Use the returned `archivoReporte[].nomArchivoReporte` to download via + `descargarArchivo()` — that endpoint streams the actual ZIP/TXT bytes + +States (`codEstadoProceso`): +- `01` Iniciado +- `03` En proceso +- `06` Terminado ✅ +- `07` Error +- `08` ? +- `10` Terminado con error +- `98` (our internal) Polling timeout + +The CLI wraps this in `--wait [--timeout]` so the operator gets a single-call +UX. Without `--wait`, ticket is returned for manual polling later. + +## codLibro values + +``` +140000 = RVIE (Registro de Ventas e Ingresos) +080000 = RCE (Registro de Compras) +``` + +## Why SIRE matters + +Mandatory since 2024 for all electronic-invoice emisores in Peru. Multa per +late filing = up to 1 UIT (S/5,350 in 2026). Today contadores do this manually +in the SOL portal monthly. This automates 95% of the workflow. + +## Verified end-to-end + +Not yet verified against real SIRE because it requires a real RUC with active +billing history (RUC 20000000001 test cert from PR #1 doesn't have RVIE +periods). The XML/JSON shapes follow the official SUNAT manual v22 (March 2024). + +When you test with your own cert + credentials: the `--out` flag on `propuesta` ++ `--wait` should give you the working .zip on first call. diff --git a/packages/cli/src/sunat-rest/oauth.ts b/packages/cli/src/sunat-rest/oauth.ts index d583a12..17206d6 100644 --- a/packages/cli/src/sunat-rest/oauth.ts +++ b/packages/cli/src/sunat-rest/oauth.ts @@ -13,11 +13,19 @@ const SECURITY_BASE = "https://api-seguridad.sunat.gob.pe/v1"; const API_BASE = "https://api.sunat.gob.pe/v1"; +const SIRE_BASE = "https://api-sire.sunat.gob.pe/v1"; export interface OAuthCredentials { clientId: string; clientSecret: string; scope?: string; + /** + * SIRE uses password grant (instead of client_credentials) and requires + * the RUC + SOL_USER + SOL_PASSWORD on top of the client_id/secret. + * When `password` is set, we use the clientessol endpoint variant. + */ + username?: string; // {RUC}{SOL_USER} concatenated, e.g. "20131312955MODDATOS" + password?: string; // SOL password (clave SOL) } interface CachedToken { @@ -34,15 +42,18 @@ function cacheKey(clientId: string, scope: string): string { export const SUNAT_REST_BASES = { security: SECURITY_BASE, api: API_BASE, + sire: SIRE_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", + sire: "https://api-sire.sunat.gob.pe", } as const; export async function getAccessToken(creds: OAuthCredentials): Promise { - const scope = creds.scope || SCOPES.contribuyente; + const isPasswordGrant = !!(creds.username && creds.password); + const scope = creds.scope || (isPasswordGrant ? SCOPES.sire : SCOPES.contribuyente); const key = cacheKey(creds.clientId, scope); const cached = tokenCache.get(key); @@ -51,13 +62,20 @@ export async function getAccessToken(creds: OAuthCredentials): Promise { return cached.accessToken; } - const tokenUrl = `${SECURITY_BASE}/clientesextranet/${encodeURIComponent(creds.clientId)}/oauth2/token/`; - const body = new URLSearchParams({ - grant_type: "client_credentials", + const endpoint = isPasswordGrant ? "clientessol" : "clientesextranet"; + const tokenUrl = `${SECURITY_BASE}/${endpoint}/${encodeURIComponent(creds.clientId)}/oauth2/token/`; + const params: Record = { + grant_type: isPasswordGrant ? "password" : "client_credentials", scope, client_id: creds.clientId, client_secret: creds.clientSecret, - }); + }; + if (isPasswordGrant) { + // SIRE-specific: username = "{RUC}{SOL_USER}" + password = SOL password + params.username = creds.username as string; + params.password = creds.password as string; + } + const body = new URLSearchParams(params); const resp = await fetch(tokenUrl, { method: "POST", @@ -91,11 +109,14 @@ export interface RestRequestOptions { path: string; // path without /v1 prefix, starts with /contribuyente/... body?: unknown; query?: Record; + /** Override base URL: defaults to api.sunat.gob.pe; use "sire" for api-sire. */ + baseHost?: "api" | "sire"; } export async function callRestApi(opts: RestRequestOptions): Promise { const token = await getAccessToken(opts.creds); - const url = new URL(`${API_BASE}${opts.path}`); + const base = opts.baseHost === "sire" ? SIRE_BASE : API_BASE; + const url = new URL(`${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)); diff --git a/packages/cli/src/sunat-rest/sire.ts b/packages/cli/src/sunat-rest/sire.ts new file mode 100644 index 0000000..c0e21b8 --- /dev/null +++ b/packages/cli/src/sunat-rest/sire.ts @@ -0,0 +1,277 @@ +/** + * SIRE — Sistema Integrado de Registros Electrónicos. + * + * SUNAT module to handle the monthly Registro de Ventas e Ingresos (RVIE) + * and Registro de Compras (RCE) electronic books. + * + * Auth: OAuth 2.0 password grant against api-seguridad.sunat.gob.pe + * - client_id + client_secret (from SOL → Credenciales API SUNAT, + * URI scope = "MIGE RCE y RVIE - SIRE") + * - username = {RUC}{SOL_USER} concatenated, e.g. "20131312955MODDATOS" + * - password = SOL password + * - scope = "https://api-sire.sunat.gob.pe" + * + * Endpoints: api-sire.sunat.gob.pe + * + * Async pattern (most operations): + * 1. Trigger operation (e.g. descargar propuesta) → returns ticket + * 2. Poll ticket status until "Terminado" + * 3. Download generated file by name + */ + +import { type OAuthCredentials, callRestApi } from "./oauth.ts"; + +/** Catálogo SUNAT de libros */ +export const COD_LIBRO = { + rvie: "140000", // Registro de Ventas e Ingresos + rce: "080000", // Registro de Compras +} as const; + +export type CodLibro = (typeof COD_LIBRO)[keyof typeof COD_LIBRO]; + +/** + * Build SIRE credentials from RUC + SOL_USER + SOL_PASSWORD + client_id/secret. + */ +export function sireCredentials(args: { + clientId: string; + clientSecret: string; + ruc: string; + solUsuario: string; + solPassword: string; +}): OAuthCredentials { + return { + clientId: args.clientId, + clientSecret: args.clientSecret, + username: `${args.ruc}${args.solUsuario}`, + password: args.solPassword, + scope: "https://api-sire.sunat.gob.pe", + }; +} + +// --------------------------------------------------------------------------- +// 5.2 Consultar periodos disponibles +// --------------------------------------------------------------------------- + +export interface SirePeriodoEntry { + perTributario: string; // "202401" + codEstado: string; // "01" presentado, "02" pendiente, etc + desEstado: string; // human description +} + +export interface SireEjercicio { + numEjercicio: string; // "2024" + desEstado: string; + lisPeriodos: SirePeriodoEntry[]; +} + +export interface SirePeriodosResponse { + registros: SireEjercicio[]; +} + +export async function listarPeriodos(codLibro: CodLibro, creds: OAuthCredentials): Promise { + const r = await callRestApi({ + creds, + baseHost: "sire", + path: `/contribuyente/migeigv/libros/rvierce/padron/web/omisos/${codLibro}/periodos`, + }); + if (Array.isArray(r)) return r; + return r.registros || []; +} + +// --------------------------------------------------------------------------- +// 5.18 Descargar propuesta (async — returns ticket) +// 5.19 Descargar no incluidos (async) +// 5.20 Descargar resumen (async) +// 5.21 Descargar resumen inconsistencias (async) +// 5.27 Descargar RVIE por periodo (async) +// 5.31 Descargar reporte inconsistencias por periodo (async) +// --------------------------------------------------------------------------- + +export interface DescargarOpts { + codLibro: CodLibro; + perTributario: string; // YYYYMM + codTipoArchivo?: string; // 0 = TXT (default), 1 = CSV, etc + codOrigenEnvio?: string; // "2" = Servicio Web (default) + mtoTotalDesde?: string; + mtoTotalHasta?: string; + rucAdquiriente?: string; + rucProveedor?: string; +} + +export interface TicketResponse { + numTicket: string; +} + +export async function descargarPropuesta(opts: DescargarOpts, creds: OAuthCredentials): Promise { + const path = opts.codLibro === COD_LIBRO.rvie + ? `/contribuyente/migeigv/libros/rvierce/gestionprocesosmasivos/web/masivo/exportapropuesta` + : `/contribuyente/migeigv/libros/rce/propuesta/web/propuesta/${opts.perTributario}/exportacioncomprobantepropuesta`; + + const r = await callRestApi({ + creds, + baseHost: "sire", + method: "GET", + path, + query: { + perTributario: opts.perTributario, + codTipoArchivoReporte: opts.codTipoArchivo || "0", + codOrigenEnvio: opts.codOrigenEnvio || "2", + }, + }); + return r.numTicket; +} + +export async function descargarRvie(perTributario: string, creds: OAuthCredentials): Promise { + const r = await callRestApi({ + creds, + baseHost: "sire", + method: "GET", + path: `/contribuyente/migeigv/libros/rvierce/gestionprocesosmasivos/web/masivo/exportarregistropropuesta`, + query: { + perTributario, + codTipoArchivoReporte: "0", + codLibro: COD_LIBRO.rvie, + }, + }); + return r.numTicket; +} + +// --------------------------------------------------------------------------- +// 5.16 Consultar estado del ticket +// --------------------------------------------------------------------------- + +export interface TicketStatus { + numTicket: string; + codEstadoProceso: string; // "01" iniciado, "03" en proceso, "06" terminado, "07" error, "10" terminado con error + desEstadoProceso: string; + codTipoArchivoReporte?: string; + desTipoArchivoReporte?: string; + archivoReporte?: { nomArchivoReporte: string; codTipoArchivoReporte?: string }[]; +} + +export async function consultarTicket(numTicket: string, creds: OAuthCredentials): Promise { + const path = `/contribuyente/migeigv/libros/rvierce/gestionprocesosmasivos/web/masivo/consultaestadotickets`; + type ApiResponse = { registros?: TicketStatus[] }; + const r = await callRestApi({ + creds, + baseHost: "sire", + method: "GET", + path, + query: { numTicket, page: 1, perPage: 1 }, + }); + const first = r.registros?.[0]; + if (!first) { + return { numTicket, codEstadoProceso: "00", desEstadoProceso: "No encontrado" }; + } + return first; +} + +// --------------------------------------------------------------------------- +// 5.17 Descargar archivo (returns the actual ZIP/TXT bytes) +// --------------------------------------------------------------------------- + +export interface DescargarArchivoOpts { + nomArchivoReporte: string; + codTipoArchivoReporte: string; + codLibro: CodLibro; + perTributario: string; + codProceso?: string; // tipo de proceso, depende del archivo origen +} + +export async function descargarArchivo( + opts: DescargarArchivoOpts, + creds: OAuthCredentials, +): Promise { + const token = await import("./oauth.ts").then((m) => m.getAccessToken(creds)); + const url = new URL(`https://api-sire.sunat.gob.pe/v1/contribuyente/migeigv/libros/rvierce/gestionprocesosmasivos/web/masivo/archivoreporte`); + url.searchParams.set("nomArchivoReporte", opts.nomArchivoReporte); + url.searchParams.set("codTipoArchivoReporte", opts.codTipoArchivoReporte); + url.searchParams.set("codLibro", opts.codLibro); + url.searchParams.set("perTributario", opts.perTributario); + if (opts.codProceso) url.searchParams.set("codProceso", opts.codProceso); + + const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`SUNAT SIRE descargarArchivo HTTP ${resp.status}: ${text.slice(0, 300)}`); + } + const ab = await resp.arrayBuffer(); + return Buffer.from(ab); +} + +// --------------------------------------------------------------------------- +// 5.8 Aceptar propuesta del RVIE +// --------------------------------------------------------------------------- + +export interface AceptarPropuestaResult { + numTicket: string; +} + +export async function aceptarPropuestaRvie(perTributario: string, creds: OAuthCredentials): Promise { + const path = `/contribuyente/migeigv/libros/rvie/propuesta/web/propuesta/${perTributario}/aceptapropuesta`; + const r = await callRestApi({ + creds, + baseHost: "sire", + method: "POST", + path, + }); + return r; +} + +// --------------------------------------------------------------------------- +// Polling helper — wait for ticket to terminate +// --------------------------------------------------------------------------- + +export type TicketTerminalState = "completed" | "error" | "still-processing"; + +export interface PollTicketResult { + state: TicketTerminalState; + statusCode: string; + statusDesc: string; + archivoReporte?: { nomArchivoReporte: string; codTipoArchivoReporte?: string }[]; +} + +export interface PollTicketOpts { + creds: OAuthCredentials; + numTicket: string; + timeoutMs?: number; + initialDelayMs?: number; + maxDelayMs?: number; + onTick?: (attempt: number, state: string) => void; +} + +export async function pollTicket(opts: PollTicketOpts): Promise { + const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1000; + const start = Date.now(); + let delay = opts.initialDelayMs ?? 2000; + const maxDelay = opts.maxDelayMs ?? 30_000; + let attempt = 0; + while (Date.now() - start < timeoutMs) { + attempt += 1; + const status = await consultarTicket(opts.numTicket, opts.creds); + opts.onTick?.(attempt, status.desEstadoProceso); + // 06 = Terminado, 07/10 = error/terminado con error + if (status.codEstadoProceso === "06") { + return { + state: "completed", + statusCode: status.codEstadoProceso, + statusDesc: status.desEstadoProceso, + archivoReporte: status.archivoReporte, + }; + } + if (["07", "10", "08"].includes(status.codEstadoProceso)) { + return { + state: "error", + statusCode: status.codEstadoProceso, + statusDesc: status.desEstadoProceso, + }; + } + await sleep(delay); + delay = Math.min(delay * 2, maxDelay); + } + return { state: "still-processing", statusCode: "98", statusDesc: `Timeout after ${Math.round((Date.now() - start) / 1000)}s` }; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/packages/cli/tests/unit/sire.test.ts b/packages/cli/tests/unit/sire.test.ts new file mode 100644 index 0000000..9cf4992 --- /dev/null +++ b/packages/cli/tests/unit/sire.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + COD_LIBRO, + aceptarPropuestaRvie, + consultarTicket, + descargarPropuesta, + descargarRvie, + listarPeriodos, + pollTicket, + sireCredentials, +} from "../../src/sunat-rest/sire.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 = sireCredentials({ + clientId: "cid", + clientSecret: "csec", + ruc: "20131312955", + solUsuario: "MODDATOS", + solPassword: "moddatos", +}); + +describe("sireCredentials", () => { + test("concats RUC + SOL_USER for username", () => { + expect(creds.username).toBe("20131312955MODDATOS"); + expect(creds.password).toBe("moddatos"); + expect(creds.scope).toContain("api-sire.sunat.gob.pe"); + }); +}); + +describe("OAuth password grant for SIRE", () => { + test("posts to clientessol with grant_type=password + username + password", async () => { + let tokenUrl = ""; + let tokenBody = ""; + mockFetch(async (url, init) => { + if (url.includes("/oauth2/token")) { + tokenUrl = url; + tokenBody = String(init?.body || ""); + return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + } + return new Response(JSON.stringify({ registros: [] }), { status: 200 }); + }); + await listarPeriodos(COD_LIBRO.rvie, creds); + expect(tokenUrl).toContain("/clientessol/cid/oauth2/token/"); + expect(tokenBody).toContain("grant_type=password"); + expect(tokenBody).toContain("username=20131312955MODDATOS"); + expect(tokenBody).toContain("password=moddatos"); + }); +}); + +describe("COD_LIBRO", () => { + test("RVIE = 140000, RCE = 080000", () => { + expect(COD_LIBRO.rvie).toBe("140000"); + expect(COD_LIBRO.rce).toBe("080000"); + }); +}); + +describe("listarPeriodos", () => { + test("hits api-sire host with codLibro in path", async () => { + let seenUrl = ""; + mockFetch(async (url) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + seenUrl = url; + return new Response(JSON.stringify({ registros: [{ numEjercicio: "2024", desEstado: "Activo", lisPeriodos: [] }] }), { status: 200 }); + }); + await listarPeriodos(COD_LIBRO.rvie, creds); + expect(seenUrl).toContain("api-sire.sunat.gob.pe"); + expect(seenUrl).toContain("/rvierce/padron/web/omisos/140000/periodos"); + }); + + test("normalizes both array and {registros} response shapes", 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([{ numEjercicio: "2025", desEstado: "Activo", lisPeriodos: [{ perTributario: "202504", codEstado: "01", desEstado: "Pendiente" }] }]), { status: 200 }); + }); + const ejercicios = await listarPeriodos(COD_LIBRO.rce, creds); + expect(ejercicios.length).toBe(1); + expect(ejercicios[0].numEjercicio).toBe("2025"); + expect(ejercicios[0].lisPeriodos[0].perTributario).toBe("202504"); + }); +}); + +describe("descargarPropuesta", () => { + test("RVIE uses gestionprocesosmasivos endpoint and returns ticket", async () => { + let seenUrl = ""; + mockFetch(async (url) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + seenUrl = url; + return new Response(JSON.stringify({ numTicket: "20240100000001" }), { status: 200 }); + }); + const ticket = await descargarPropuesta({ codLibro: COD_LIBRO.rvie, perTributario: "202404" }, creds); + expect(ticket).toBe("20240100000001"); + expect(seenUrl).toContain("exportapropuesta"); + expect(seenUrl).toContain("perTributario=202404"); + expect(seenUrl).toContain("codTipoArchivoReporte=0"); + expect(seenUrl).toContain("codOrigenEnvio=2"); + }); + + test("RCE uses /rce/propuesta/.../exportacioncomprobantepropuesta path", async () => { + let seenUrl = ""; + mockFetch(async (url) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + seenUrl = url; + return new Response(JSON.stringify({ numTicket: "T1" }), { status: 200 }); + }); + await descargarPropuesta({ codLibro: COD_LIBRO.rce, perTributario: "202404" }, creds); + expect(seenUrl).toContain("/rce/propuesta/web/propuesta/202404/exportacioncomprobantepropuesta"); + }); +}); + +describe("descargarRvie", () => { + test("hits exportarregistropropuesta and returns ticket", async () => { + let seenUrl = ""; + mockFetch(async (url) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + seenUrl = url; + return new Response(JSON.stringify({ numTicket: "T2" }), { status: 200 }); + }); + const ticket = await descargarRvie("202404", creds); + expect(ticket).toBe("T2"); + expect(seenUrl).toContain("exportarregistropropuesta"); + expect(seenUrl).toContain("codLibro=140000"); + }); +}); + +describe("aceptarPropuestaRvie", () => { + test("POSTs to /rvie/propuesta/web/propuesta/{periodo}/aceptapropuesta", async () => { + let seenUrl = ""; + let seenMethod = ""; + mockFetch(async (url, init) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + seenUrl = url; + seenMethod = (init?.method as string) || "GET"; + return new Response(JSON.stringify({ numTicket: "T3" }), { status: 200 }); + }); + const result = await aceptarPropuestaRvie("202404", creds); + expect(result.numTicket).toBe("T3"); + expect(seenMethod).toBe("POST"); + expect(seenUrl).toContain("/rvie/propuesta/web/propuesta/202404/aceptapropuesta"); + }); +}); + +describe("consultarTicket", () => { + test("returns first registro from response", 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({ registros: [{ numTicket: "T1", codEstadoProceso: "06", desEstadoProceso: "Terminado", archivoReporte: [{ nomArchivoReporte: "out.zip", codTipoArchivoReporte: "0" }] }] }), { status: 200 }); + }); + const status = await consultarTicket("T1", creds); + expect(status.codEstadoProceso).toBe("06"); + expect(status.archivoReporte?.[0].nomArchivoReporte).toBe("out.zip"); + }); + + test("returns 'No encontrado' when registros is empty", 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({ registros: [] }), { status: 200 }); + }); + const status = await consultarTicket("Tx", creds); + expect(status.codEstadoProceso).toBe("00"); + expect(status.desEstadoProceso).toContain("No encontrado"); + }); +}); + +describe("pollTicket", () => { + test("returns 'completed' when ticket reaches state 06", 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({ registros: [{ numTicket: "T1", codEstadoProceso: "06", desEstadoProceso: "Terminado", archivoReporte: [{ nomArchivoReporte: "f.zip" }] }] }), { status: 200 }); + }); + const result = await pollTicket({ creds, numTicket: "T1", initialDelayMs: 1, maxDelayMs: 1, timeoutMs: 5000 }); + expect(result.state).toBe("completed"); + expect(result.archivoReporte?.[0].nomArchivoReporte).toBe("f.zip"); + }); + + test("returns 'error' when ticket reaches state 07", 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({ registros: [{ numTicket: "T1", codEstadoProceso: "07", desEstadoProceso: "Error en proceso" }] }), { status: 200 }); + }); + const result = await pollTicket({ creds, numTicket: "T1", initialDelayMs: 1, maxDelayMs: 1, timeoutMs: 5000 }); + expect(result.state).toBe("error"); + expect(result.statusDesc).toContain("Error"); + }); + + test("returns 'still-processing' on timeout", 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({ registros: [{ numTicket: "T1", codEstadoProceso: "03", desEstadoProceso: "En proceso" }] }), { status: 200 }); + }); + const result = await pollTicket({ creds, numTicket: "T1", initialDelayMs: 1, maxDelayMs: 1, timeoutMs: 50 }); + expect(result.state).toBe("still-processing"); + }); +});