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
21 changes: 21 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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();

Expand All @@ -31,5 +32,6 @@ program.addCommand(createF616Command());
program.addCommand(createApiCommand());
program.addCommand(createLukeaCommand());
program.addCommand(createCpeCommand());
program.addCommand(createPadronCommand());

program.parse();
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/scripts/smoke-padron.sh
Original file line number Diff line number Diff line change
@@ -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);
'
38 changes: 38 additions & 0 deletions packages/cli/skills/sunat-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/src/commands/cpe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>", "RUC of the emisor (issuer)")
.requiredOption("--tipo <code>", "01=Factura, 03=Boleta, 07=NC, 08=ND, 09=Guia, 20=Retencion, 40=Percepcion")
.requiredOption("--serie <s>", "e.g. F001")
.requiredOption("--numero <n>", "Correlativo")
.requiredOption("--fecha <YYYY-MM-DD>", "Fecha emision (ISO)")
.option("--monto <n>", "Total amount; if provided, must match SUNAT records exactly to 2 decimals")
.option("--ruc-consultante <ruc>", "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
Expand Down
156 changes: 156 additions & 0 deletions packages/cli/src/commands/padron/index.ts
Original file line number Diff line number Diff line change
@@ -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("<ruc>", "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>", "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 <path> 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;
}
17 changes: 17 additions & 0 deletions packages/cli/src/cpe/oauth-config.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading