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
17 changes: 16 additions & 1 deletion packages/cli/LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,27 @@ All endpoints follow Manual de Servicios Web Api SIRE Ventas v22 (March 2024) at
### Active limitations

- ⚠️ **Never tested against real SIRE.** Same reason as Consulta CPE: needs real RUC with SIRE credentials + active billing periods. The Greenter test RUC `20000000001` has no RVIE history. When you run the first time with your own creds + a periodo with data, `propuesta --wait --out X.zip` should give you the working ZIP.
- 🚧 **`reemplazar propuesta` + `importar comprobantes` (propuesta/preliminar/ajustes)**not implemented. These use **TUS.IO resumable upload protocol**. SUNAT's own manual notes "deben ser desarrollados en JAVA". Needs a TUS.IO client in TS. **Estimated next PR (#5)**.
- ⚠️ **`reemplazar propuesta` + `importar comprobantes`** (PR #6)TUS.IO 1.0.0 client implemented in TS (`src/sunat-rest/tus.ts`), 15 unit tests cover POST/PATCH/HEAD + chunking + metadata base64 encoding. Wired as `sunat sire {ventas|compras} {reemplazar|importar --tipo X}`. **However, ticket extraction from the upload Location URL is best-effort**: SUNAT's response shape varies and the manual is ambiguous. If `numTicket` comes back empty, the upload itself succeeded but the operator must poll `consultaestadotickets` manually using `perTributario` + `codProceso`. To verify in prod: upload a tiny test ZIP first with `--wait` and check whether the ticket round-trips.
- 🚧 **Reportes complementarios** (resumen, inconsistencias, CAR, casillas, reporte de exportadores, reporte de cumplimiento, reporte estadístico) — same async ticket pattern as `propuesta`. Easy adds when needed.
- 🚧 **Tipo de cambio masivo** — JSON POST endpoint, easy add.
- 🚧 **Eliminar comprobantes** (propuesta / preliminar / reemplazo) — same shape, low priority.
- ⚠️ **CORS warning from SUNAT** — "los servicios del API SIRE no deben ser consumidos desde un cliente Web". CLI is server-side, not affected. Don't try to call these from a browser bundle.

### TUS.IO implementation notes (PR #6)

- **TUS spec version**: `1.0.0`
- **Chunk size**: default 8 MB, override with `--chunk-size <bytes>`. Configured per upload.
- **File size limit**: 6 GB enforced client-side per SUNAT spec (Manual error 1346)
- **Metadata encoding**: keys uncoded, values base64. SUNAT-required keys: `filename, filetype, perTributario, codOrigenEnvio (=2), codProceso, codTipoCorrelativo (=01), nomArchivoImportacion, codLibro`
- **codProceso values** (Anexo I — Indicador de carga masiva):
- `1` = Importar CP propuesta
- `3` = Reemplazo de la propuesta
- `4` = Importar CP preliminar
- `6` = Cargar Ajustes posteriores
- `7` = Cargar Ajustes posteriores anteriores a la vigencia
- **Resumability**: TUS supports HEAD-then-resume on partial uploads, but PR #6 does not implement automatic retry-from-last-offset on network errors. If a large upload fails mid-flight, re-run the whole command. Future PR can add resumption.
- **Why we ignored SUNAT's "JAVA required" note**: SUNAT only ships Java samples. The TUS protocol itself is HTTP-only, language-agnostic. Verified by spec review.

---

## RHE / F616 — Personas Naturales (legacy, pre-existing)
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ sunat-cli sire ventas propuesta --periodo 202404 --wait --out propuesta-202404.z
sunat-cli sire ventas aceptar --periodo 202404 --yes
sunat-cli sire ventas descargar --periodo 202404 --wait --out rvie-202404.zip

# Replace SUNAT's proposal with your own .zip (TUS.IO upload, chunked 8MB)
sunat-cli sire ventas reemplazar --periodo 202404 --file mi-propuesta.zip --yes --wait

# Import extra comprobantes (--tipo: propuesta | preliminar | ajustes | ajustes-anteriores)
sunat-cli sire ventas importar --periodo 202404 --file extra.zip --tipo propuesta --yes --wait

# RCE (Compras) — same flow
sunat-cli sire compras periodos
sunat-cli sire compras propuesta --periodo 202404 --wait --out compras-202404.zip
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/skills/sunat-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,12 @@ sunat sire ventas propuesta --periodo 202404 --wait --out propuesta-202404.zip
# 4a. Accept as-is
sunat sire ventas aceptar --periodo 202404 --yes

# 4b. Or replace with your own (T2): use --reemplazar (shaped, see RESEARCH)
# 4b. Or replace SUNAT's proposal with your own .zip (T2, TUS.IO upload)
sunat sire ventas reemplazar --periodo 202404 --file mi-propuesta.zip --yes --wait

# 4c. Or import additional comprobantes not in the proposal
sunat sire ventas importar --periodo 202404 --file extra.zip --tipo propuesta --yes --wait
# --tipo: propuesta | preliminar | ajustes | ajustes-anteriores

# 5. Download the final RVIE PDF/TXT once accepted
sunat sire ventas descargar --periodo 202404 --wait --out rvie-202404.zip
Expand Down
148 changes: 148 additions & 0 deletions packages/cli/src/commands/sire/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Command } from "commander";
import { writeFileSync } from "fs";
import { audit } from "../../data/audit.ts";
import { readFileSync as readFile, statSync as fileStat } from "fs";
import { basename } from "path";
import {
COD_LIBRO,
type CodLibro,
type SireUploadKind,
aceptarPropuestaRvie,
consultarTicket,
descargarArchivo,
Expand All @@ -12,6 +15,7 @@ import {
listarPeriodos,
pollTicket,
sireCredentials,
sireUpload,
} from "../../sunat-rest/sire.ts";
import { output, outputError } from "../../utils/output.ts";

Expand Down Expand Up @@ -182,6 +186,150 @@ function bookCommand(libroAlias: "ventas" | "compras", codLibro: CodLibro): Comm
}
});

