Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/bin/sunat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -33,5 +34,6 @@ program.addCommand(createApiCommand());
program.addCommand(createLukeaCommand());
program.addCommand(createCpeCommand());
program.addCommand(createPadronCommand());
program.addCommand(createSireCommand());

program.parse();
48 changes: 48 additions & 0 deletions packages/cli/skills/sunat-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> [--wait]`.

### Padrón Reducido del RUC (offline)

Local copy of the SUNAT RUC registry. ~370MB ZIP, ~600MB TXT, ~3.5M entries.
Expand Down
253 changes: 253 additions & 0 deletions packages/cli/src/commands/sire/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof sireCredentials> {
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 <YYYYMM>", "Periodo tributario, e.g. 202404")
.option("--wait", "Poll ticket until completed/error")
.option("--timeout <ms>", "Polling timeout (default 300000 = 5min)", "300000")
.option("--out <path>", "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 <ticket>", "Número de ticket")
.option("--wait", "Poll until completed/error")
.option("--timeout <ms>", "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 <name>", "nomArchivoReporte from a completed ticket")
.requiredOption("--periodo <YYYYMM>")
.requiredOption("--out <path>", "Path to write the file")
.option("--tipo <code>", "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 <YYYYMM>")
.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<string, unknown> });
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 <YYYYMM>")
.option("--wait")
.option("--timeout <ms>", "Polling timeout", "300000")
.option("--out <path>")
.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;
}
Loading
Loading