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
10 changes: 9 additions & 1 deletion packages/cli/LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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 @@ -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
Expand Down
171 changes: 165 additions & 6 deletions packages/cli/src/commands/cpe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <verb>' 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 <json>")
.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>", "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 <ms>", "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<string, unknown> = {
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<string, unknown>, 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<string, unknown>,
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 <id>")
.option("--wait")
.option("--timeout <ms>", "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.");

Expand Down
Loading
Loading