const uploadCommand = sub
.command("reemplazar")
.description(
"Reemplazar la propuesta SUNAT con un archivo .zip propio (TUS.IO upload, async). T2.",
)
.requiredOption("--periodo <YYYYMM>", "Periodo tributario, e.g. 202404")
.requiredOption("--file <path>", "Path to the .zip file to upload (must contain a .txt per SUNAT spec)")
.option("--filename <name>", "Override filename announced to SUNAT (defaults to basename of --file)")
.option("--yes", "Skip T2 confirmation prompt")
.option("--wait", "After upload, poll the resulting ticket until completed/error")
.option("--timeout <ms>", "Polling timeout (default 300000 = 5min)", "300000")
.option("--chunk-size <bytes>", "TUS chunk size (default 8388608 = 8MB)", "8388608")
.action(async (opts, cmd) => uploadAction(cmd, opts, "reemplazoPropuesta"));

sub
.command("importar")
.description(
"Importar nuevos comprobantes (TUS.IO upload, async). T2. " +
"--tipo controls the destination: propuesta | preliminar | ajustes | ajustes-anteriores",
)
.requiredOption("--periodo <YYYYMM>")
.requiredOption("--file <path>", "Path to the .zip file to upload")
.requiredOption(
"--tipo <kind>",
"propuesta | preliminar | ajustes | ajustes-anteriores",
)
.option("--filename <name>")
.option("--yes")
.option("--wait")
.option("--timeout <ms>", "Polling timeout", "300000")
.option("--chunk-size <bytes>", "TUS chunk size", "8388608")
.action(async (opts, cmd) => {
const tipoMap: Record<string, SireUploadKind> = {
propuesta: "importarPropuestaCp",
preliminar: "importarPreliminarCp",
ajustes: "ajustesPosteriores",
"ajustes-anteriores": "ajustesPosterioresAnteriores",
};
const kind = tipoMap[opts.tipo];
if (!kind) {
outputError(`--tipo must be one of: ${Object.keys(tipoMap).join(", ")}`, getFormat(cmd));
return;
}
await uploadAction(cmd, opts, kind);
});

