Skip to content

feat(dte): implement buildDteXml, applyTimbre and signDte for types 33 and 39 - #35

Open
nbisquertt wants to merge 1 commit into
emisso-ai:mainfrom
nbisquertt:feat/dte-builder-33-39
Open

feat(dte): implement buildDteXml, applyTimbre and signDte for types 33 and 39#35
nbisquertt wants to merge 1 commit into
emisso-ai:mainfrom
nbisquertt:feat/dte-builder-33-39

Conversation

@nbisquertt

Copy link
Copy Markdown

Summary

packages/engine/src/dte/index.ts had buildDteXml, signDte, and applyTimbre fully stubbed (throw new Error("Not implemented")) — running the README's own Quick Start example would throw. This PR implements the full pipeline for Factura Electrónica (33) and Boleta Electrónica (39):

  • buildDteXml — produces the real <DTE>/<Documento> XML per type, with correct per-type header field names (RznSoc/GiroEmis/Acteco for 33 vs RznSocEmisor/GiroEmisor for 39), receptor validation for facturas (giro/dirección/comuna required), and derived totals (neto/iva computed from montoTotal when omitted, with iva as the exact remainder to avoid double-rounding).
  • Neto/bruto line-amount handling (the subtlest and most SII-rejection-prone part): items[].montoItem is treated as gross for both types; for type 33 lines are converted to net with per-line rounding drift absorbed into the last affected line so the detail sums to the header MntNeto exactly — this is the invariant the SII validates and rejects with an otherwise opaque "amounts don't match" error. Documented as an explicit design decision in resolveLineAmounts since DteItemSchema has no gross/net flag.
  • applyTimbre — builds a real TED (compact <DD> block, RSA-SHA1-signed with the CAF private key, embedding the original <CAF> block verbatim).
  • signDte — XMLDSig over <Documento> (Reference URI="#F{folio}T{tipo}" + C14N, distinct from the auth seed's enveloped pattern), following the repo's existing manual string-building style in auth/xml-dsig.ts (no new XML-DSIG dependency).
  • Timestamps use America/Santiago wall time, not UTC (a document emitted at night would otherwise be dated "tomorrow").

Design decision worth flagging for review

The old applyTimbre(xml, cafPrivateKey: string) signature could never produce a valid TED: the SII's own <FRMA> signature inside the CAF authorization can't be reconstructed from parsed fields — it has to be re-embedded verbatim from the original CAF XML. I added an optional cafXml field to FolioRangeSchema (backward-compatible) and changed applyTimbre to take (xml, caf: FolioRange) instead, so it also validates the folio/type against the authorization range. Happy to adjust this if you'd prefer a different shape (e.g. a separate cafXml param instead of extending FolioRange).

Out of scope

uploadDte, generateDtePdf, parseCaf/loadCafFromFile, and the remaining DTE types (34/41/43/46/52/56/61/110/112) are still unimplemented — this PR focuses on the two most common types to get the core pipeline working end-to-end first.

Test plan

  • pnpm build — clean
  • pnpm lint (tsc --noEmit) — clean
  • pnpm test:run — 96 passed / 3 skipped (pre-existing skips), all green
  • New tests use hand-verified values (e.g. two $999 gross items → MntNeto 1679 with line split [839, 840] proving the last-line rounding adjustment) and real cryptographic round-trips: TED FRMT verified against the CAF public key, XMLDSig SignatureValue verified against the P12 certificate's public key
  • Changeset added (.changeset/tidy-dtes-sign.md, minor bump) per CONTRIBUTING.md

…3 and 39

buildDteXml now produces the real <DTE>/<Documento ID="F{folio}T{tipo}">
XML for Factura Electronica (33) and Boleta Electronica (39):

- Type-specific header element names: RznSoc/GiroEmis/Acteco for 33,
  RznSocEmisor/GiroEmisor (no Acteco) for 39. Facturas fail fast when
  receptor giro/direccion/comuna are missing (the SII rejects them).
- Totals: when montoNeto/iva are omitted they are derived from montoTotal
  with IVA computed as the exact remainder (never a second rounding), so
  MntNeto + IVA + MntExe === MntTotal always holds. Caller-provided totals
  are validated within ±1.
- Line amounts convention (design decision, see resolveLineAmounts):
  items[].montoItem is always GROSS (IVA-included) for both types, since
  DteItemSchema has no gross/net flag and boletas require gross lines.
  For type 33 lines are converted to net (round(monto/1.19)) and the
  per-line rounding drift is absorbed into the last affected line so the
  detail sums to the header MntNeto EXACTLY — the invariant the SII
  validates with an otherwise opaque "amounts don't match" rejection.
- New optional DteDocumentSchema.indServicio (1|2|3), emitted only for
  boletas and only when explicitly provided.
- UTF-8 prolog so declared encoding matches transmitted bytes.

applyTimbre builds a real TED: compact <DD> block signed RSA-SHA1 with
the CAF private key, embedding the ORIGINAL <CAF> block verbatim.
BREAKING-ish design decision: the previous (xml, cafPrivateKey: string)
signature could never produce a valid TED, because the SII's <FRMA>
signature inside the CAF cannot be reconstructed from parsed fields. The
function now takes (xml, caf: FolioRange) and FolioRangeSchema gained an
optional cafXml field carrying the raw CAF XML; the range metadata also
lets applyTimbre validate the folio/type against the authorization.
TSTED/TmstFirma use America/Santiago wall time (UTC would date nighttime
documents on the wrong day and desynchronize the tax period).

signDte applies XMLDSig over <Documento> (Reference URI="#F{folio}T{tipo}"
with C14N transform — unlike the auth seed's URI="" + enveloped pattern),
inserting <Signature> as sibling of <Documento> inside <DTE>. It must run
AFTER applyTimbre so the digest covers the TED. Digest/signature are
computed over the canonical form (in-scope namespaces rendered), following
the same manual string-building style as auth/xml-dsig.ts.

Tests replace the old "throws Not implemented" stub with hand-verified
values, a real in-memory P12 (generateTestP12) and a new synthetic CAF
helper; TED and XMLDSig signatures are verified cryptographically
(round-trip with the corresponding public keys).

Out of scope: uploadDte, generateDtePdf, folios parsing (parseCaf), and
the remaining DTE types (34/41/52/56/61/...) stay unimplemented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nbisquertt
nbisquertt requested a review from cdotte as a code owner July 23, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant