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
6 changes: 4 additions & 2 deletions packages/cli/LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

---
Expand Down
32 changes: 30 additions & 2 deletions packages/cli/src/commands/cpe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <json>")
.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<string, unknown>,
result: "success",
details: result as unknown as Record<string, unknown>,
});
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
Expand Down
75 changes: 70 additions & 5 deletions packages/cli/src/cpe/drivers/sunat-direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -252,12 +254,75 @@ export class SunatDirectDriver implements CpeDriver {
}
}

async emitNotaCredito(_input: NotaCreditoInput): Promise<CpeResult> {
throw new Error("sunat-direct: nota de credito not yet implemented. Use --driver mock.");
async emitNotaCredito(input: NotaCreditoInput): Promise<CpeResult> {
return this.emitNota(input, "07");
}

async emitNotaDebito(_input: NotaDebitoInput): Promise<CpeResult> {
throw new Error("sunat-direct: nota de debito not yet implemented. Use --driver mock.");
async emitNotaDebito(input: NotaDebitoInput): Promise<CpeResult> {
return this.emitNota(input, "08");
}

private async emitNota(input: NotaCreditoInput | NotaDebitoInput, tipo: "07" | "08"): Promise<CpeResult> {
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<ResumenSubmitResult> {
Expand Down
108 changes: 108 additions & 0 deletions packages/cli/src/cpe/ubl/nota-credito.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* UBL 2.1 Nota de Crédito Electrónica builder for SUNAT (CPE tipo 07).
*
* Differences vs Factura:
* - Root: <CreditNote> (NS CreditNote-2)
* - Line: <CreditNoteLine> with <CreditedQuantity> instead of InvoicedQuantity
* - Adds <cac:DiscrepancyResponse> with motivo (Catálogo 09)
* - Adds <cac:BillingReference> 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 "<cac:InvoiceLine>...<InvoicedQuantity>...</InvoiceLine>".
* 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(/<cac:InvoiceLine>/g, "<cac:CreditNoteLine>")
.replace(/<\/cac:InvoiceLine>/g, "</cac:CreditNoteLine>")
.replace(/<cbc:InvoicedQuantity/g, "<cbc:CreditedQuantity")
.replace(/<\/cbc:InvoicedQuantity>/g, "</cbc:CreditedQuantity>");
}

/**
* 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 `<?xml version="1.0" encoding="UTF-8"?>
<CreditNote xmlns="${NC_NS.xmlns}" xmlns:cac="${NC_NS.cac}" xmlns:cbc="${NC_NS.cbc}" xmlns:ds="${NC_NS.ds}" xmlns:ext="${NC_NS.ext}">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent/>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>${escapeXml(id)}</cbc:ID>
<cbc:IssueDate>${input.fechaEmision}</cbc:IssueDate>
<cbc:DocumentCurrencyCode>${input.moneda}</cbc:DocumentCurrencyCode>
<cac:DiscrepancyResponse>
<cbc:ReferenceID>${escapeXml(refSerieNumero)}</cbc:ReferenceID>
<cbc:ResponseCode>${escapeXml(input.tipoNota)}</cbc:ResponseCode>
<cbc:Description><![CDATA[${input.motivo}]]></cbc:Description>
</cac:DiscrepancyResponse>
<cac:BillingReference>
<cac:InvoiceDocumentReference>
<cbc:ID>${escapeXml(refSerieNumero)}</cbc:ID>
<cbc:DocumentTypeCode>${tipDocAfectado}</cbc:DocumentTypeCode>
</cac:InvoiceDocumentReference>
</cac:BillingReference>
${renderCacSignature(emisor)}
${renderEmisorParty(emisor)}
${renderReceptorParty(input.receptor)}
${renderTaxAndTotals(input.totales, input.moneda)}
${lines}
</CreditNote>`;
}
123 changes: 123 additions & 0 deletions packages/cli/src/cpe/ubl/nota-debito.ts
Original file line number Diff line number Diff line change
@@ -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: <DebitNote> (NS DebitNote-2)
* - Line: <DebitNoteLine> with <DebitedQuantity>
* - Totales: <RequestedMonetaryTotal> instead of <LegalMonetaryTotal>
* - 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(/<cac:InvoiceLine>/g, "<cac:DebitNoteLine>")
.replace(/<\/cac:InvoiceLine>/g, "</cac:DebitNoteLine>")
.replace(/<cbc:InvoicedQuantity/g, "<cbc:DebitedQuantity")
.replace(/<\/cbc:InvoicedQuantity>/g, "</cbc:DebitedQuantity>");
}

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 ` <cac:TaxTotal>
<cbc:TaxAmount currencyID="${moneda}">${fmt(totalIgv)}</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="${moneda}">${fmt(totalValor)}</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="${moneda}">${fmt(totalIgv)}</cbc:TaxAmount>
<cac:TaxCategory>
<cac:TaxScheme>
<cbc:ID schemeName="Codigo de tributos" schemeAgencyName="PE:SUNAT" schemeURI="urn:pe:gob:sunat:cpe:see:gem:catalogos:catalogo05">1000</cbc:ID>
<cbc:Name>IGV</cbc:Name>
<cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:RequestedMonetaryTotal>
<cbc:LineExtensionAmount currencyID="${moneda}">${fmt(totalValor)}</cbc:LineExtensionAmount>
<cbc:TaxInclusiveAmount currencyID="${moneda}">${fmt(totalPagar)}</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="${moneda}">${fmt(totalPagar)}</cbc:PayableAmount>
</cac:RequestedMonetaryTotal>`;
}

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 `<?xml version="1.0" encoding="UTF-8"?>
<DebitNote xmlns="${ND_NS.xmlns}" xmlns:cac="${ND_NS.cac}" xmlns:cbc="${ND_NS.cbc}" xmlns:ds="${ND_NS.ds}" xmlns:ext="${ND_NS.ext}">
<ext:UBLExtensions>
<ext:UBLExtension>
<ext:ExtensionContent/>
</ext:UBLExtension>
</ext:UBLExtensions>
<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>2.0</cbc:CustomizationID>
<cbc:ID>${escapeXml(id)}</cbc:ID>
<cbc:IssueDate>${input.fechaEmision}</cbc:IssueDate>
<cbc:DocumentCurrencyCode>${input.moneda}</cbc:DocumentCurrencyCode>
<cac:DiscrepancyResponse>
<cbc:ReferenceID>${escapeXml(refSerieNumero)}</cbc:ReferenceID>
<cbc:ResponseCode>${escapeXml(input.tipoNota)}</cbc:ResponseCode>
<cbc:Description><![CDATA[${input.motivo}]]></cbc:Description>
</cac:DiscrepancyResponse>
<cac:BillingReference>
<cac:InvoiceDocumentReference>
<cbc:ID>${escapeXml(refSerieNumero)}</cbc:ID>
<cbc:DocumentTypeCode>${tipDocAfectado}</cbc:DocumentTypeCode>
</cac:InvoiceDocumentReference>
</cac:BillingReference>
${renderCacSignature(emisor)}
${renderEmisorParty(emisor)}
${renderReceptorParty(input.receptor)}
${renderTaxAndTotalsND(input.totales, input.moneda)}
${lines}
</DebitNote>`;
}
Loading
Loading