async function uploadAction(
cmd: Command,
opts: { periodo: string; file: string; filename?: string; yes?: boolean; wait?: boolean; timeout: string; chunkSize: string },
kind: SireUploadKind,
): Promise<void> {
const format = getFormat(cmd);
try {
if (!opts.yes) {
outputError("T2 — requires --yes. This uploads a .zip to SUNAT and modifies your SIRE state.", format);
return;
}

const stat = fileStat(opts.file);
if (!stat.isFile()) {
outputError(`--file must be a regular file: ${opts.file}`, format);
return;
}
if (stat.size === 0) {
outputError(`--file is empty: ${opts.file}`, format);
return;
}
if (stat.size > 6 * 1024 * 1024 * 1024) {
outputError(`--file exceeds SUNAT 6GB limit: ${stat.size} bytes`, format);
return;
}

const data = readFile(opts.file);
const filename = opts.filename || basename(opts.file);
const creds = resolveSireCreds();

let lastLog = 0;
const result = await sireUpload(
{
kind,
codLibro,
perTributario: opts.periodo,
filename,
data,
chunkSize: Number.parseInt(opts.chunkSize, 10),
onProgress: (uploaded, total) => {
const now = Date.now();
if (format !== "json" && now - lastLog > 1000) {
const pct = Math.round((uploaded / total) * 100);
const mb = (uploaded / 1024 / 1024).toFixed(1);
const totalMb = (total / 1024 / 1024).toFixed(1);
process.stderr.write(`\r uploading: ${mb}/${totalMb} MB (${pct}%)`);
lastLog = now;
}
},
},
creds,
);
if (format !== "json") process.stderr.write("\n");

audit({
command: `sire ${libroAlias} ${kind === "reemplazoPropuesta" ? "reemplazar" : "importar"}`,
args: { periodo: opts.periodo, file: opts.file, kind },
result: "success",
details: { numTicket: result.numTicket, bytesSent: result.bytesSent },
});

if (!opts.wait || !result.numTicket) {
output(format, {
json: {
uploaded: true,
bytesSent: result.bytesSent,
numTicket: result.numTicket || null,
uploadUrl: result.uploadUrl,
hint: result.numTicket
? `Poll status with: sunat sire ${libroAlias} ticket --num ${result.numTicket}`
: "No ticket extracted from upload location. Inspect uploadUrl + run consultaestadotickets manually.",
},
});
return;
}

const polled = await pollTicket({
creds,
numTicket: result.numTicket,
timeoutMs: Number.parseInt(opts.timeout, 10),
});
output(format, {
json: {
uploaded: true,
bytesSent: result.bytesSent,
numTicket: result.numTicket,
uploadUrl: result.uploadUrl,
...polled,
},
});
} catch (err) {
outputError(err instanceof Error ? err.message : String(err), format);
}
}

// Suppress unused-var warning for uploadCommand (commander chains side-effects).
void uploadCommand;

