diff --git a/packages/cli/LIMITATIONS.md b/packages/cli/LIMITATIONS.md index 3949f95..4b62ecd 100644 --- a/packages/cli/LIMITATIONS.md +++ b/packages/cli/LIMITATIONS.md @@ -31,7 +31,15 @@ If you hit something that's not documented here, open an issue. ### Active limitations -- **NC, ND, Guía de Remisión** — driver methods throw "not yet implemented" errors. UBL builders shaped in `src/cpe/ubl/`, signer + SOAP infra reusable. Estimated 1 day each. **Future PR**. +- **NC, ND** — driver methods throw "not yet implemented" errors. UBL builders shaped in `src/cpe/ubl/`, signer + SOAP infra reusable. Estimated 1 day each. **Future PR**. +- **Guía de Remisión Electrónica (GRE)** — ✅ shipped in PR #7 as `sunat cpe gre emit|status` (REST OAuth, NOT SOAP). Reuses XAdES signer. **However**: + - ⚠️ Untested live (needs SUNAT_GRE_CLIENT_ID/SECRET from SOL menu URI = "GRE Emisión de Comprobantes") + - 🚧 Only modTraslado=02 (transporte privado, emisor moves goods). Modal 01 (transporte público / carrier party) → next PR + - 🚧 No `BuyerCustomerParty` (when distinto del destinatario) + - 🚧 No `SellerSupplierParty` (tercero/proveedor) + - 🚧 No `AdditionalDocumentReference` (factura previa, etc) + - 🚧 GRE Transportista (tipo doc 31) — different schema, not implemented + - 🚧 Multiple choferes — schema accepts loop, only one supported in PR #7 - **`sunat cpe void` (T3)** — intent-token flow shaped, command stubbed. Voiding is currently done via Comunicación de Baja (`sunat cpe baja send`) for boletas or NC for facturas. **Future PR**. - **Resumen Diario `sendSummary` against SUNAT beta** — XML structure 100% verified against Greenter twig template; unit tests cover all 14 structural assertions. **However**, the actual SUNAT beta nginx wrapper returns transient HTTP 401 on the `/sendSummary` path with the public test RUC `20000000001`. `sendBill` calls in the same window work fine. Hypothesis: rate-limit specific to the RC endpoint on the shared test RUC. **Production cert + RUC will not see this.** Documented in `src/commands/cpe/RESEARCH.md` appendix. - **Drivers `facturador`, `nubefact`, `apisperu`** — `getDriver()` returns a clear "shaped but not implemented" error. The `facturador` driver requires coordination with Christian Pasquel's containerized Java Facturador. The other two are PSE/OSE adapters; useful when the user wants to keep their existing OSE while gaining the CLI UX. diff --git a/packages/cli/README.md b/packages/cli/README.md index e02dd1d..43800e5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -60,6 +60,22 @@ sunat-cli sire compras periodos sunat-cli sire compras propuesta --periodo 202404 --wait --out compras-202404.zip ``` +### Guía de Remisión Electrónica (GRE — REST OAuth) + +```bash +export SUNAT_GRE_CLIENT_ID=... # SOL → Credenciales API SUNAT, URI = "GRE Emisión de Comprobantes" +export SUNAT_GRE_CLIENT_SECRET=... + +sunat-cli cpe gre emit --params '{ + "serie": "T001", "numero": 1, "fechaEmision": "2026-04-29", + "destinatario": {"tipoDoc":"6","numDoc":"20100070970","rznSocial":"CLIENTE SAC"}, + "envio": { ... codTraslado, modTraslado=02, chofer, vehiculo, partida, llegada }, + "items": [{"codigo":"P001","descripcion":"Cajas","cantidad":10,"unidad":"NIU"}] +}' --yes --wait + +sunat-cli cpe gre status --ticket 20240100000001 --wait +``` + ### Padrón Reducido del RUC (offline lookup, no auth) ```bash diff --git a/packages/cli/skills/sunat-cli/SKILL.md b/packages/cli/skills/sunat-cli/SKILL.md index f295320..ac29b0b 100644 --- a/packages/cli/skills/sunat-cli/SKILL.md +++ b/packages/cli/skills/sunat-cli/SKILL.md @@ -215,6 +215,54 @@ 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. +### Guía de Remisión Electrónica (REST OAuth) + +GRE is the SUNAT 2022 spec for tracking goods in transit (CPE tipo 09). +Different from Factura/Boleta: REST API (not SOAP), DespatchAdvice schema +(not Invoice), distinct OAuth credentials (URI = "GRE Emisión de Comprobantes" +in SOL → Credenciales API SUNAT). + +Setup once: +```bash +# GRE-specific OAuth (separate from CPE consulta credentials) +export SUNAT_GRE_CLIENT_ID=... +export SUNAT_GRE_CLIENT_SECRET=... +# Plus the same SOL creds used by sunat-direct +export CPE_SOL_USUARIO=MODDATOS +export CPE_SOL_PASSWORD='clave-sol' +``` + +```bash +# Submit (sign + zip + base64 + POST + optional polling) +sunat cpe gre emit --params '{ + "tipoDoc": "09", + "serie": "T001", + "numero": 1, + "fechaEmision": "2026-04-29", + "destinatario": {"tipoDoc":"6","numDoc":"20100070970","rznSocial":"CLIENTE SAC"}, + "envio": { + "codTraslado": "01", + "modTraslado": "02", + "fecTraslado": "2026-04-29", + "pesoTotal": 100, "undPesoTotal": "KGM", "numBultos": 2, + "chofer": {"tipoDoc":"1","nroDoc":"12345678","nombres":"JUAN","apellidos":"PEREZ","licencia":"Q12345678"}, + "vehiculo": {"placa": "ABC-123"}, + "partida": {"ubigeo":"150101","direccion":"AV LIMA 123"}, + "llegada": {"ubigeo":"150114","direccion":"AV ALIVERTI 456"} + }, + "items": [{"codigo":"P001","descripcion":"Caja cervezas","cantidad":10,"unidad":"NIU"}] +}' --yes --wait + +# Independent status check +sunat cpe gre status --ticket 20240100000001 --wait +``` + +Async response codes: +- `0001` Aceptado +- `0002` Anulado +- `0003` Rechazado +- `0098` En proceso (poll again) + ### CPE Consulta Integrada (REST OAuth) Validate any CPE (yours or a vendor's) against SUNAT records. Useful for diff --git a/packages/cli/src/commands/cpe/index.ts b/packages/cli/src/commands/cpe/index.ts index 3ecf6c8..aadedab 100644 --- a/packages/cli/src/commands/cpe/index.ts +++ b/packages/cli/src/commands/cpe/index.ts @@ -313,14 +313,173 @@ export function createCpeCommand(): Command { .option("--yes") .action((_, cmd) => notImplemented("nd emit", getFormat(cmd))); - const guia = cpe.command("guia").description("Guia de Remision (CPE tipo 09) operations."); + const gre = cpe.command("gre").description("Guía de Remisión Electrónica (CPE tipo 09) — REST OAuth, NOT SOAP. T0/T2."); + cpe + .command("guia") + .description("Alias for 'cpe gre' — kept for backwards naming.") + .allowUnknownOption(true) + .helpOption(false) + .action(() => { + console.error("Use 'sunat cpe gre ' instead. 'cpe guia' is an alias placeholder."); + process.exit(1); + }); - guia + gre .command("emit") - .description("Emit a Guia de Remision. T2. STUB — separate BillService endpoint.") - .option("--params ") - .option("--yes") - .action((_, cmd) => notImplemented("guia emit", getFormat(cmd))); + .description( + "Sign + zip + base64 + POST a Guía de Remisión via SUNAT GRE REST API. Async — returns ticket. T2.", + ) + .requiredOption("--params ", "JSON payload (run: sunat schema cpe-gre)") + .option("--dry-run", "Build + sign locally, do NOT submit") + .option("--yes", "Skip T2 confirmation") + .option("--wait", "After submit, poll the ticket until completed/rejected") + .option("--timeout ", "Polling timeout (default 300000 = 5min)", "300000") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + const { resolveCpeContext } = await import("../../cpe/config.ts"); + const { signFacturaXml } = await import("../../cpe/sign/xades.ts"); + const { buildGreUbl, greFilename } = await import("../../cpe/ubl/gre.ts"); + const { greCredentials, enviarGre, pollGreTicket } = await import("../../sunat-rest/gre.ts"); + const { resolveOAuthCredentials } = await import("../../cpe/oauth-config.ts").catch(() => ({ + resolveOAuthCredentials: () => { + throw new Error("oauth-config not found"); + }, + })); + + const ctx = resolveCpeContext(); + const input = JSON.parse(opts.params); + if (!input.envio || !input.destinatario || !input.items?.length) { + outputError("GRE requires destinatario, envio, items. Run: sunat schema cpe-gre", format); + return; + } + input.tipoDoc = input.tipoDoc || "09"; + input.serie = input.serie || "T001"; + + const unsignedXml = buildGreUbl(input, { emisor: ctx.emisor }); + const { xml: signedXml } = signFacturaXml(unsignedXml, { + pfxPath: ctx.certPath, + pfxPassword: ctx.certPassword, + }); + const filename = greFilename(ctx.emisor.ruc, input.serie, input.numero); + + if (opts.dryRun) { + output(format, { json: { dryRun: true, filename, signedXmlBytes: signedXml.length } }); + return; + } + + if (!opts.yes) { + outputError("T2 emission requires --yes flag.", format); + return; + } + + // Need OAuth credentials (client_id/secret + RUC + SOL pwd) + const clientId = process.env.SUNAT_GRE_CLIENT_ID || process.env.SUNAT_API_CLIENT_ID; + const clientSecret = process.env.SUNAT_GRE_CLIENT_SECRET || process.env.SUNAT_API_CLIENT_SECRET; + if (!clientId || !clientSecret) { + outputError( + "GRE needs SUNAT_GRE_CLIENT_ID + SUNAT_GRE_CLIENT_SECRET (or SUNAT_API_*) env vars. Get from SOL → Credenciales API SUNAT, URI = 'GRE Emisión de Comprobantes'.", + format, + ); + return; + } + if (!ctx.solUsuario || !ctx.solPassword) { + outputError("GRE needs SOL usuario + password (CPE_SOL_USUARIO/PASSWORD env vars).", format); + return; + } + const greCreds = greCredentials({ + clientId, + clientSecret, + ruc: ctx.emisor.ruc, + solUsuario: ctx.solUsuario, + solPassword: ctx.solPassword, + }); + + const sendResp = await enviarGre({ filename, signedXml }, greCreds); + const auditDetails: Record = { + filename, + numTicket: sendResp.numTicket, + }; + + if (!opts.wait) { + output(format, { + json: { + submitted: true, + filename, + numTicket: sendResp.numTicket, + hint: `Poll status with: sunat cpe gre status --ticket ${sendResp.numTicket}`, + }, + }); + audit({ command: "cpe gre emit", args: input as Record, result: "success", details: auditDetails }); + return; + } + + const polled = await pollGreTicket({ + creds: greCreds, + numTicket: sendResp.numTicket, + timeoutMs: Number.parseInt(opts.timeout, 10), + }); + audit({ + command: "cpe gre emit", + args: input as Record, + result: polled.state === "completed" ? "success" : polled.state === "rejected" ? "error" : "pending", + details: { ...auditDetails, ...polled }, + }); + output(format, { + json: { + submitted: true, + filename, + numTicket: sendResp.numTicket, + ...polled, + }, + }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); + + gre + .command("status") + .description("Poll status of a previously submitted GRE ticket. T0.") + .requiredOption("--ticket ") + .option("--wait") + .option("--timeout ", "Polling timeout", "300000") + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + const { resolveCpeContext } = await import("../../cpe/config.ts"); + const { greCredentials, consultarGreTicket, pollGreTicket } = await import("../../sunat-rest/gre.ts"); + const ctx = resolveCpeContext(); + const clientId = process.env.SUNAT_GRE_CLIENT_ID || process.env.SUNAT_API_CLIENT_ID; + const clientSecret = process.env.SUNAT_GRE_CLIENT_SECRET || process.env.SUNAT_API_CLIENT_SECRET; + if (!clientId || !clientSecret) { + outputError("Missing SUNAT_GRE_CLIENT_ID/SECRET env vars.", format); + return; + } + const greCreds = greCredentials({ + clientId, + clientSecret, + ruc: ctx.emisor.ruc, + solUsuario: ctx.solUsuario, + solPassword: ctx.solPassword, + }); + + if (opts.wait) { + const polled = await pollGreTicket({ + creds: greCreds, + numTicket: opts.ticket, + timeoutMs: Number.parseInt(opts.timeout, 10), + }); + output(format, { json: { ticket: opts.ticket, ...polled } }); + return; + } + + const status = await consultarGreTicket(opts.ticket, greCreds); + output(format, { json: status }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); const resumen = cpe.command("resumen").description("Resumen Diario de Boletas operations."); diff --git a/packages/cli/src/cpe/ubl/gre.ts b/packages/cli/src/cpe/ubl/gre.ts new file mode 100644 index 0000000..64619bc --- /dev/null +++ b/packages/cli/src/cpe/ubl/gre.ts @@ -0,0 +1,257 @@ +/** + * UBL 2.1 Guía de Remisión Electrónica (GRE) builder for SUNAT. + * + * GRE 2022 spec — DespatchAdvice schema, distinct from Invoice (Factura/Boleta). + * + * Scope of this PR: + * - Tipo doc 09 (Guía de Remisión Remitente) + * - codTraslado 01 (Venta) — most common case, room to extend + * - modTraslado 02 (Transporte privado) — emisor moves the goods + * - codigoTipoOperacion 0101 (Venta interna) + * + * Out of scope (deferred to follow-up PRs as needed): + * - Modal 01 (Transporte público) — requires + RUC + MTC + * - Comprador (when distinto del destinatario) — + * - Tercero / proveedor — + * - Documentos relacionados (factura previa, etc) — + * - Importación (codTraslado 02) y otros catálogos 20 + * - GRE Transportista (tipo doc 31) — different schema + * - Multiple choferes (one supported, schema accepts loop) + * + * Reference: https://github.com/thegreenter/greenter/blob/master/packages/xml/src/Xml/Templates/despatch2022.xml.twig + */ + +import { + type EmisorCtx, + NS, + escapeXml, + fmt, + renderCacSignature, +} from "./common.ts"; + +export const GRE_NS = { + xmlns: "urn:oasis:names:specification:ubl:schema:xsd:DespatchAdvice-2", + cac: NS.cac, + cbc: NS.cbc, + ds: NS.ds, + ext: NS.ext, +} as const; + +export type GreTipoDoc = "09"; // Guía de Remisión Remitente +export type GreCodTraslado = "01" | "02" | "04" | "08" | "09" | "13" | "14" | "18" | "19"; +// 01=Venta, 02=Compra, 04=Transf entre estab, 08=Importación, 09=Exportación, +// 13=Otros, 14=Venta sujeta a confirmación, 18=Traslado emisor itinerante CP, 19=Traslado a zona primaria +export type GreModTraslado = "01" | "02"; // 01=Público (transportista), 02=Privado (emisor) +export type GrePersonaTipoDoc = "1" | "4" | "6" | "7" | "0"; + +export interface GreDestinatario { + tipoDoc: GrePersonaTipoDoc; + numDoc: string; + rznSocial: string; +} + +export interface GreChofer { + tipoDoc: GrePersonaTipoDoc; + nroDoc: string; + nombres: string; + apellidos: string; + licencia: string; +} + +export interface GreVehiculo { + placa: string; // e.g. "ABC-123" +} + +export interface GreDireccion { + ubigeo: string; // INEI 6-digit code + direccion: string; + codLocal?: string; // optional SUNAT establecimiento code (default "0000") + ruc?: string; // owner of the establecimiento (defaults to emisor RUC) +} + +export interface GreItem { + codigo: string; + descripcion: string; + cantidad: number; + unidad: string; // SUNAT Catalog 03 (e.g. NIU, KGM, ZZ) + codigoProductoSunat?: string; // Catalog 25 (often "00000000") +} + +export interface GreEnvio { + codTraslado: GreCodTraslado; + desTraslado?: string; + modTraslado: GreModTraslado; + fecTraslado: string; // YYYY-MM-DD + pesoTotal: number; + undPesoTotal: string; // KGM, TNE + numBultos?: number; + indicadores?: string[]; // SpecialInstructions per Catalog 53 (e.g. "SUNAT_Envio_IndicadorTrasladoTotalDAMoDS") + chofer?: GreChofer; // required when modTraslado=02 + vehiculo?: GreVehiculo; // required when modTraslado=02 + partida: GreDireccion; + llegada: GreDireccion; +} + +export interface GreInput { + tipoDoc: GreTipoDoc; + serie: string; // e.g. "T001" + numero: number; + fechaEmision: string; // YYYY-MM-DD + horaEmision?: string; // HH:mm:ss (defaults to noon if missing) + observacion?: string; + destinatario: GreDestinatario; + envio: GreEnvio; + items: GreItem[]; +} + +export interface GreContext { + emisor: EmisorCtx; +} + +/** + * Filename per SUNAT spec: {RUC}-09-{serie}-{numero} + */ +export function greFilename(emisorRuc: string, serie: string, numero: number): string { + return `${emisorRuc}-09-${serie}-${numero}`; +} + +function renderDespatchLine(item: GreItem, idx: number): string { + const sunatCode = item.codigoProductoSunat || "00000000"; + return ` + ${idx + 1} + ${fmt(item.cantidad)} + + ${idx + 1} + + + + + ${escapeXml(item.codigo)} + + + ${escapeXml(sunatCode)} + + + `; +} + +function renderAddress(addr: GreDireccion, fallbackRuc: string): string { + const codLocal = addr.codLocal || "0000"; + const ruc = addr.ruc || fallbackRuc; + return ` + ${escapeXml(addr.ubigeo)} + ${escapeXml(codLocal)} + + + + `; +} + +function renderShipmentStage(envio: GreEnvio, fallbackRuc: string): string { + const chofer = envio.chofer + ? ` + ${escapeXml(envio.chofer.nroDoc)} + ${escapeXml(envio.chofer.nombres)} + ${escapeXml(envio.chofer.apellidos)} + Principal + + ${escapeXml(envio.chofer.licencia)} + + ` + : ""; + + const transitDate = envio.fecTraslado + ? ` ${envio.fecTraslado}` + : ""; + + return ` + ${envio.modTraslado} +${transitDate} +${chofer} + + + ${renderAddress(envio.llegada, fallbackRuc)} + + + ${escapeXml(envio.partida.ubigeo)} + + + + + + `; +} + +function renderTransportEquipment(vehiculo: GreVehiculo): string { + return ` + + ${escapeXml(vehiculo.placa)} + + `; +} + +export function buildGreUbl(input: GreInput, ctx: GreContext): string { + const { emisor } = ctx; + const id = `${input.serie}-${input.numero}`; + const horaEmision = input.horaEmision || "12:00:00"; + const lines = input.items.map((item, idx) => renderDespatchLine(item, idx)).join("\n"); + + const indicadores = (input.envio.indicadores || []) + .map((ind) => ` ${escapeXml(ind)}`) + .join("\n"); + + const numBultos = input.envio.numBultos + ? ` ${input.envio.numBultos}` + : ""; + + const transportEq = input.envio.vehiculo ? renderTransportEquipment(input.envio.vehiculo) : ""; + const note = input.observacion ? ` ` : ""; + + return ` + + + + + + + 2.1 + 2.0 + ${escapeXml(id)} + ${input.fechaEmision} + ${horaEmision} + ${input.tipoDoc} +${note} +${renderCacSignature(emisor)} + + + + ${emisor.ruc} + + + + + + + + + + ${escapeXml(input.destinatario.numDoc)} + + + + + + + + SUNAT_Envio + ${input.envio.codTraslado} + ${input.envio.desTraslado ? `${escapeXml(input.envio.desTraslado)}` : ""} + ${fmt(input.envio.pesoTotal)} +${numBultos} +${indicadores} +${renderShipmentStage(input.envio, emisor.ruc)} +${transportEq} + +${lines} +`; +} diff --git a/packages/cli/src/sunat-rest/gre.ts b/packages/cli/src/sunat-rest/gre.ts new file mode 100644 index 0000000..e567cdd --- /dev/null +++ b/packages/cli/src/sunat-rest/gre.ts @@ -0,0 +1,139 @@ +/** + * SUNAT GRE — Guía de Remisión Electrónica REST API. + * + * Distinct from CPE SOAP (factura/boleta) and from SIRE: + * - Host: api-cpe.sunat.gob.pe (NOT api.sunat.gob.pe, NOT api-sire) + * - OAuth scope: https://api-cpe.sunat.gob.pe + * - Auth: password grant (RUC + SOL_USER + SOL_PASSWORD), same flavor as SIRE + * - Body: JSON with arcGreZip (the signed XML, zipped, then base64-encoded) + * + * Async pattern same as SIRE: send → ticket → poll status → CDR ZIP + */ + +import { createHash } from "crypto"; +import { type OAuthCredentials, callRestApi, getAccessToken } from "./oauth.ts"; +import { zipSingleFile } from "../cpe/soap/zip.ts"; + +/** + * Build SIRE-style credentials for GRE (same shape, different scope). + * Reuses RUC + SOL_USER + SOL_PASSWORD env vars. + */ +export function greCredentials(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-cpe.sunat.gob.pe", + }; +} + +export interface GreSendInput { + filename: string; // e.g. "20131312955-09-T001-1234" (without extension) + signedXml: string; // signed UBL DespatchAdvice +} + +export interface GreSendResponse { + numTicket: string; +} + +/** + * POST /v1/contribuyente/gem/comprobantes/{filename} + * + * Body: { archivo: { nomArchivo, arcGreZip, hashZip } } + * - nomArchivo: filename + ".zip" + * - arcGreZip: base64 of (zip containing filename + ".xml") + * - hashZip: SHA256 of the zip bytes (lowercase hex) + */ +export async function enviarGre(input: GreSendInput, creds: OAuthCredentials): Promise { + const xmlFilename = `${input.filename}.xml`; + const zipFilename = `${input.filename}.zip`; + const zipBuffer = await zipSingleFile(xmlFilename, input.signedXml); + const arcGreZip = zipBuffer.toString("base64"); + const hashZip = createHash("sha256").update(zipBuffer).digest("hex"); + + const body = { + archivo: { + nomArchivo: zipFilename, + arcGreZip, + hashZip, + }, + }; + + return callRestApi({ + creds, + method: "POST", + path: `/contribuyente/gem/comprobantes/${encodeURIComponent(input.filename)}`, + body, + baseHost: "cpe", + }); +} + +export interface GreStatusResponse { + numTicket: string; + codRespuesta: string; // "0001" Aceptado, "0002" Anulado, "0003" Rechazado, "0098" En proceso + desRespuesta?: string; + indCdrGenerado?: string; // "1" if CDR was generated + arcCdr?: string; // base64-encoded CDR ZIP (when indCdrGenerado=1) +} + +/** + * GET /v1/contribuyente/gem/comprobantes/envios/{ticket} + */ +export async function consultarGreTicket(numTicket: string, creds: OAuthCredentials): Promise { + return callRestApi({ + creds, + baseHost: "cpe", + path: `/contribuyente/gem/comprobantes/envios/${encodeURIComponent(numTicket)}`, + }); +} + +export interface GrePollResult { + state: "completed" | "rejected" | "still-processing"; + codRespuesta: string; + desRespuesta?: string; + arcCdr?: string; +} + +export interface GrePollOpts { + creds: OAuthCredentials; + numTicket: string; + timeoutMs?: number; + initialDelayMs?: number; + maxDelayMs?: number; + onTick?: (attempt: number, state: string) => void; +} + +export async function pollGreTicket(opts: GrePollOpts): 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 consultarGreTicket(opts.numTicket, opts.creds); + opts.onTick?.(attempt, status.codRespuesta); + if (status.codRespuesta === "0001") { + return { state: "completed", codRespuesta: status.codRespuesta, desRespuesta: status.desRespuesta, arcCdr: status.arcCdr }; + } + if (status.codRespuesta === "0002" || status.codRespuesta === "0003") { + return { state: "rejected", codRespuesta: status.codRespuesta, desRespuesta: status.desRespuesta, arcCdr: status.arcCdr }; + } + // 0098 / unknown → still processing + await sleep(delay); + delay = Math.min(delay * 2, maxDelay); + } + return { state: "still-processing", codRespuesta: "0098", desRespuesta: `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/src/sunat-rest/oauth.ts b/packages/cli/src/sunat-rest/oauth.ts index 17206d6..c680d67 100644 --- a/packages/cli/src/sunat-rest/oauth.ts +++ b/packages/cli/src/sunat-rest/oauth.ts @@ -14,6 +14,7 @@ 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"; +const CPE_BASE = "https://api-cpe.sunat.gob.pe/v1"; export interface OAuthCredentials { clientId: string; @@ -43,11 +44,12 @@ export const SUNAT_REST_BASES = { security: SECURITY_BASE, api: API_BASE, sire: SIRE_BASE, + cpe: CPE_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", + gre: "https://api-cpe.sunat.gob.pe", sire: "https://api-sire.sunat.gob.pe", } as const; @@ -109,13 +111,13 @@ 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"; + /** Override base URL: defaults to api.sunat.gob.pe; "sire" / "cpe" for the dedicated hosts. */ + baseHost?: "api" | "sire" | "cpe"; } export async function callRestApi(opts: RestRequestOptions): Promise { const token = await getAccessToken(opts.creds); - const base = opts.baseHost === "sire" ? SIRE_BASE : API_BASE; + const base = opts.baseHost === "sire" ? SIRE_BASE : opts.baseHost === "cpe" ? CPE_BASE : API_BASE; const url = new URL(`${base}${opts.path}`); if (opts.query) { for (const [k, v] of Object.entries(opts.query)) { diff --git a/packages/cli/tests/e2e/cpe-cli.test.ts b/packages/cli/tests/e2e/cpe-cli.test.ts index 9d1b6c8..f0a8fb4 100644 --- a/packages/cli/tests/e2e/cpe-cli.test.ts +++ b/packages/cli/tests/e2e/cpe-cli.test.ts @@ -214,11 +214,26 @@ describe("sunat cpe — E2E", () => { expect(combined).toContain("not implemented"); }); - test("cpe guia emit returns shaped-not-implemented stub error", async () => { - const result = await runCli(["-o", "json", "cpe", "guia", "emit"]); + test("cpe gre --help lists emit + status verbs", async () => { + const result = await runCli(["cpe", "gre", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("emit"); + expect(result.stdout).toContain("status"); + expect(result.stdout).toContain("REST"); + }); + + test("cpe gre emit requires --params flag", async () => { + const result = await runCli(["-o", "json", "cpe", "gre", "emit"]); expect(result.exitCode).toBe(1); const combined = result.stdout + result.stderr; - expect(combined).toContain("not implemented"); + expect(combined).toContain("--params"); + }); + + test("cpe guia (legacy alias) prints redirect notice", async () => { + const result = await runCli(["-o", "json", "cpe", "guia"]); + expect(result.exitCode).toBe(1); + const combined = result.stdout + result.stderr; + expect(combined).toContain("cpe gre"); }); test("cpe resumen send requires --fecha flag", async () => { diff --git a/packages/cli/tests/unit/gre-rest.test.ts b/packages/cli/tests/unit/gre-rest.test.ts new file mode 100644 index 0000000..98aa623 --- /dev/null +++ b/packages/cli/tests/unit/gre-rest.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { consultarGreTicket, enviarGre, greCredentials, pollGreTicket } from "../../src/sunat-rest/gre.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 = greCredentials({ + clientId: "cid", + clientSecret: "csec", + ruc: "20131312955", + solUsuario: "MODDATOS", + solPassword: "moddatos", +}); + +describe("greCredentials", () => { + test("password grant with api-cpe scope", () => { + expect(creds.username).toBe("20131312955MODDATOS"); + expect(creds.password).toBe("moddatos"); + expect(creds.scope).toContain("api-cpe.sunat.gob.pe"); + }); +}); + +describe("OAuth password grant for GRE", () => { + test("posts to clientessol with scope api-cpe", async () => { + let tokenBody = ""; + mockFetch(async (url, init) => { + if (url.includes("/oauth2/token")) { + tokenBody = String(init?.body || ""); + return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + } + return new Response(JSON.stringify({ numTicket: "T1" }), { status: 200 }); + }); + await enviarGre({ filename: "20131312955-09-T001-1", signedXml: "" }, creds); + expect(tokenBody).toContain("grant_type=password"); + expect(tokenBody).toContain("scope=https%3A%2F%2Fapi-cpe.sunat.gob.pe"); + }); +}); + +describe("enviarGre", () => { + test("POSTs to /v1/contribuyente/gem/comprobantes/{filename} on api-cpe host", 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: "20240100000001" }), { status: 200 }); + }); + const r = await enviarGre({ filename: "20131312955-09-T001-1", signedXml: "" }, creds); + expect(seenMethod).toBe("POST"); + expect(seenUrl).toContain("api-cpe.sunat.gob.pe"); + expect(seenUrl).toContain("/contribuyente/gem/comprobantes/20131312955-09-T001-1"); + expect(r.numTicket).toBe("20240100000001"); + }); + + test("body has archivo.{nomArchivo, arcGreZip, hashZip}", async () => { + let body: { archivo?: { nomArchivo?: string; arcGreZip?: string; hashZip?: string } } = {}; + 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({ numTicket: "T1" }), { status: 200 }); + }); + await enviarGre({ filename: "20131312955-09-T001-1", signedXml: "" }, creds); + expect(body.archivo?.nomArchivo).toBe("20131312955-09-T001-1.zip"); + expect(body.archivo?.arcGreZip).toMatch(/^[A-Za-z0-9+/=]+$/); + expect(body.archivo?.hashZip).toMatch(/^[a-f0-9]{64}$/); + }); + + test("hashZip is sha256 of the zip bytes (matches arcGreZip decoded)", async () => { + let captured: { archivo?: { arcGreZip?: string; hashZip?: string } } = {}; + mockFetch(async (url, init) => { + if (url.includes("token")) return new Response(JSON.stringify({ access_token: "tk", expires_in: 3600 }), { status: 200 }); + captured = JSON.parse(String(init?.body || "{}")); + return new Response(JSON.stringify({ numTicket: "T1" }), { status: 200 }); + }); + await enviarGre({ filename: "20131312955-09-T001-99", signedXml: "" }, creds); + const zipBytes = Buffer.from(captured.archivo!.arcGreZip!, "base64"); + const expectedHash = await import("crypto").then((c) => c.createHash("sha256").update(zipBytes).digest("hex")); + expect(captured.archivo?.hashZip).toBe(expectedHash); + }); +}); + +describe("consultarGreTicket", () => { + test("GETs /v1/contribuyente/gem/comprobantes/envios/{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: "T1", codRespuesta: "0001", desRespuesta: "Aceptado" }), { status: 200 }); + }); + const r = await consultarGreTicket("T1", creds); + expect(seenUrl).toContain("/contribuyente/gem/comprobantes/envios/T1"); + expect(r.codRespuesta).toBe("0001"); + }); +}); + +describe("pollGreTicket", () => { + test("returns 'completed' when codRespuesta=0001", 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({ numTicket: "T1", codRespuesta: "0001", desRespuesta: "Aceptado" }), { status: 200 }); + }); + const r = await pollGreTicket({ creds, numTicket: "T1", initialDelayMs: 1, maxDelayMs: 1, timeoutMs: 1000 }); + expect(r.state).toBe("completed"); + expect(r.codRespuesta).toBe("0001"); + }); + + test("returns 'rejected' when codRespuesta=0003", 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({ numTicket: "T1", codRespuesta: "0003", desRespuesta: "Rechazado" }), { status: 200 }); + }); + const r = await pollGreTicket({ creds, numTicket: "T1", initialDelayMs: 1, maxDelayMs: 1, timeoutMs: 1000 }); + expect(r.state).toBe("rejected"); + }); + + 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({ numTicket: "T1", codRespuesta: "0098", desRespuesta: "En proceso" }), { status: 200 }); + }); + const r = await pollGreTicket({ creds, numTicket: "T1", initialDelayMs: 1, maxDelayMs: 1, timeoutMs: 30 }); + expect(r.state).toBe("still-processing"); + }); +}); diff --git a/packages/cli/tests/unit/ubl-gre.test.ts b/packages/cli/tests/unit/ubl-gre.test.ts new file mode 100644 index 0000000..694ded7 --- /dev/null +++ b/packages/cli/tests/unit/ubl-gre.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { type GreInput, buildGreUbl, greFilename } from "../../src/cpe/ubl/gre.ts"; + +const ctx = { emisor: { ruc: "20131312955", razonSocial: "EMPRESA EMISORA SAC" } }; + +const baseInput: GreInput = { + tipoDoc: "09", + serie: "T001", + numero: 1, + fechaEmision: "2026-04-29", + horaEmision: "12:00:00", + destinatario: { tipoDoc: "6", numDoc: "20100070970", rznSocial: "CLIENTE SAC" }, + envio: { + codTraslado: "01", + modTraslado: "02", + fecTraslado: "2026-04-29", + pesoTotal: 100.5, + undPesoTotal: "KGM", + numBultos: 2, + chofer: { tipoDoc: "1", nroDoc: "12345678", nombres: "JUAN", apellidos: "PEREZ", licencia: "Q12345678" }, + vehiculo: { placa: "ABC-123" }, + partida: { ubigeo: "150101", direccion: "AV LIMA 123" }, + llegada: { ubigeo: "150114", direccion: "AV ALIVERTI 456" }, + }, + items: [ + { codigo: "P001", descripcion: "Caja de cervezas", cantidad: 10, unidad: "NIU" }, + ], +}; + +describe("buildGreUbl", () => { + test("starts with XML UTF-8 declaration", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml.startsWith('')).toBe(true); + }); + + test("uses DespatchAdvice root", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain(" { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain(">09"); + }); + + test("ID is serie-numero", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain("T001-1"); + }); + + test("includes ext:UBLExtensions placeholder", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain(""); + }); + + test("emisor RUC in DespatchSupplierParty", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain(">20131312955<"); + }); + + test("destinatario in DeliveryCustomerParty", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain(">20100070970<"); + expect(xml).toContain("CLIENTE SAC"); + }); + + test("Shipment carries codTraslado in HandlingCode", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain("01"); + }); + + test("modTraslado in TransportModeCode", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain(">02"); + }); + + test("chofer rendered when modTraslado=02", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain(">12345678<"); + expect(xml).toContain("Q12345678"); + }); + + test("vehiculo placa in TransportEquipment", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain(">ABC-123<"); + }); + + test("ubigeos in DeliveryAddress + DespatchAddress", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain(">150114<"); // llegada + expect(xml).toContain(">150101<"); // partida + }); + + test("DespatchLine count matches items", () => { + const multi = { ...baseInput, items: [...baseInput.items, ...baseInput.items] }; + const xml = buildGreUbl(multi, ctx); + const matches = xml.match(//g) || []; + expect(matches.length).toBe(2); + }); + + test("GrossWeightMeasure formatted", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml).toContain('unitCode="KGM"'); + expect(xml).toContain(">100.50"); + }); + + test("encoding is UTF-8 without BOM", () => { + const xml = buildGreUbl(baseInput, ctx); + expect(xml.charCodeAt(0)).not.toBe(0xfeff); + }); +}); + +describe("greFilename", () => { + test("RUC-09-SERIE-NUMERO format", () => { + expect(greFilename("20131312955", "T001", 1234)).toBe("20131312955-09-T001-1234"); + }); +});