diff --git a/.changeset/tidy-dtes-sign.md b/.changeset/tidy-dtes-sign.md new file mode 100644 index 0000000..ce07c5c --- /dev/null +++ b/.changeset/tidy-dtes-sign.md @@ -0,0 +1,10 @@ +--- +"@emisso/sii": minor +--- + +Implement `buildDteXml`, `applyTimbre` and `signDte` for DTE types 33 (Factura Electrónica) and 39 (Boleta Electrónica). + +- `buildDteXml`: builds the `` / `` XML with type-specific element names (RznSoc/GiroEmis/Acteco for 33; RznSocEmisor/GiroEmisor for 39), derives MntNeto/IVA from MntTotal when omitted (IVA as exact remainder, never a second rounding), validates receiver requirements for facturas, and treats line amounts as GROSS: for type 33 lines are converted to net with the rounding difference absorbed into the last affected line so the detail sums to MntNeto exactly. +- `applyTimbre`: builds a real TED signed RSA-SHA1 with the CAF private key over the compact `
` block, embedding the original `` block verbatim. Signature changed to `(xml, caf: FolioRange)` and `FolioRangeSchema` gained an optional `cafXml` field, since SII's `` signature inside the CAF cannot be reconstructed from parsed fields. +- `signDte`: XMLDSig over the `` (Reference by ID + C14N transform), inserted as sibling of `` inside ``; must run after `applyTimbre`. +- `DteDocumentSchema` gained an optional `indServicio` (1|2|3) emitted only for boletas and only when explicitly set. Timestamps (TSTED/TmstFirma) use America/Santiago wall time. diff --git a/examples/basic.ts b/examples/basic.ts index 74d63d2..8ef8513 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -10,9 +10,11 @@ import { authenticate, buildDteXml, + applyTimbre, signDte, uploadDte, queryUploadStatus, + loadCafFromFile, loadConfigFromEnv, type DteDocument, } from "@emisso/sii"; @@ -47,10 +49,12 @@ async function main() { }, items: [ { + // Line amounts are GROSS (IVA-included) — buildDteXml converts them + // to net for facturas so the detail matches MntNeto exactly. nombre: "Servicio de Consultoría en Software", cantidad: 10, - precioUnitario: 50000, - montoItem: 500000, + precioUnitario: 59500, + montoItem: 595000, }, ], montoNeto: 500000, @@ -62,15 +66,20 @@ async function main() { const xml = await buildDteXml(document); console.log("DTE XML built successfully"); - // 5. Sign the DTE - const signedXml = await signDte(xml, config.certPath, config.certPassword); + // 5. Stamp with the CAF timbre (TED) — must happen BEFORE signing + const caf = await loadCafFromFile("./caf-33.xml"); + const stampedXml = await applyTimbre(xml, caf); + console.log("DTE stamped with TED"); + + // 6. Sign the DTE + const signedXml = await signDte(stampedXml, config.certPath, config.certPassword); console.log("DTE signed successfully"); - // 6. Upload to SII + // 7. Upload to SII const uploadResponse = await uploadDte(signedXml, token, config); console.log("DTE uploaded, trackId:", uploadResponse.trackId); - // 7. Check status (poll after a few seconds in real usage) + // 8. Check status (poll after a few seconds in real usage) const status = await queryUploadStatus(uploadResponse.trackId, token, config); console.log("DTE status:", status.status, "-", status.glosa); } diff --git a/packages/engine/src/dte/index.ts b/packages/engine/src/dte/index.ts index 772ea96..512c939 100644 --- a/packages/engine/src/dte/index.ts +++ b/packages/engine/src/dte/index.ts @@ -1,27 +1,483 @@ -import type { DteDocument, SiiConfig, SiiUploadResponse } from "../types"; +import * as forge from "node-forge"; +import type { DteDocument, FolioRange, SiiConfig, SiiUploadResponse } from "../types"; +import { DteDocumentSchema } from "../types"; import type { SiiToken } from "../auth"; +import { loadCertFromFile, rsaSha1Sign, sha1Digest } from "../cert"; +import { formatRut } from "../utils"; + +const SII_DTE_NS = "http://www.sii.cl/SiiDte"; +const DSIG_NS = "http://www.w3.org/2000/09/xmldsig#"; +const C14N_ALG = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"; + +/** Chilean VAT rate as a percentage (TasaIVA). */ +const IVA_RATE = 19; +/** Multiplier to convert a net amount into a gross (IVA-included) amount. */ +const IVA_FACTOR = 1 + IVA_RATE / 100; + +/** DTE types currently supported by buildDteXml. */ +const SUPPORTED_DTE_TYPES = ["33", "39"]; + +// --- XML string helpers (same manual-construction pattern as auth/xml-dsig.ts) --- + +/** Escapes XML special characters to prevent injection. */ +function escapeXml(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">"); +} + +/** Reverses escapeXml (entities produced by our own serializer). */ +function unescapeXml(str: string): string { + return str.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); +} + +/** Serializes an amount as the integer CLP string SII expects. */ +function fmtInt(n: number): string { + return String(Math.round(n)); +} + +/** Renders `value`, or an empty string when value is undefined/empty. */ +function tagIf(name: string, value: string | number | undefined): string { + if (value === undefined || value === "") return ""; + return `<${name}>${escapeXml(String(value))}`; +} + +/** Extracts the text content of the first occurrence of a simple tag. */ +function extractTag(xml: string, tag: string): string | undefined { + const match = xml.match(new RegExp(`<${tag}>([^<]*)`)); + return match ? match[1] : undefined; +} + +/** + * Current timestamp in Chile's timezone (America/Santiago), formatted + * YYYY-MM-DDTHH:mm:ss as SII expects. Using the wall clock in Chile (not UTC) + * matters: a DTE stamped at night with a UTC clock would carry tomorrow's + * date and desynchronize the tax period. + */ +function chileTimestamp(date: Date = new Date()): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "America/Santiago", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes): string => + parts.find((p) => p.type === type)?.value ?? ""; + return `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}`; +} + +// --- Totals and line-amount resolution --- + +interface ResolvedTotals { + montoNeto: number; + montoExento: number; + iva: number; + montoTotal: number; +} + +/** + * Resolves header totals. When montoNeto/iva are omitted they are derived from + * montoTotal so that `montoNeto + iva === (montoTotal - montoExento)` EXACTLY: + * the IVA is computed as the remainder (never as a separate `neto * 0.19` + * rounding, which can drift by ±1 and make the SII reject the document). + * When the caller provides them, they are used as-is but must add up to + * montoTotal within ±1. + */ +function resolveTotals(doc: DteDocument): ResolvedTotals { + const montoTotal = Math.round(doc.montoTotal); + const montoExento = Math.round(doc.montoExento ?? 0); + const afecto = montoTotal - montoExento; + if (afecto < 0) { + throw new Error( + `DTE totals are inconsistent: MntExe (${montoExento}) exceeds MntTotal (${montoTotal})` + ); + } + + let montoNeto = doc.montoNeto !== undefined ? Math.round(doc.montoNeto) : undefined; + let iva = doc.iva !== undefined ? Math.round(doc.iva) : undefined; + + if (montoNeto === undefined && iva === undefined) { + montoNeto = Math.round(afecto / IVA_FACTOR); + iva = afecto - montoNeto; + } else if (montoNeto === undefined) { + montoNeto = afecto - (iva as number); + } else if (iva === undefined) { + iva = afecto - montoNeto; + } + + const sum = montoNeto + (iva as number) + montoExento; + if (Math.abs(sum - montoTotal) > 1) { + throw new Error( + `DTE totals do not add up: MntNeto (${montoNeto}) + IVA (${iva}) + MntExe (${montoExento}) ` + + `= ${sum}, but MntTotal is ${montoTotal} (tolerance ±1)` + ); + } + if (montoNeto < 0 || (iva as number) < 0) { + throw new Error( + `DTE totals are inconsistent: resolved MntNeto (${montoNeto}) / IVA (${iva}) is negative` + ); + } + + return { montoNeto, montoExento, iva: iva as number, montoTotal }; +} + +interface ResolvedLine { + prcItem: number; + montoItem: number; +} + +/** + * Resolves per-line amounts for the Detalle section. + * + * DESIGN DECISION — items are always treated as GROSS (IVA-included) amounts: + * `DteItemSchema` has no gross/net flag, and boletas (39) must carry gross + * line amounts by definition, so the only convention consistent with a single + * item schema serving both types is "montoItem is gross" for facturas too. + * + * - Type 39 (boleta): lines are serialized as-is (gross). The SII expects the + * sum of affected MontoItem to equal MntTotal's affected portion. + * - Type 33 (factura): the SII validates that header MntNeto ≈ sum of affected + * MontoItem, so gross lines must be converted to NET before serialization + * (`round(montoItem / 1.19)`). Because each line is rounded independently, + * the net lines can drift from the header MntNeto by a few pesos; the + * difference is absorbed into the LAST affected line so the sum matches + * MntNeto EXACTLY. A type-33 DTE whose detail does not add up is rejected by + * the SII with an opaque "amounts don't match" error, which is why this + * adjustment exists. + * - Exempt lines (`exento: true`) are never converted: they carry no IVA. + */ +function resolveLineAmounts(doc: DteDocument, totals: ResolvedTotals): ResolvedLine[] { + const isFactura = doc.tipoDte === "33"; + + const lines: ResolvedLine[] = doc.items.map((item) => { + if (isFactura && !item.exento) { + return { + prcItem: Math.round(item.precioUnitario / IVA_FACTOR), + montoItem: Math.round(item.montoItem / IVA_FACTOR), + }; + } + return { + prcItem: Math.round(item.precioUnitario), + montoItem: Math.round(item.montoItem), + }; + }); + + const afectaIndexes = doc.items + .map((item, i) => (item.exento ? -1 : i)) + .filter((i) => i >= 0); + + if (isFactura) { + if (afectaIndexes.length === 0) { + if (totals.montoNeto !== 0) { + throw new Error( + `DTE type 33 has MntNeto ${totals.montoNeto} but every line is exempt; ` + + `use type 34 for fully exempt facturas` + ); + } + return lines; + } + const sumNet = afectaIndexes.reduce((sum, i) => sum + lines[i].montoItem, 0); + const diff = totals.montoNeto - sumNet; + // Per-line rounding can only drift by <1 peso per line. A larger gap means + // the input is inconsistent with the documented gross-items convention. + if (Math.abs(diff) > afectaIndexes.length) { + throw new Error( + `DTE type 33 detail does not match header: net line sum ${sumNet} vs MntNeto ` + + `${totals.montoNeto} (diff ${diff}). Line montoItem values must be GROSS ` + + `(IVA-included) amounts consistent with montoTotal` + ); + } + const last = afectaIndexes[afectaIndexes.length - 1]; + lines[last] = { ...lines[last], montoItem: lines[last].montoItem + diff }; + if (lines[last].montoItem < 0) { + throw new Error("DTE type 33 rounding adjustment produced a negative line amount"); + } + } else { + // Boleta: gross lines must add up to the gross total. + const sumGross = lines.reduce((sum, line) => sum + line.montoItem, 0); + const tolerance = Math.max(1, doc.items.length); + if (Math.abs(sumGross - totals.montoTotal) > tolerance) { + throw new Error( + `DTE type 39 detail does not match header: line sum ${sumGross} vs MntTotal ` + + `${totals.montoTotal}. Boleta line amounts must be GROSS (IVA-included)` + ); + } + } + + return lines; +} + +// --- Section builders --- + +function buildIdDocXml(doc: DteDocument): string { + return ( + `` + + `${doc.tipoDte}` + + `${doc.folio}` + + `${escapeXml(doc.fechaEmision)}` + + // IndServicio only exists for boletas and only when explicitly provided. + (doc.tipoDte === "39" ? tagIf("IndServicio", doc.indServicio) : "") + + `` + ); +} + +function buildEmisorXml(doc: DteDocument): string { + const emisor = doc.emisor; + if (doc.tipoDte === "39") { + // Boletas use different element names than facturas (RznSocEmisor / + // GiroEmisor) and carry no Acteco. + return ( + `` + + `${escapeXml(formatRut(emisor.rut))}` + + `${escapeXml(emisor.razonSocial)}` + + `${escapeXml(emisor.giro)}` + + `${escapeXml(emisor.direccion)}` + + `${escapeXml(emisor.comuna)}` + + tagIf("CiudadOrigen", emisor.ciudad) + + `` + ); + } + return ( + `` + + `${escapeXml(formatRut(emisor.rut))}` + + `${escapeXml(emisor.razonSocial)}` + + `${escapeXml(emisor.giro)}` + + `${emisor.actividadEconomica}` + + `${escapeXml(emisor.direccion)}` + + `${escapeXml(emisor.comuna)}` + + tagIf("CiudadOrigen", emisor.ciudad) + + `` + ); +} + +function buildReceptorXml(doc: DteDocument): string { + const receptor = doc.receptor; + if (doc.tipoDte === "33") { + // Facturas require full receiver identification; missing fields make the + // SII reject the document, so fail fast with a descriptive error. + const missing: string[] = []; + if (!receptor.giro) missing.push("giro"); + if (!receptor.direccion) missing.push("direccion"); + if (!receptor.comuna) missing.push("comuna"); + if (missing.length > 0) { + throw new Error( + `DTE type 33 requires receptor.${missing.join(", receptor.")} (mandatory for facturas)` + ); + } + return ( + `` + + `${escapeXml(formatRut(receptor.rut))}` + + `${escapeXml(receptor.razonSocial)}` + + `${escapeXml(receptor.giro as string)}` + + `${escapeXml(receptor.direccion as string)}` + + `${escapeXml(receptor.comuna as string)}` + + `` + ); + } + // Boleta: only RUT and razon social are mandatory. + return ( + `` + + `${escapeXml(formatRut(receptor.rut))}` + + `${escapeXml(receptor.razonSocial)}` + + tagIf("DirRecep", receptor.direccion) + + tagIf("CmnaRecep", receptor.comuna) + + `` + ); +} + +function buildTotalesXml(totals: ResolvedTotals): string { + return ( + `` + + `${fmtInt(totals.montoNeto)}` + + (totals.montoExento > 0 ? `${fmtInt(totals.montoExento)}` : "") + + `${IVA_RATE}` + + `${fmtInt(totals.iva)}` + + `${fmtInt(totals.montoTotal)}` + + `` + ); +} + +function buildDetalleXml(doc: DteDocument, lines: ResolvedLine[]): string { + return doc.items + .map((item, i) => { + const line = lines[i]; + return ( + `` + + `${i + 1}` + + (item.exento ? `1` : "") + + `${escapeXml(item.nombre)}` + + `${item.cantidad}` + + `${fmtInt(line.prcItem)}` + + `${fmtInt(line.montoItem)}` + + `` + ); + }) + .join(""); +} + +function buildReferenciasXml(doc: DteDocument): string { + if (!doc.referencias || doc.referencias.length === 0) return ""; + return doc.referencias + .map( + (ref, i) => + `` + + `${i + 1}` + + `${ref.tipoDteRef}` + + `${ref.folioRef}` + + `${escapeXml(ref.fechaRef)}` + + tagIf("CodRef", ref.codigoRef) + + tagIf("RazonRef", ref.razonRef) + + `` + ) + .join(""); +} /** * Builds the DTE XML document from structured data. - * Includes the EnvioDTE envelope, SetDTE, and individual DTE XML. + * + * Produces a single `` element (namespace http://www.sii.cl/SiiDte) with + * its ``. The EnvioDTE/SetDTE envelope is a + * shipping concern and is NOT built here (see uploadDte). + * + * Supported types: 33 (Factura Electronica) and 39 (Boleta Electronica). + * + * Conventions (see resolveLineAmounts / resolveTotals for details): + * - `items[].montoItem` is always interpreted as a GROSS (IVA-included) + * amount for both types. For type 33 lines are converted to net so the + * detail matches the header MntNeto exactly. + * - When `montoNeto`/`iva` are omitted they are derived from `montoTotal`. + * - Emitted with an UTF-8 prolog so the declared encoding matches the bytes + * actually transmitted (accented characters survive signature validation). + * + * Expected pipeline: buildDteXml → applyTimbre → signDte. */ -export async function buildDteXml(_document: DteDocument): Promise { - // TODO: Build XML using fast-xml-parser builder - // Structure: EnvioDTE > SetDTE > DTE > Documento > Encabezado + Detalle - throw new Error("Not implemented"); +export async function buildDteXml(document: DteDocument): Promise { + const doc = DteDocumentSchema.parse(document); + + if (!SUPPORTED_DTE_TYPES.includes(doc.tipoDte)) { + throw new Error( + `buildDteXml currently supports DTE types ${SUPPORTED_DTE_TYPES.join(", ")}; got ${doc.tipoDte}` + ); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(doc.fechaEmision)) { + throw new Error(`fechaEmision must be YYYY-MM-DD, got "${doc.fechaEmision}"`); + } + + const totals = resolveTotals(doc); + const lines = resolveLineAmounts(doc, totals); + const documentId = `F${doc.folio}T${doc.tipoDte}`; + + return ( + `` + + `` + + `` + + `` + + buildIdDocXml(doc) + + buildEmisorXml(doc) + + buildReceptorXml(doc) + + buildTotalesXml(totals) + + `` + + buildDetalleXml(doc, lines) + + buildReferenciasXml(doc) + + `` + + `` + ); } /** - * Signs a DTE XML document with the digital certificate. - * Applies XMLDSig to the document. + * Signs a DTE XML document with the company's digital certificate (XMLDSig). + * + * Unlike the auth seed signature (URI="" + enveloped transform), the SII + * expects the DTE signature to reference the `` element by ID + * (`Reference URI="#F{folio}T{tipoDte}"`) with a C14N transform, and the + * `` element is inserted as a SIBLING of ``, inside + * ``. + * + * MUST be called AFTER applyTimbre: the Reference digest covers the whole + * `` content, TED included. Signing first and stamping later would + * invalidate the signature. Expected pipeline: buildDteXml → applyTimbre → signDte. + * + * Canonicalization note: the XML is generated deterministically with no + * whitespace variation, so C14N reduces to rendering in-scope namespace + * declarations. The digest/signature are computed over the canonical form + * (`` / ``) while the + * serialized output keeps the namespaces declared on the ancestors, exactly + * as C14N-aware validators reconstruct them. */ export async function signDte( - _xml: string, - _certPath: string, - _certPassword: string + xml: string, + certPath: string, + certPassword: string ): Promise { - // TODO: Sign DTE XML with certificate private key (XMLDSig) - throw new Error("Not implemented"); + const match = xml.match(/[\s\S]*?<\/Documento>/); + if (!match) { + throw new Error("signDte: no element found in XML"); + } + const documentoBlock = match[0]; + const documentId = match[1]; + + const certData = loadCertFromFile(certPath, certPassword); + + // 1. Digest of the canonicalized (inherits the SiiDte namespace + // from , which C14N renders on the element itself). + const canonicalDocumento = documentoBlock.replace( + ``, + `` + ); + const digestValue = sha1Digest(canonicalDocumento); + + // 2. SignedInfo referencing the Documento by ID. + const signedInfo = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `${digestValue}` + + `` + + ``; + + // 3. Sign the canonical SignedInfo (inherits the dsig namespace from + // ). + const canonicalSignedInfo = signedInfo.replace( + "", + `` + ); + const signatureValue = rsaSha1Sign(canonicalSignedInfo, certData.privateKey); + + // 4. KeyInfo: RSAKeyValue + X509Data, same format as the auth signature. + const keyInfo = + `` + + `` + + `` + + `${certData.modulusB64}` + + `${certData.exponentB64}` + + `` + + `` + + `` + + `${certData.certDerB64}` + + `` + + ``; + + const signature = + `` + + signedInfo + + `${signatureValue}` + + keyInfo + + ``; + + // 5. Insert the Signature as sibling of Documento, before . + const closeTag = ""; + const closeIndex = xml.lastIndexOf(closeTag); + if (closeIndex === -1) { + throw new Error("signDte: no closing tag found in XML"); + } + return xml.slice(0, closeIndex) + signature + xml.slice(closeIndex); } /** @@ -37,15 +493,116 @@ export async function uploadDte( throw new Error("Not implemented"); } +/** Truncates a value extracted from XML to `max` chars, entity-safely. */ +function truncateXmlValue(value: string, max: number): string { + return escapeXml(unescapeXml(value).slice(0, max)); +} + /** - * Stamps a DTE with the CAF timbre (folio authorization). + * Stamps a DTE with the CAF timbre (TED — Timbre Electronico DTE). + * + * The TED is NOT an XMLDSig signature: it is the SII's own format, signed + * RSA-SHA1 with the CAF's private key (not the company certificate) over the + * compact-serialized `
` block. It is inserted inside `` + * (together with ``) BEFORE the document is signed: + * buildDteXml → applyTimbre → signDte. + * + * DESIGN DECISION — the signature changed from `(xml, cafPrivateKey: string)` + * to `(xml, caf: FolioRange)`: a valid TED must embed the ORIGINAL `` + * block exactly as issued by the SII (its inner `` is the SII's own + * signature over the authorization data and cannot be reconstructed from + * parsed fields). The bare private key was not enough to build a real TED, so + * `FolioRangeSchema` gained an optional `cafXml` field carrying the raw CAF + * XML, and applyTimbre now takes the whole FolioRange (privateKey + cafXml + + * range metadata, which also lets it validate the folio against the + * authorized range). */ -export async function applyTimbre( - _xml: string, - _cafPrivateKey: string -): Promise { - // TODO: Generate TED (Timbre Electronico DTE) and insert into XML - throw new Error("Not implemented"); +export async function applyTimbre(xml: string, caf: FolioRange): Promise { + if (xml.includes(" block, including SII's signature, must be embedded verbatim in the TED" + ); + } + + const cafBlockMatch = caf.cafXml.match(/]*>[\s\S]*?<\/CAF>/); + if (!cafBlockMatch) { + throw new Error("applyTimbre: no block found in caf.cafXml"); + } + const cafBlock = cafBlockMatch[0]; + + // Pull the TED fields out of the already-built document. + const tipoDte = extractTag(xml, "TipoDTE"); + const folio = extractTag(xml, "Folio"); + const fchEmis = extractTag(xml, "FchEmis"); + const rutEmisor = extractTag(xml, "RUTEmisor"); + const rutRecep = extractTag(xml, "RUTRecep"); + const rznSocRecep = extractTag(xml, "RznSocRecep"); + const mntTotal = extractTag(xml, "MntTotal"); + const firstItem = extractTag(xml, "NmbItem"); + if (!tipoDte || !folio || !fchEmis || !rutEmisor || !rutRecep || !rznSocRecep || !mntTotal || !firstItem) { + throw new Error( + "applyTimbre: XML is missing required fields (TipoDTE, Folio, FchEmis, RUTEmisor, " + + "RUTRecep, RznSocRecep, MntTotal, NmbItem) — was it built with buildDteXml?" + ); + } + + // Validate the folio against the CAF authorization. + if (caf.tipoDte !== tipoDte) { + throw new Error( + `applyTimbre: CAF authorizes DTE type ${caf.tipoDte} but document is type ${tipoDte}` + ); + } + const folioNum = Number(folio); + if (folioNum < caf.rangoDesde || folioNum > caf.rangoHasta) { + throw new Error( + `applyTimbre: folio ${folioNum} is outside the CAF range ${caf.rangoDesde}-${caf.rangoHasta}` + ); + } + + // DD block, compact-serialized: FRMT signs this exact string, so no + // whitespace or line breaks between tags. + const dd = + `
` + + `${escapeXml(formatRut(unescapeXml(rutEmisor)))}` + + `${tipoDte}` + + `${folio}` + + `${fchEmis}` + + `${rutRecep}` + + `${truncateXmlValue(rznSocRecep, 40)}` + + `${fmtInt(Number(mntTotal))}` + + `${truncateXmlValue(firstItem, 40)}` + + cafBlock + + `${chileTimestamp()}` + + `
`; + + const cafPrivateKey = forge.pki.privateKeyFromPem(caf.privateKey) as forge.pki.rsa.PrivateKey; + const frmt = rsaSha1Sign(dd, cafPrivateKey); + + const ted = + `` + + dd + + `${frmt}` + + ``; + + // Insert TED + TmstFirma at the end of , before it is signed. + const closeTag = ""; + const closeIndex = xml.indexOf(closeTag); + if (closeIndex === -1) { + throw new Error("applyTimbre: no closing
tag found in XML"); + } + return ( + xml.slice(0, closeIndex) + + ted + + `${chileTimestamp()}` + + xml.slice(closeIndex) + ); } /** diff --git a/packages/engine/src/types/index.ts b/packages/engine/src/types/index.ts index d23da36..51b1151 100644 --- a/packages/engine/src/types/index.ts +++ b/packages/engine/src/types/index.ts @@ -71,6 +71,12 @@ export const DteDocumentSchema = z.object({ tipoDte: DteTypeSchema, folio: z.number().positive(), fechaEmision: z.string().describe("ISO date string YYYY-MM-DD"), + indServicio: z + .union([z.literal(1), z.literal(2), z.literal(3)]) + .optional() + .describe( + "Boletas only: service indicator (1=periodic, 2=periodic domiciliary, 3=other services). Omitted from the XML unless explicitly set." + ), emisor: EmisorSchema, receptor: ReceptorSchema, items: z.array(DteItemSchema).min(1), @@ -101,6 +107,14 @@ export const FolioRangeSchema = z.object({ fechaAutorizacion: z.string(), privateKey: z.string().optional().describe("RSA private key from CAF XML"), publicKey: z.string().optional().describe("RSA public key from CAF XML"), + cafXml: z + .string() + .optional() + .describe( + "Raw CAF XML exactly as issued by SII. Required to build the TED: the block " + + "(including SII's own signature over the authorization data) must be re-inserted " + + "verbatim into every stamped DTE and cannot be reconstructed from the parsed fields." + ), }); export type FolioRange = z.infer; diff --git a/packages/engine/tests/dte.test.ts b/packages/engine/tests/dte.test.ts index accd4d4..a9ce159 100644 --- a/packages/engine/tests/dte.test.ts +++ b/packages/engine/tests/dte.test.ts @@ -1,38 +1,375 @@ -import { describe, it, expect } from "vitest"; -import { buildDteXml } from "../src/dte"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as forge from "node-forge"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { XMLParser } from "fast-xml-parser"; +import { buildDteXml, applyTimbre, signDte } from "../src/dte"; +import { loadCertFromBase64, sha1Digest } from "../src/cert"; import type { DteDocument } from "../src/types"; +import { generateTestP12 } from "./helpers/cert"; +import { generateTestCaf } from "./helpers/caf"; -const sampleDte: DteDocument = { +const SII_DTE_NS = "http://www.sii.cl/SiiDte"; +const DSIG_NS = "http://www.w3.org/2000/09/xmldsig#"; + +const emisor = { + rut: "76123456-7", + razonSocial: "Empresa Test SpA", + giro: "Desarrollo de Software", + actividadEconomica: 620200, + direccion: "Av. Providencia 1234", + comuna: "Providencia", +}; + +/** + * Factura 33 with GROSS line amounts (the SDK convention): two lines of + * $999 gross each. Hand-verified values: + * montoTotal = 1998 + * MntNeto = round(1998 / 1.19) = round(1678.99) = 1679 + * IVA = 1998 - 1679 = 319 + * net lines = round(999 / 1.19) = round(839.49) = 839 each → sum 1678 + * → last line absorbs the 1-peso rounding gap: [839, 840], sum 1679 = MntNeto + */ +const factura33: DteDocument = { tipoDte: "33", - folio: 1, + folio: 42, fechaEmision: "2026-03-11", - emisor: { - rut: "76123456-7", - razonSocial: "Empresa Test SpA", - giro: "Desarrollo de Software", - actividadEconomica: 620200, - direccion: "Av. Providencia 1234", - comuna: "Providencia", - }, + emisor, receptor: { rut: "12345678-9", - razonSocial: "Cliente Test", + razonSocial: "Cliente Test Ltda.", + giro: "Comercio", + direccion: "Calle Ejemplo 789", + comuna: "Santiago", }, items: [ - { - nombre: "Servicio de Consultoría", - cantidad: 1, - precioUnitario: 100000, - montoItem: 100000, - }, + { nombre: "Producto A", cantidad: 1, precioUnitario: 999, montoItem: 999 }, + { nombre: "Producto B", cantidad: 1, precioUnitario: 999, montoItem: 999 }, ], - montoNeto: 100000, - iva: 19000, - montoTotal: 119000, + montoTotal: 1998, }; -describe("dte", () => { - it("buildDteXml throws not implemented", async () => { - await expect(buildDteXml(sampleDte)).rejects.toThrow("Not implemented"); +/** + * Boleta 39 with gross lines. Hand-verified values: + * montoTotal = 5990 + 3990 = 9980 + * MntNeto = round(9980 / 1.19) = round(8386.55) = 8387 + * IVA = 9980 - 8387 = 1593 + */ +const boleta39: DteDocument = { + tipoDte: "39", + folio: 7, + fechaEmision: "2026-03-11", + emisor, + receptor: { + rut: "66666666-6", + razonSocial: "Consumidor Final", + }, + items: [ + { nombre: "Polera estampada", cantidad: 1, precioUnitario: 5990, montoItem: 5990 }, + { nombre: "Gorro lana", cantidad: 1, precioUnitario: 3990, montoItem: 3990 }, + ], + montoTotal: 9980, +}; + +const parser = new XMLParser({ ignoreAttributes: false }); +const toArray = (x: T | T[]): T[] => (Array.isArray(x) ? x : [x]); + +describe("buildDteXml", () => { + it("factura 33: net detail matches header MntNeto exactly (rounding absorbed in last line)", async () => { + const xml = await buildDteXml(factura33); + const parsed = parser.parse(xml); + const documento = parsed.DTE.Documento; + const totales = documento.Encabezado.Totales; + const detalle = toArray(documento.Detalle); + + expect(totales.MntNeto).toBe(1679); + expect(totales.TasaIVA).toBe(19); + expect(totales.IVA).toBe(319); + expect(totales.MntTotal).toBe(1998); + + const montos = detalle.map((d: any) => d.MontoItem); + expect(montos).toEqual([839, 840]); + // The invariant the SII actually validates: + expect(montos.reduce((a: number, b: number) => a + b, 0)).toBe(totales.MntNeto); + expect(totales.MntNeto + totales.IVA).toBe(totales.MntTotal); + }); + + it("factura 33: uses factura element names and Documento ID", async () => { + const xml = await buildDteXml(factura33); + expect(xml).toContain(``); + expect(xml).toContain(``); + expect(xml).toContain(``); + expect(xml).toContain("Empresa Test SpA"); + expect(xml).toContain("Desarrollo de Software"); + expect(xml).toContain("620200"); + expect(xml).toContain("Comercio"); + expect(xml).not.toContain(""); + }); + + it("factura 33: exempt lines are not net-converted and MntExe is emitted", async () => { + const doc: DteDocument = { + ...factura33, + items: [ + { nombre: "Afecto", cantidad: 1, precioUnitario: 1190, montoItem: 1190 }, + { nombre: "Exento", cantidad: 1, precioUnitario: 500, montoItem: 500, exento: true }, + ], + montoExento: 500, + montoTotal: 1690, + }; + const xml = await buildDteXml(doc); + const parsed = parser.parse(xml); + const totales = parsed.DTE.Documento.Encabezado.Totales; + const detalle = toArray(parsed.DTE.Documento.Detalle); + + // round((1690 - 500) / 1.19) = 1000 + expect(totales.MntNeto).toBe(1000); + expect(totales.MntExe).toBe(500); + expect(totales.IVA).toBe(190); + expect(detalle[0].MontoItem).toBe(1000); + expect(detalle[0].IndExe).toBeUndefined(); + expect(detalle[1].MontoItem).toBe(500); + expect(detalle[1].IndExe).toBe(1); + }); + + it("factura 33: rejects when receptor giro/direccion/comuna are missing", async () => { + const doc: DteDocument = { + ...factura33, + receptor: { rut: "12345678-9", razonSocial: "Cliente Test" }, + }; + await expect(buildDteXml(doc)).rejects.toThrow(/receptor\.giro.*direccion.*comuna/); + }); + + it("factura 33: rejects inconsistent provided totals", async () => { + const doc: DteDocument = { + ...factura33, + montoNeto: 1679, + iva: 319, + montoTotal: 5000, + }; + await expect(buildDteXml(doc)).rejects.toThrow(/do not add up/); + }); + + it("factura 33: rejects lines that are not consistent with gross convention", async () => { + // montoNeto === montoItem means the caller passed NET lines: the net line + // sum would be off by far more than rounding allows. + const doc: DteDocument = { + ...factura33, + items: [{ nombre: "Servicio", cantidad: 1, precioUnitario: 500000, montoItem: 500000 }], + montoNeto: 500000, + iva: 95000, + montoTotal: 595000, + }; + await expect(buildDteXml(doc)).rejects.toThrow(/GROSS/); + }); + + it("boleta 39: detail stays gross and matches MntTotal", async () => { + const xml = await buildDteXml(boleta39); + const parsed = parser.parse(xml); + const documento = parsed.DTE.Documento; + const totales = documento.Encabezado.Totales; + const detalle = toArray(documento.Detalle); + + expect(detalle.map((d: any) => d.MontoItem)).toEqual([5990, 3990]); + expect(totales.MntTotal).toBe(9980); + // Derived so MntNeto + IVA === MntTotal exactly. + expect(totales.MntNeto).toBe(8387); + expect(totales.IVA).toBe(1593); + expect(totales.MntNeto + totales.IVA).toBe(totales.MntTotal); + }); + + it("boleta 39: uses boleta element names, no Acteco, no IndServicio by default", async () => { + const xml = await buildDteXml(boleta39); + expect(xml).toContain(``); + expect(xml).toContain("Empresa Test SpA"); + expect(xml).toContain("Desarrollo de Software"); + expect(xml).not.toContain(""); + expect(xml).not.toContain(""); + expect(xml).not.toContain(""); + expect(xml).not.toContain(""); + }); + + it("boleta 39: emits IndServicio only when explicitly provided", async () => { + const xml = await buildDteXml({ ...boleta39, indServicio: 3 }); + expect(xml).toContain("3"); + }); + + it("escapes XML special characters in text fields", async () => { + const xml = await buildDteXml({ + ...boleta39, + items: [ + { nombre: "Café & ", cantidad: 1, precioUnitario: 9980, montoItem: 9980 }, + ], + }); + expect(xml).toContain("Café & <té>"); + }); + + it("serializes referencias when provided", async () => { + const xml = await buildDteXml({ + ...factura33, + referencias: [ + { + tipoDteRef: "33", + folioRef: 10, + fechaRef: "2026-02-01", + codigoRef: "2", + razonRef: "Corrige glosa", + }, + ], + }); + const parsed = parser.parse(xml); + const ref = parsed.DTE.Documento.Referencia; + expect(ref.NroLinRef).toBe(1); + expect(ref.TpoDocRef).toBe(33); + expect(ref.FolioRef).toBe(10); + expect(ref.CodRef).toBe(2); + expect(ref.RazonRef).toBe("Corrige glosa"); + }); + + it("rejects unsupported DTE types", async () => { + await expect(buildDteXml({ ...factura33, tipoDte: "61" })).rejects.toThrow(/33, 39/); + }); +}); + +describe("applyTimbre", () => { + const caf = generateTestCaf({ tipoDte: "39", rangoDesde: 1, rangoHasta: 100 }); + + it("produces a TED whose FRMT verifies against the CAF public key", async () => { + const xml = await buildDteXml(boleta39); + const stamped = await applyTimbre(xml, caf.folioRange); + + const dd = stamped.match(/
[\s\S]*?<\/DD>/)?.[0]; + const frmt = stamped.match(/([^<]+)<\/FRMT>/)?.[1]; + expect(dd).toBeTruthy(); + expect(frmt).toBeTruthy(); + + // Cryptographic round-trip: the FRMT signature over the compact DD block + // must verify with the CAF's RSA public key. + const md = forge.md.sha1.create(); + md.update(dd as string, "utf8"); + const verified = caf.publicKey.verify(md.digest().bytes(), forge.util.decode64(frmt as string)); + expect(verified).toBe(true); + }); + + it("embeds the original CAF block (with SII's FRMA) verbatim and fills DD fields", async () => { + const xml = await buildDteXml(boleta39); + const stamped = await applyTimbre(xml, caf.folioRange); + + const originalCafBlock = caf.cafXml.match(/]*>[\s\S]*?<\/CAF>/)?.[0]; + expect(stamped).toContain(originalCafBlock as string); + + const dd = stamped.match(/
[\s\S]*?<\/DD>/)?.[0] as string; + expect(dd).toContain("76123456-7"); + expect(dd).toContain("39"); + expect(dd).toContain("7"); + expect(dd).toContain("2026-03-11"); + expect(dd).toContain("66666666-6"); + expect(dd).toContain("Consumidor Final"); + expect(dd).toContain("9980"); + expect(dd).toContain("Polera estampada"); + expect(dd).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}<\/TSTED>/); + // TED goes inside Documento, followed by TmstFirma. + expect(stamped).toMatch(/<\/TED>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}<\/TmstFirma><\/Documento>/); + }); + + it("rejects a folio outside the CAF range", async () => { + const xml = await buildDteXml({ ...boleta39, folio: 500 }); + await expect(applyTimbre(xml, caf.folioRange)).rejects.toThrow(/outside the CAF range/); + }); + + it("rejects a CAF for a different DTE type", async () => { + const xml = await buildDteXml(boleta39); + const caf33 = generateTestCaf({ tipoDte: "33" }); + await expect(applyTimbre(xml, caf33.folioRange)).rejects.toThrow(/type 33/); + }); + + it("rejects a FolioRange without cafXml (raw CAF is required for the TED)", async () => { + const xml = await buildDteXml(boleta39); + await expect( + applyTimbre(xml, { ...caf.folioRange, cafXml: undefined }) + ).rejects.toThrow(/cafXml/); + }); +}); + +describe("signDte", () => { + const password = "testpassword"; + const p12Base64 = generateTestP12(password); + const testsDir = path.dirname(fileURLToPath(import.meta.url)); + let tmpDir: string; + let certPath: string; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(testsDir, "tmp-cert-")); + certPath = path.join(tmpDir, "test.p12"); + fs.writeFileSync(certPath, Buffer.from(p12Base64, "base64")); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("produces an XMLDSig signature verifiable with the certificate public key", async () => { + const xml = await buildDteXml(factura33); + const signed = await signDte(xml, certPath, password); + + const signedInfo = signed.match(/[\s\S]*?<\/SignedInfo>/)?.[0] as string; + const signatureValue = signed.match(/([^<]+)<\/SignatureValue>/)?.[1] as string; + expect(signedInfo).toBeTruthy(); + expect(signatureValue).toBeTruthy(); + + // Verify against the canonical SignedInfo (dsig namespace rendered). + const canonicalSignedInfo = signedInfo.replace( + "", + `` + ); + const md = forge.md.sha1.create(); + md.update(canonicalSignedInfo, "utf8"); + const certData = loadCertFromBase64(p12Base64, password); + const verified = (certData.certificate.publicKey as forge.pki.rsa.PublicKey).verify( + md.digest().bytes(), + forge.util.decode64(signatureValue) + ); + expect(verified).toBe(true); + }); + + it("references the Documento by ID and digests its canonical form", async () => { + const xml = await buildDteXml(factura33); + const signed = await signDte(xml, certPath, password); + + expect(signed).toContain(``); + const digestValue = signed.match(/([^<]+)<\/DigestValue>/)?.[1]; + const documento = signed.match(/[\s\S]*?<\/Documento>/)?.[0] as string; + const canonicalDocumento = documento.replace( + ``, + `` + ); + expect(digestValue).toBe(sha1Digest(canonicalDocumento)); + }); + + it("inserts the Signature as sibling of Documento inside DTE", async () => { + const xml = await buildDteXml(factura33); + const signed = await signDte(xml, certPath, password); + expect(signed).toMatch(/<\/Documento>[\s\S]*<\/Signature><\/DTE>$/); + expect(signed).toContain(""); + expect(signed).toContain(""); + }); + + it("full pipeline buildDteXml → applyTimbre → signDte yields well-formed XML", async () => { + const caf33 = generateTestCaf({ tipoDte: "33", rangoDesde: 1, rangoHasta: 100 }); + const xml = await buildDteXml(factura33); + const stamped = await applyTimbre(xml, caf33.folioRange); + const signed = await signDte(stamped, certPath, password); + + const parsed = parser.parse(signed); + expect(parsed.DTE.Documento.TED).toBeDefined(); + expect(parsed.DTE.Documento.TmstFirma).toBeDefined(); + expect(parsed.DTE.Signature).toBeDefined(); + // The signature digest must cover the TED (sign AFTER stamping). + const documento = signed.match(/[\s\S]*?<\/Documento>/)?.[0] as string; + expect(documento).toContain(""); + }); + + it("rejects XML without a Documento ID", async () => { + await expect(signDte("", certPath, password)).rejects.toThrow(/Documento/); }); }); diff --git a/packages/engine/tests/helpers/caf.ts b/packages/engine/tests/helpers/caf.ts new file mode 100644 index 0000000..2c5ef6f --- /dev/null +++ b/packages/engine/tests/helpers/caf.ts @@ -0,0 +1,89 @@ +import * as forge from "node-forge"; +import type { DteType, FolioRange } from "../../src/types"; + +export interface TestCaf { + /** Full CAF file XML (AUTORIZACION > CAF + RSASK + RSAPUBK). */ + cafXml: string; + privateKeyPem: string; + publicKeyPem: string; + publicKey: forge.pki.rsa.PublicKey; + /** FolioRange ready to pass to applyTimbre. */ + folioRange: FolioRange; +} + +/** + * Generates a synthetic CAF (folio authorization) for testing. + * + * Mirrors the structure the SII emits: AUTORIZACION > CAF (DA + FRMA) + + * RSASK/RSAPUBK. The inner FRMA is normally the SII's signature over DA; + * here it is self-signed with the same test key — applyTimbre must embed it + * verbatim either way, which is what tests assert. + */ +export function generateTestCaf( + options: { + tipoDte?: DteType; + rangoDesde?: number; + rangoHasta?: number; + rutEmisor?: string; + } = {} +): TestCaf { + const { + tipoDte = "39", + rangoDesde = 1, + rangoHasta = 100, + rutEmisor = "76123456-7", + } = options; + + // Real CAF keys are 1024-bit RSA. + const keys = forge.pki.rsa.generateKeyPair(1024); + const privateKeyPem = forge.pki.privateKeyToPem(keys.privateKey); + const publicKeyPem = forge.pki.publicKeyToPem(keys.publicKey); + + const modulusB64 = bigIntToBase64(keys.publicKey.n as forge.jsbn.BigInteger); + const exponentB64 = bigIntToBase64(keys.publicKey.e as forge.jsbn.BigInteger); + + const da = + `` + + `${rutEmisor}` + + `EMPRESA TEST SPA` + + `${tipoDte}` + + `${rangoDesde}${rangoHasta}` + + `2026-01-01` + + `${modulusB64}${exponentB64}` + + `100` + + ``; + + const md = forge.md.sha1.create(); + md.update(da, "utf8"); + const frma = forge.util.encode64(keys.privateKey.sign(md)); + + const cafXml = + `` + + `${da}${frma}` + + `${privateKeyPem}` + + `${publicKeyPem}` + + ``; + + return { + cafXml, + privateKeyPem, + publicKeyPem, + publicKey: keys.publicKey, + folioRange: { + tipoDte, + rangoDesde, + rangoHasta, + fechaAutorizacion: "2026-01-01", + privateKey: privateKeyPem, + publicKey: publicKeyPem, + cafXml, + }, + }; +} + +function bigIntToBase64(n: forge.jsbn.BigInteger): string { + const hex = n.toString(16); + let bytes = forge.util.hexToBytes(hex.length % 2 ? "0" + hex : hex); + if (bytes.charCodeAt(0) >= 0x80) bytes = "\x00" + bytes; + return forge.util.encode64(bytes); +}