diff --git a/packages/cli/LIMITATIONS.md b/packages/cli/LIMITATIONS.md index 938c4fc..9b70e63 100644 --- a/packages/cli/LIMITATIONS.md +++ b/packages/cli/LIMITATIONS.md @@ -24,14 +24,14 @@ If you hit something that's not documented here, open an issue. | Driver | Factura | Boleta | NC/ND | Guia | Resumen Diario | Comunicación Baja | |--------|---------|--------|-------|------|----------------|-------------------| | `mock` | 🔬 | 🔬 | 🔬 | 🚧 | 🚧 | 🚧 | -| `sunat-direct` | 🔬 | 🔬 (≥S/700 individual) | 🚧 | 🚧 | ⚠️ XML verified, send blocked by WAF on test RUC | ⚠️ XML verified, untested live | +| `sunat-direct` | 🔬 | 🔬 (≥S/700 individual) | 🔬 | ⚠️ via REST `cpe gre` (PR #7) | ⚠️ XML verified, send blocked by WAF on test RUC | ⚠️ XML verified, untested live | | `facturador` | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | | `nubefact` | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | | `apisperu` | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | ### Active limitations -- **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**. +- **NC, ND** — ✅ shipped in PR #9 as `sunat cpe nc emit` and `sunat cpe nd emit` (sunat-direct driver). UBL builders for `CreditNote-2` / `DebitNote-2` schemas, full validation against Catálogo 09 (NC) and Catálogo 10 (ND), reuses XAdES signer + SOAP `sendBill` from PR #1. **Verified end-to-end against SUNAT beta 2026-04-29** (FC01-555 and FD01-777 both `cdrCode=0` Aceptado). - **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 @@ -50,6 +50,8 @@ If you hit something that's not documented here, open an issue. - ✅ `cpe factura emit --driver sunat-direct` → `cdrCode=0` Aceptado - ✅ `cpe boleta emit --driver sunat-direct` (≥S/700) → `cdrCode=0` Aceptado +- ✅ `cpe nc emit --driver sunat-direct` → `cdrCode=0` Aceptado (FC01-555, PR #9) +- ✅ `cpe nd emit --driver sunat-direct` → `cdrCode=0` Aceptado (FD01-777, PR #9) - ✅ Idempotency cache (re-emit same serie+numero returns cached CDR) --- diff --git a/packages/cli/src/commands/cpe/index.ts b/packages/cli/src/commands/cpe/index.ts index aadedab..93ca98b 100644 --- a/packages/cli/src/commands/cpe/index.ts +++ b/packages/cli/src/commands/cpe/index.ts @@ -308,10 +308,38 @@ export function createCpeCommand(): Command { nd .command("emit") - .description("Emit a Nota de Debito. T2. STUB.") + .description("Emit a Nota de Debito. T2.") .requiredOption("--params ") + .option("--dry-run") .option("--yes") - .action((_, cmd) => notImplemented("nd emit", getFormat(cmd))); + .action(async (opts, cmd) => { + const format = getFormat(cmd); + try { + const input = parseNotaInput(opts.params); + const driver = getDriver(getDriverName(cmd)); + + if (opts.dryRun) { + const preview = await driver.previewFactura(input); + output(format, { json: { dryRun: true, ...preview } }); + return; + } + if (!opts.yes) { + outputError("T2 emission requires --yes flag.", format); + return; + } + + const result = await driver.emitNotaDebito(input); + audit({ + command: "cpe nd emit", + args: input as unknown as Record, + result: "success", + details: result as unknown as Record, + }); + output(format, { json: { success: true, ...result } }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + }); const gre = cpe.command("gre").description("Guía de Remisión Electrónica (CPE tipo 09) — REST OAuth, NOT SOAP. T0/T2."); cpe diff --git a/packages/cli/src/cpe/drivers/sunat-direct.ts b/packages/cli/src/cpe/drivers/sunat-direct.ts index b24fdee..79d4561 100644 --- a/packages/cli/src/cpe/drivers/sunat-direct.ts +++ b/packages/cli/src/cpe/drivers/sunat-direct.ts @@ -7,8 +7,10 @@ import { getStatus, pollStatus, sendBill, sendSummary, SUNAT_ENDPOINTS_FAC } fro import { bajaFilenameRA, buildBajaUbl } from "../ubl/baja.ts"; import { boletaFilename, boletaRequiresIndividualSubmission, buildBoletaUbl } from "../ubl/boleta.ts"; import { buildFacturaUbl, facturaFilename } from "../ubl/factura.ts"; +import { buildNotaCreditoUbl, notaCreditoFilename } from "../ubl/nota-credito.ts"; +import { buildNotaDebitoUbl, notaDebitoFilename } from "../ubl/nota-debito.ts"; import { buildResumenUbl, resumenFilename } from "../ubl/resumen.ts"; -import { validateBoleta, validateFactura } from "../validation/reglas.ts"; +import { validateBoleta, validateFactura, validateNotaCredito, validateNotaDebito } from "../validation/reglas.ts"; import type { BajaSubmitInput, BajaSubmitResult, @@ -252,12 +254,75 @@ export class SunatDirectDriver implements CpeDriver { } } - async emitNotaCredito(_input: NotaCreditoInput): Promise { - throw new Error("sunat-direct: nota de credito not yet implemented. Use --driver mock."); + async emitNotaCredito(input: NotaCreditoInput): Promise { + return this.emitNota(input, "07"); } - async emitNotaDebito(_input: NotaDebitoInput): Promise { - throw new Error("sunat-direct: nota de debito not yet implemented. Use --driver mock."); + async emitNotaDebito(input: NotaDebitoInput): Promise { + return this.emitNota(input, "08"); + } + + private async emitNota(input: NotaCreditoInput | NotaDebitoInput, tipo: "07" | "08"): Promise { + const ctx = resolveCpeContext(); + const errors = tipo === "07" + ? validateNotaCredito(input as NotaCreditoInput) + : validateNotaDebito(input as NotaDebitoInput); + if (errors.length > 0) { + throw new Error(`Validation failed: ${errors.map((e) => `[${e.code}] ${e.message}`).join("; ")}`); + } + + const idemKey = { emisorRuc: ctx.emisor.ruc, tipo: tipo as "07" | "08", serie: input.serie, numero: input.numero }; + const cached = findCachedResult(idemKey); + if (cached) return cached; + + const unsigned = tipo === "07" + ? buildNotaCreditoUbl(input as NotaCreditoInput, { emisor: ctx.emisor }) + : buildNotaDebitoUbl(input as NotaDebitoInput, { emisor: ctx.emisor }); + const signed = signFacturaXml(unsigned, { pfxPath: ctx.certPath, pfxPassword: ctx.certPassword }); + const filename = tipo === "07" + ? notaCreditoFilename(ctx.emisor.ruc, input.serie, input.numero) + : notaDebitoFilename(ctx.emisor.ruc, input.serie, input.numero); + const hash = `sha256:${await sha256Hex(signed.xml)}`; + + const auditCmd = tipo === "07" ? "cpe nc emit" : "cpe nd emit"; + const auditArgs = { + serie: input.serie, + numero: input.numero, + refSerie: input.refSerie, + refNumero: input.refNumero, + tipoNota: input.tipoNota, + total: input.totales.total, + }; + logPending(idemKey, auditCmd, auditArgs); + + try { + const soapResult = await sendBill({ + mode: ctx.mode, + wsUsername: `${ctx.emisor.ruc}${ctx.solUsuario}`, + wsPassword: ctx.solPassword, + xml: signed.xml, + filename, + }); + + const result: CpeResult = { + id: idempotencyKey(idemKey), + serie: input.serie, + numero: input.numero, + hash, + status: soapResult.cdr.accepted ? "accepted" : "rejected", + cdrCode: soapResult.cdr.responseCode, + cdrDesc: soapResult.cdr.description, + xml: signed.xml, + ts: new Date().toISOString(), + }; + + logSuccess(idemKey, auditCmd, auditArgs, result); + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logFailure(idemKey, auditCmd, auditArgs, msg); + throw err; + } } async submitResumen(input: ResumenSubmitInput): Promise { diff --git a/packages/cli/src/cpe/ubl/nota-credito.ts b/packages/cli/src/cpe/ubl/nota-credito.ts new file mode 100644 index 0000000..6e01c2b --- /dev/null +++ b/packages/cli/src/cpe/ubl/nota-credito.ts @@ -0,0 +1,108 @@ +/** + * UBL 2.1 Nota de Crédito Electrónica builder for SUNAT (CPE tipo 07). + * + * Differences vs Factura: + * - Root: (NS CreditNote-2) + * - Line: with instead of InvoicedQuantity + * - Adds with motivo (Catálogo 09) + * - Adds pointing to the original Factura/Boleta + * - Serie: FXNN (when relating to a Factura) or BXNN (when relating to a Boleta) + * - Filename: {RUC}-07-{serie}-{numero} + * - InvoiceTypeCode replaced by absence (CreditNote root implies tipo 07) + * + * References: + * - https://cpe.sunat.gob.pe/sites/default/files/inline-files/guia+xml+nota%20de%20cr%C3%A9dito+version+2-1+1+0_0_0%20(2).pdf + * - Greenter notacr2.1.xml.twig + */ + +import type { NotaCreditoInput } from "../drivers/types.ts"; +import { + type EmisorCtx, + escapeXml, + renderCacSignature, + renderEmisorParty, + renderReceptorParty, + renderTaxAndTotals, + renderInvoiceLine, +} from "./common.ts"; + +export interface NotaContext { + emisor: EmisorCtx; +} + +const NC_NS = { + xmlns: "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2", + cac: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", + cbc: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", + ds: "http://www.w3.org/2000/09/xmldsig#", + ext: "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2", +} as const; + +export function notaCreditoFilename(emisorRuc: string, serie: string, numero: number): string { + return `${emisorRuc}-07-${serie}-${numero}`; +} + +/** + * Render a CreditNoteLine. + * + * The factura's renderInvoiceLine returns "......". + * We post-process its output to swap the wrapper tag + quantity tag, then keep + * everything else (PricingReference, TaxTotal, Item, Price) identical — they + * are valid in the CreditNote schema unchanged. + */ +function renderCreditNoteLine(item: NotaCreditoInput["items"][number], idx: number, moneda: string): string { + const invoiceLineXml = renderInvoiceLine(item, idx, moneda); + return invoiceLineXml + .replace(//g, "") + .replace(/<\/cac:InvoiceLine>/g, "") + .replace(//g, ""); +} + +/** + * Detect the SUNAT document type code of the affected document from the serie. + * F* → 01 (Factura), B* → 03 (Boleta). + */ +function tipDocAfectadoFromSerie(serie: string): "01" | "03" { + if (serie.startsWith("B")) return "03"; + return "01"; +} + +export function buildNotaCreditoUbl(input: NotaCreditoInput, ctx: NotaContext): string { + const { emisor } = ctx; + const id = `${input.serie}-${input.numero}`; + const lines = input.items.map((item, idx) => renderCreditNoteLine(item, idx, input.moneda)).join("\n"); + + const refSerieNumero = `${input.refSerie}-${input.refNumero}`; + const tipDocAfectado = tipDocAfectadoFromSerie(input.refSerie); + + return ` + + + + + + + 2.1 + 2.0 + ${escapeXml(id)} + ${input.fechaEmision} + ${input.moneda} + + ${escapeXml(refSerieNumero)} + ${escapeXml(input.tipoNota)} + + + + + ${escapeXml(refSerieNumero)} + ${tipDocAfectado} + + +${renderCacSignature(emisor)} +${renderEmisorParty(emisor)} +${renderReceptorParty(input.receptor)} +${renderTaxAndTotals(input.totales, input.moneda)} +${lines} +`; +} diff --git a/packages/cli/src/cpe/ubl/nota-debito.ts b/packages/cli/src/cpe/ubl/nota-debito.ts new file mode 100644 index 0000000..c7c65ad --- /dev/null +++ b/packages/cli/src/cpe/ubl/nota-debito.ts @@ -0,0 +1,123 @@ +/** + * UBL 2.1 Nota de Débito Electrónica builder for SUNAT (CPE tipo 08). + * + * Differences vs Nota de Crédito: + * - Root: (NS DebitNote-2) + * - Line: with + * - Totales: instead of + * - tipoNota uses Catalog 10 (Tipo de nota de débito) instead of Catalog 09 + * - Filename: {RUC}-08-{serie}-{numero} + * + * Common with NC: DiscrepancyResponse + BillingReference pointing to the + * affected Factura/Boleta. Same emisor/receptor blocks. Same line shape + * apart from the wrapper tags. + */ + +import type { NotaDebitoInput } from "../drivers/types.ts"; +import { + type EmisorCtx, + escapeXml, + renderCacSignature, + renderEmisorParty, + renderReceptorParty, + renderInvoiceLine, + fmt, +} from "./common.ts"; + +export interface NotaDebitoContext { + emisor: EmisorCtx; +} + +const ND_NS = { + xmlns: "urn:oasis:names:specification:ubl:schema:xsd:DebitNote-2", + cac: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", + cbc: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", + ds: "http://www.w3.org/2000/09/xmldsig#", + ext: "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2", +} as const; + +export function notaDebitoFilename(emisorRuc: string, serie: string, numero: number): string { + return `${emisorRuc}-08-${serie}-${numero}`; +} + +function renderDebitNoteLine(item: NotaDebitoInput["items"][number], idx: number, moneda: string): string { + const invoiceLineXml = renderInvoiceLine(item, idx, moneda); + return invoiceLineXml + .replace(//g, "") + .replace(/<\/cac:InvoiceLine>/g, "") + .replace(//g, ""); +} + +function tipDocAfectadoFromSerie(serie: string): "01" | "03" { + if (serie.startsWith("B")) return "03"; + return "01"; +} + +/** + * ND uses RequestedMonetaryTotal, NOT LegalMonetaryTotal. We keep the + * TaxTotal block identical to NC/Factura but rebuild the totals block. + */ +function renderTaxAndTotalsND(totales: { valorVenta: number; igv: number; total: number }, moneda: string): string { + const totalIgv = totales.igv; + const totalValor = totales.valorVenta; + const totalPagar = totales.total; + return ` + ${fmt(totalIgv)} + + ${fmt(totalValor)} + ${fmt(totalIgv)} + + + 1000 + IGV + VAT + + + + + + ${fmt(totalValor)} + ${fmt(totalPagar)} + ${fmt(totalPagar)} + `; +} + +export function buildNotaDebitoUbl(input: NotaDebitoInput, ctx: NotaDebitoContext): string { + const { emisor } = ctx; + const id = `${input.serie}-${input.numero}`; + const lines = input.items.map((item, idx) => renderDebitNoteLine(item, idx, input.moneda)).join("\n"); + + const refSerieNumero = `${input.refSerie}-${input.refNumero}`; + const tipDocAfectado = tipDocAfectadoFromSerie(input.refSerie); + + return ` + + + + + + + 2.1 + 2.0 + ${escapeXml(id)} + ${input.fechaEmision} + ${input.moneda} + + ${escapeXml(refSerieNumero)} + ${escapeXml(input.tipoNota)} + + + + + ${escapeXml(refSerieNumero)} + ${tipDocAfectado} + + +${renderCacSignature(emisor)} +${renderEmisorParty(emisor)} +${renderReceptorParty(input.receptor)} +${renderTaxAndTotalsND(input.totales, input.moneda)} +${lines} +`; +} diff --git a/packages/cli/src/cpe/validation/reglas.ts b/packages/cli/src/cpe/validation/reglas.ts index 4572f22..070bacd 100644 --- a/packages/cli/src/cpe/validation/reglas.ts +++ b/packages/cli/src/cpe/validation/reglas.ts @@ -5,7 +5,7 @@ * before hitting the SUNAT SOAP endpoint. Full SUNAT catalog is ~600 rules. */ -import type { BoletaInput, CpeItem, CpeTotales, FacturaInput } from "../drivers/types.ts"; +import type { BoletaInput, CpeItem, CpeTotales, FacturaInput, NotaCreditoInput, NotaDebitoInput } from "../drivers/types.ts"; import { BOLETA_RECEPTOR_REQUIRED_THRESHOLD } from "../ubl/boleta.ts"; export interface ValidationError { @@ -17,9 +17,36 @@ export interface ValidationError { const RUC_FACTOR = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2]; const SERIE_FACTURA = /^F[0-9A-Z]{3}$/; const SERIE_BOLETA = /^B[0-9A-Z]{3}$/; +const SERIE_NOTA = /^[FB][0-9A-Z]{3}$/; // NC/ND series mirror the affected document const FECHA_ISO = /^\d{4}-\d{2}-\d{2}$/; const PLAZO_DIAS = 3; +/** SUNAT Catalog 09 — Tipo de nota de crédito */ +const TIPO_NOTA_CREDITO_VALIDOS = new Set([ + "01", // Anulación de la operación + "02", // Anulación por error en el RUC + "03", // Corrección por error en la descripción + "04", // Descuento global + "05", // Descuento por ítem + "06", // Devolución total + "07", // Devolución por ítem + "08", // Bonificación + "09", // Disminución en el valor + "10", // Otros conceptos + "11", // Ajustes de operaciones de exportación + "12", // Ajustes afectos al IVAP + "13", // Ajustes - montos y/o fechas de pago +]); + +/** SUNAT Catalog 10 — Tipo de nota de débito */ +const TIPO_NOTA_DEBITO_VALIDOS = new Set([ + "01", // Intereses por mora + "02", // Aumento en el valor + "03", // Penalidades / otros conceptos + "10", // Ajustes de operaciones de exportación + "11", // Ajustes afectos al IVAP +]); + export function validateRucChecksum(ruc: string): boolean { if (!/^\d{11}$/.test(ruc)) return false; const digits = ruc.split("").map(Number); @@ -234,3 +261,95 @@ export function validateBoleta(input: BoletaInput, today = new Date()): Validati return errors; } + +/** + * Shared core for NC/ND: validates the reference fields (refSerie, refNumero, + * tipoNota, motivo) plus the standard items+totales+moneda block. + * + * The catalog of valid tipoNota codes is passed by the caller so the same + * function powers both NC (Catalog 09) and ND (Catalog 10). + */ +function validateNotaCommon( + input: NotaCreditoInput | NotaDebitoInput, + tipoNotaValidos: Set, + catalogName: string, + today: Date, +): ValidationError[] { + const errors: ValidationError[] = []; + + if (!SERIE_NOTA.test(input.serie)) { + errors.push({ + code: "SERIE_FORMAT", + field: "serie", + message: `Nota serie '${input.serie}' must match [FB][A-Z0-9]{3}`, + }); + } + + if (!Number.isInteger(input.numero) || input.numero < 1 || input.numero > 99_999_999) { + errors.push({ code: "NUMERO_RANGE", field: "numero", message: "numero must be integer 1..99999999" }); + } + + if (!validateFechaPlazo(input.fechaEmision, today)) { + errors.push({ + code: "FECHA_PLAZO", + field: "fechaEmision", + message: `fechaEmision '${input.fechaEmision}' is outside the ${PLAZO_DIAS}-day SUNAT window or malformed`, + }); + } + + if (!input.refSerie || !/^[FB][0-9A-Z]{3}$/.test(input.refSerie)) { + errors.push({ + code: "REF_SERIE_FORMAT", + field: "refSerie", + message: `refSerie '${input.refSerie}' must reference a Factura (F***) or Boleta (B***)`, + }); + } + + if (!Number.isInteger(input.refNumero) || input.refNumero < 1) { + errors.push({ code: "REF_NUMERO_RANGE", field: "refNumero", message: "refNumero must be a positive integer" }); + } + + if (!input.tipoNota || !tipoNotaValidos.has(input.tipoNota)) { + errors.push({ + code: "TIPO_NOTA_INVALIDO", + field: "tipoNota", + message: `tipoNota '${input.tipoNota}' not in ${catalogName}: ${[...tipoNotaValidos].join(", ")}`, + }); + } + + if (!input.motivo || input.motivo.length === 0) { + errors.push({ code: "MOTIVO_REQUIRED", field: "motivo", message: "motivo description is required" }); + } else if (input.motivo.length > 250) { + errors.push({ code: "MOTIVO_LENGTH", field: "motivo", message: "motivo must be ≤ 250 chars" }); + } + + if (input.receptor.tipoDoc === "6") { + if (!validateRucChecksum(input.receptor.numDoc)) { + errors.push({ code: "RUC_RECEPTOR", field: "receptor.numDoc", message: "RUC receptor checksum invalid" }); + } + } else if (input.receptor.tipoDoc === "1") { + if (!/^\d{8}$/.test(input.receptor.numDoc)) { + errors.push({ code: "DNI_RECEPTOR", field: "receptor.numDoc", message: "DNI must be 8 digits" }); + } + } + + if (!input.receptor.rznSocial || input.receptor.rznSocial.length === 0) { + errors.push({ code: "RZN_SOCIAL", field: "receptor.rznSocial", message: "rznSocial required" }); + } + + errors.push(...validateItemsAndTotals(input.items, input.totales)); + + if (input.moneda !== "PEN" && input.moneda !== "USD") { + errors.push({ code: "MONEDA", field: "moneda", message: "moneda must be PEN or USD" }); + } + + return errors; +} + +export function validateNotaCredito(input: NotaCreditoInput, today = new Date()): ValidationError[] { + return validateNotaCommon(input, TIPO_NOTA_CREDITO_VALIDOS, "Catálogo 09 (Tipo de nota de crédito)", today); +} + +export function validateNotaDebito(input: NotaDebitoInput, today = new Date()): ValidationError[] { + return validateNotaCommon(input, TIPO_NOTA_DEBITO_VALIDOS, "Catálogo 10 (Tipo de nota de débito)", today); +} diff --git a/packages/cli/tests/e2e/cpe-cli.test.ts b/packages/cli/tests/e2e/cpe-cli.test.ts index f0a8fb4..2ed534a 100644 --- a/packages/cli/tests/e2e/cpe-cli.test.ts +++ b/packages/cli/tests/e2e/cpe-cli.test.ts @@ -207,11 +207,36 @@ describe("sunat cpe — E2E", () => { expect(json.checks.find((c) => c.name === "config_resolved")?.ok).toBe(false); }); - test("cpe nd emit returns shaped-not-implemented stub error", async () => { + test("cpe nd emit fails on empty params (validation gate)", async () => { const result = await runCli(["-o", "json", "cpe", "nd", "emit", "--params", "{}", "--yes"]); expect(result.exitCode).toBe(1); const combined = result.stdout + result.stderr; - expect(combined).toContain("not implemented"); + // Either parseFacturaInput rejects empty receptor/items/totales, or + // parseNotaInput rejects missing refSerie. Both are valid gates. + const matchedReason = /Missing required fields|refSerie|refNumero|tipoNota/.test(combined); + expect(matchedReason).toBe(true); + }); + + test("cpe nd emit rejects nota with valid factura body but missing refSerie", async () => { + const result = await runCli(["-o", "json", "cpe", "nd", "emit", "--params", JSON.stringify(validFactura), "--yes"]); + expect(result.exitCode).toBe(1); + const combined = result.stdout + result.stderr; + expect(combined).toContain("refSerie"); + }); + + test("cpe nd emit --yes succeeds with full nota payload (mock driver)", async () => { + const validNota = { + ...validFactura, + motivo: "Aumento por mora", + tipoNota: "01", // Catálogo 10 — Intereses por mora + refSerie: "F001", + refNumero: 1230, + }; + const result = await runCli(["-o", "json", "cpe", "nd", "emit", "--params", JSON.stringify(validNota), "--yes"]); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout) as { success: boolean; status: string }; + expect(json.success).toBe(true); + expect(json.status).toBe("accepted"); }); test("cpe gre --help lists emit + status verbs", async () => { diff --git a/packages/cli/tests/unit/reglas-nota.test.ts b/packages/cli/tests/unit/reglas-nota.test.ts new file mode 100644 index 0000000..7d5815e --- /dev/null +++ b/packages/cli/tests/unit/reglas-nota.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import { validateNotaCredito, validateNotaDebito } from "../../src/cpe/validation/reglas.ts"; +import type { NotaCreditoInput, NotaDebitoInput } from "../../src/cpe/drivers/types.ts"; + +const today = new Date().toISOString().split("T")[0]; + +const validNc: NotaCreditoInput = { + receptor: { tipoDoc: "6", numDoc: "20536557858", rznSocial: "X SAC" }, + items: [{ codigo: "P", descripcion: "X", cantidad: 1, unidad: "ZZ", valorUnitario: 100, igvPct: 18 }], + totales: { valorVenta: 100, igv: 18, total: 118 }, + moneda: "PEN", + serie: "FC01", + numero: 1, + fechaEmision: today, + motivo: "Anulación", + tipoNota: "01", + refSerie: "F001", + refNumero: 1234, +}; + +const validNd: NotaDebitoInput = { ...validNc, serie: "FD01", motivo: "Mora", tipoNota: "01" }; + +describe("validateNotaCredito", () => { + test("happy path returns no errors", () => { + expect(validateNotaCredito(validNc)).toEqual([]); + }); + + test("rejects invalid serie format", () => { + const errors = validateNotaCredito({ ...validNc, serie: "X001" }); + expect(errors.some((e) => e.code === "SERIE_FORMAT")).toBe(true); + }); + + test("accepts F-prefix and B-prefix series (NC of factura or boleta)", () => { + expect(validateNotaCredito({ ...validNc, serie: "FC01" })).toEqual([]); + expect(validateNotaCredito({ ...validNc, serie: "BC01", refSerie: "B001" })).toEqual([]); + }); + + test("rejects missing refSerie", () => { + const errors = validateNotaCredito({ ...validNc, refSerie: "" }); + expect(errors.some((e) => e.code === "REF_SERIE_FORMAT")).toBe(true); + }); + + test("rejects malformed refSerie (must be F*** or B***)", () => { + const errors = validateNotaCredito({ ...validNc, refSerie: "X999" }); + expect(errors.some((e) => e.code === "REF_SERIE_FORMAT")).toBe(true); + }); + + test("rejects refNumero < 1", () => { + const errors = validateNotaCredito({ ...validNc, refNumero: 0 }); + expect(errors.some((e) => e.code === "REF_NUMERO_RANGE")).toBe(true); + }); + + test("rejects tipoNota not in Catálogo 09", () => { + const errors = validateNotaCredito({ ...validNc, tipoNota: "99" }); + expect(errors.some((e) => e.code === "TIPO_NOTA_INVALIDO")).toBe(true); + }); + + test("accepts all 13 codes in Catálogo 09", () => { + const valid09 = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13"]; + for (const code of valid09) { + expect(validateNotaCredito({ ...validNc, tipoNota: code })).toEqual([]); + } + }); + + test("rejects empty motivo", () => { + const errors = validateNotaCredito({ ...validNc, motivo: "" }); + expect(errors.some((e) => e.code === "MOTIVO_REQUIRED")).toBe(true); + }); + + test("rejects motivo > 250 chars", () => { + const errors = validateNotaCredito({ ...validNc, motivo: "x".repeat(300) }); + expect(errors.some((e) => e.code === "MOTIVO_LENGTH")).toBe(true); + }); + + test("rejects RUC receptor with bad checksum", () => { + const errors = validateNotaCredito({ + ...validNc, + receptor: { tipoDoc: "6", numDoc: "20131312956", rznSocial: "X" }, + }); + expect(errors.some((e) => e.code === "RUC_RECEPTOR")).toBe(true); + }); + + test("rejects fechaEmision out of plazo (>3 days)", () => { + const errors = validateNotaCredito({ ...validNc, fechaEmision: "2020-01-01" }); + expect(errors.some((e) => e.code === "FECHA_PLAZO")).toBe(true); + }); + + test("propagates totales mismatch", () => { + const errors = validateNotaCredito({ ...validNc, totales: { valorVenta: 100, igv: 18, total: 9999 } }); + expect(errors.some((e) => e.code === "TOTAL_TOTAL")).toBe(true); + }); +}); + +describe("validateNotaDebito", () => { + test("happy path returns no errors", () => { + expect(validateNotaDebito(validNd)).toEqual([]); + }); + + test("rejects tipoNota not in Catálogo 10", () => { + // "04" is in Catálogo 09 but NOT in 10 — must reject for ND + const errors = validateNotaDebito({ ...validNd, tipoNota: "04" }); + expect(errors.some((e) => e.code === "TIPO_NOTA_INVALIDO")).toBe(true); + }); + + test("accepts all 5 codes in Catálogo 10", () => { + const valid10 = ["01", "02", "03", "10", "11"]; + for (const code of valid10) { + expect(validateNotaDebito({ ...validNd, tipoNota: code })).toEqual([]); + } + }); + + test("rejects code valid in Catálogo 09 but not in 10", () => { + // "06" (Devolución total) is NC-only + const errors = validateNotaDebito({ ...validNd, tipoNota: "06" }); + expect(errors.some((e) => e.code === "TIPO_NOTA_INVALIDO")).toBe(true); + }); + + test("shares same series + ref + items + totales rules as NC", () => { + const errors = validateNotaDebito({ ...validNd, refSerie: "X999" }); + expect(errors.some((e) => e.code === "REF_SERIE_FORMAT")).toBe(true); + }); +}); diff --git a/packages/cli/tests/unit/ubl-nota-credito.test.ts b/packages/cli/tests/unit/ubl-nota-credito.test.ts new file mode 100644 index 0000000..c162859 --- /dev/null +++ b/packages/cli/tests/unit/ubl-nota-credito.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { buildNotaCreditoUbl, notaCreditoFilename } from "../../src/cpe/ubl/nota-credito.ts"; +import type { NotaCreditoInput } from "../../src/cpe/drivers/types.ts"; + +const ctx = { emisor: { ruc: "20131312955", razonSocial: "EMPRESA EMISORA SAC", ubigeo: "150101", direccion: "AV LIMA 123" } }; + +const baseInput: NotaCreditoInput = { + receptor: { tipoDoc: "6", numDoc: "20536557858", rznSocial: "RECEPTOR SAC" }, + items: [{ codigo: "P001", descripcion: "Servicio devuelto", cantidad: 1, unidad: "ZZ", valorUnitario: 1000, igvPct: 18 }], + totales: { valorVenta: 1000, igv: 180, total: 1180 }, + moneda: "PEN", + serie: "FC01", + numero: 100, + fechaEmision: "2026-04-29", + motivo: "Anulación por error en datos", + tipoNota: "01", + refSerie: "F001", + refNumero: 1234, +}; + +describe("buildNotaCreditoUbl", () => { + test("XML UTF-8 declaration without BOM", () => { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml.startsWith('')).toBe(true); + expect(xml.charCodeAt(0)).not.toBe(0xfeff); + }); + + test("uses CreditNote root + CreditNote-2 namespace", () => { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml).toContain(""); + }); + + test("does NOT use Invoice root or InvoiceTypeCode (NC has no type code)", () => { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml).not.toContain(" { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml).toContain("FC01-100"); + }); + + test("DiscrepancyResponse carries refDoc + ResponseCode (Catálogo 09) + Description", () => { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain("F001-1234"); + expect(xml).toContain("01"); + expect(xml).toContain("Anulación por error en datos"); + }); + + test("BillingReference ties NC to original Factura (DocumentTypeCode=01)", () => { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain("F001-1234"); + expect(xml).toContain("01"); + }); + + test("BillingReference DocumentTypeCode=03 when refSerie starts with B (Boleta)", () => { + const xml = buildNotaCreditoUbl({ ...baseInput, refSerie: "B001" }, ctx); + expect(xml).toContain("03"); + }); + + test("uses CreditNoteLine + CreditedQuantity (NOT InvoiceLine/InvoicedQuantity)", () => { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain(""); + expect(xml).not.toContain(" { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml).toContain(">20131312955<"); + expect(xml).toContain("EMPRESA EMISORA SAC"); + expect(xml).toContain(">20536557858<"); + expect(xml).toContain("RECEPTOR SAC"); + }); + + test("CreditNoteLine count matches items", () => { + const multi = { + ...baseInput, + items: [ + { codigo: "A", descripcion: "X", cantidad: 1, unidad: "NIU", valorUnitario: 100, igvPct: 18 }, + { codigo: "B", descripcion: "Y", cantidad: 2, unidad: "NIU", valorUnitario: 50, igvPct: 18 }, + { codigo: "C", descripcion: "Z", cantidad: 1, unidad: "ZZ", valorUnitario: 50, igvPct: 18 }, + ], + totales: { valorVenta: 250, igv: 45, total: 295 }, + }; + const xml = buildNotaCreditoUbl(multi, ctx); + expect((xml.match(//g) || []).length).toBe(3); + }); + + test("ext:UBLExtensions placeholder for signature", () => { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain(""); + }); + + test("LegalMonetaryTotal carries totals", () => { + const xml = buildNotaCreditoUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain('currencyID="PEN">1180.00'); + }); +}); + +describe("notaCreditoFilename", () => { + test("RUC-07-SERIE-NUMERO format", () => { + expect(notaCreditoFilename("20131312955", "FC01", 100)).toBe("20131312955-07-FC01-100"); + }); +}); diff --git a/packages/cli/tests/unit/ubl-nota-debito.test.ts b/packages/cli/tests/unit/ubl-nota-debito.test.ts new file mode 100644 index 0000000..e0a4da4 --- /dev/null +++ b/packages/cli/tests/unit/ubl-nota-debito.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { buildNotaDebitoUbl, notaDebitoFilename } from "../../src/cpe/ubl/nota-debito.ts"; +import type { NotaDebitoInput } from "../../src/cpe/drivers/types.ts"; + +const ctx = { emisor: { ruc: "20131312955", razonSocial: "EMPRESA EMISORA SAC" } }; + +const baseInput: NotaDebitoInput = { + receptor: { tipoDoc: "6", numDoc: "20536557858", rznSocial: "RECEPTOR SAC" }, + items: [{ codigo: "P001", descripcion: "Cargo por mora", cantidad: 1, unidad: "ZZ", valorUnitario: 50, igvPct: 18 }], + totales: { valorVenta: 50, igv: 9, total: 59 }, + moneda: "PEN", + serie: "FD01", + numero: 1, + fechaEmision: "2026-04-29", + motivo: "Intereses por mora", + tipoNota: "01", + refSerie: "F001", + refNumero: 1234, +}; + +describe("buildNotaDebitoUbl", () => { + test("uses DebitNote root + DebitNote-2 namespace", () => { + const xml = buildNotaDebitoUbl(baseInput, ctx); + expect(xml).toContain(""); + }); + + test("uses DebitNoteLine + DebitedQuantity", () => { + const xml = buildNotaDebitoUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain(""); + expect(xml).not.toContain(" { + const xml = buildNotaDebitoUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).not.toContain(""); + expect(xml).toContain('currencyID="PEN">59.00'); + }); + + test("DiscrepancyResponse uses Catálogo 10 ResponseCode", () => { + const xml = buildNotaDebitoUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain("01"); + expect(xml).toContain("Intereses por mora"); + }); + + test("BillingReference DocumentTypeCode=03 when refSerie=B***", () => { + const xml = buildNotaDebitoUbl({ ...baseInput, refSerie: "B001" }, ctx); + expect(xml).toContain("03"); + }); + + test("ID format serie-numero", () => { + const xml = buildNotaDebitoUbl(baseInput, ctx); + expect(xml).toContain("FD01-1"); + }); + + test("ext:UBLExtensions placeholder", () => { + const xml = buildNotaDebitoUbl(baseInput, ctx); + expect(xml).toContain(""); + expect(xml).toContain(""); + }); + + test("multi-line", () => { + const multi = { + ...baseInput, + items: [ + { codigo: "A", descripcion: "Mora", cantidad: 1, unidad: "ZZ", valorUnitario: 50, igvPct: 18 }, + { codigo: "B", descripcion: "Penalidad", cantidad: 1, unidad: "ZZ", valorUnitario: 25, igvPct: 18 }, + ], + totales: { valorVenta: 75, igv: 13.5, total: 88.5 }, + }; + const xml = buildNotaDebitoUbl(multi, ctx); + expect((xml.match(//g) || []).length).toBe(2); + }); +}); + +describe("notaDebitoFilename", () => { + test("RUC-08-SERIE-NUMERO format", () => { + expect(notaDebitoFilename("20131312955", "FD01", 1)).toBe("20131312955-08-FD01-1"); + }); +});