if (libroAlias === "ventas") {
sub
.command("aceptar")
Expand Down
100 changes: 99 additions & 1 deletion packages/cli/src/sunat-rest/sire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
* 3. Download generated file by name
*/

import { type OAuthCredentials, callRestApi } from "./oauth.ts";
import { type OAuthCredentials, callRestApi, getAccessToken } from "./oauth.ts";
import { tusUpload } from "./tus.ts";

/** Catálogo SUNAT de libros */
export const COD_LIBRO = {
Expand Down Expand Up @@ -275,3 +276,100 @@ export async function pollTicket(opts: PollTicketOpts): Promise<PollTicketResult
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

// ---------------------------------------------------------------------------
// 5.3 Importar reemplazo de la propuesta (TUS.IO upload)
// 5.4 Importar nuevos comprobantes propuesta (TUS.IO upload)
// 5.5 Importar nuevos comprobantes preliminar (TUS.IO upload)
// 5.6 Importar ajustes posteriores (TUS.IO upload)
// 5.7 Importar ajustes posteriores de periodos anteriores (TUS.IO upload)
//
// All five share the same TUS upload mechanism. They differ in:
// - the upload endpoint (URL path)
// - the codProceso metadata value (3=Reemplazo, 1=ImportarCP, 4=ImportarPreliminar, 6=Ajustes, 7=AjustesAnteriores)
//
// Returns numTicket — same async polling flow as the read endpoints.
// ---------------------------------------------------------------------------

const SIRE_BASE = "https://api-sire.sunat.gob.pe/v1";

/** SUNAT codProceso values from Anexo I (Indicador de carga masiva) */
export const COD_PROCESO = {
importarPropuestaCp: "1",
reemplazoPropuesta: "3",
importarPreliminarCp: "4",
ajustesPosteriores: "6",
ajustesPosterioresAnteriores: "7",
} as const;

export type CodProceso = (typeof COD_PROCESO)[keyof typeof COD_PROCESO];

const SIRE_UPLOAD_PATHS = {
reemplazoPropuesta: "/contribuyente/migeigv/libros/rvierce/receptorpropuesta/web/propuesta/upload",
importarPropuestaCp: "/contribuyente/migeigv/libros/rvierce/receptorpropuesta/web/propuesta/upload",
importarPreliminarCp: "/contribuyente/migeigv/libros/rvierce/receptorpreliminar/web/preliminar/upload",
ajustesPosteriores: "/contribuyente/migeigv/libros/rvierce/receptorajustesposteriores/web/ajustesposteriores/upload",
ajustesPosterioresAnteriores: "/contribuyente/migeigv/libros/rvierce/receptorajustesposteriores/web/ajustesposteriores/upload",
} as const;

export type SireUploadKind = keyof typeof SIRE_UPLOAD_PATHS;

export interface SireUploadOpts {
kind: SireUploadKind;
codLibro: CodLibro;
perTributario: string; // YYYYMM
filename: string; // e.g. "LE201013129550014040002OIM2.txt" — see SUNAT Resolución 112-2021 tabla 6
data: Buffer; // ZIP bytes (the .txt is wrapped in a .zip per SUNAT spec)
chunkSize?: number;
onProgress?: (uploaded: number, total: number) => void;
}

export interface SireUploadResult {
numTicket: string;
uploadUrl: string;
bytesSent: number;
}

export async function sireUpload(opts: SireUploadOpts, creds: OAuthCredentials): Promise<SireUploadResult> {
const codProceso = COD_PROCESO[opts.kind];
const path = SIRE_UPLOAD_PATHS[opts.kind];
const endpoint = `${SIRE_BASE}${path}`;
const token = await getAccessToken(creds);

const { uploadUrl, bytesSent } = await tusUpload({
endpoint,
data: opts.data,
bearerToken: token,
chunkSize: opts.chunkSize,
onProgress: opts.onProgress,
metadata: {
filename: opts.filename,
filetype: "application/zip",
perTributario: opts.perTributario,
codOrigenEnvio: "2", // Servicio Web
codProceso,
codTipoCorrelativo: "01", // envíos masivos
nomArchivoImportacion: opts.filename,
codLibro: opts.codLibro,
},
});

// SUNAT returns the ticket either in the final PATCH response body (rare)
// or via a separate consultaestadotickets call seeded by the upload location.
// Per Manual Section 5.3 page 24, the response body of the final PATCH
// contains the ticket as plain text. We HEAD the upload location to check.
// For now we extract from the location URL or fetch the upload metadata.
const ticket = extractTicketFromUploadUrl(uploadUrl) || "";
return { numTicket: ticket, uploadUrl, bytesSent };
}

/**
* SUNAT upload locations look like ".../upload/{filename-base64}/{ticketId}".
* Best-effort extraction; if not present, the caller should poll
* consultarTicket using the metadata they sent (perTributario + codProceso).
*/
function extractTicketFromUploadUrl(url: string): string | null {
// Match any 13-digit numeric segment (SUNAT ticket format AAAA99999999)
const match = url.match(/(\d{13,})/);
return match ? match[1] : null;
}
Loading
Loading