@devlas/dte-sii 2.19.1 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CafSolicitor.js CHANGED
@@ -241,6 +241,28 @@ class CafSolicitor {
241
241
  return /NO\s+(SE\s+)?AUTORIZA\s+TIMBRAJE/i.test(CafSolicitor.textoVisible(html, 4000));
242
242
  }
243
243
 
244
+ /**
245
+ * Aísla el motivo real que el SII escribe en la página de bloqueo de timbraje, en vez
246
+ * de descartarlo — hasta ahora `solicitar()` detectaba el bloqueo con `esBloqueoTimbraje`
247
+ * y siempre devolvía el mismo mensaje genérico, tirando a la basura el texto real de la
248
+ * página (que sí queda en el HTML, ya se guardaba en debug).
249
+ *
250
+ * Caso real (verificado 2026-09-03, RUT anonimizado en los tests): el SII no dice un
251
+ * error genérico, dice algo concreto y accionable — "de acuerdo a nuestros registros,
252
+ * usted tiene disponible una cantidad de folios suficiente para emitir documentos
253
+ * electronicos... debe emitir y enviar documentos electronicos al SII o anular folios".
254
+ * Ese texto es justo lo que el consumidor necesita para armar un aviso útil, en vez de
255
+ * mandar a alguien a "revisa el portal" sin decirle qué va a encontrar ahí.
256
+ *
257
+ * @returns {string|null} el motivo aislado, o null si no se pudo — el llamador cae al
258
+ * mensaje genérico en ese caso, nunca se vuelve fatal por esto.
259
+ */
260
+ static extraerMotivoBloqueoTimbraje(html) {
261
+ const t = CafSolicitor.textoVisible(html, 4000);
262
+ const m = t.match(/(NO\s+(?:SE\s+)?AUTORIZA\s+TIMBRAJE[\s\S]{0,600}?)(?:\s+Si necesita mas informacion|$)/i);
263
+ return m ? m[1].trim() : null;
264
+ }
265
+
244
266
  /**
245
267
  * Detecta el rechazo genérico "no está autorizado para ingresar a esta opción" en
246
268
  * palena (producción). A diferencia de maullin/certificación, donde el bloqueo por
@@ -596,7 +618,14 @@ class CafSolicitor {
596
618
  }
597
619
 
598
620
  if (response.body && CafSolicitor.esBloqueoTimbraje(response.body)) {
599
- return { success: false, errorCode: 'TIMBRAJE_BLOQUEADO', error: 'SII: No se autoriza timbraje. Folios acumulados excesivos o situaciones tributarias pendientes. Revisa el portal SII → Factura Electrónica → Solicitud de Timbraje.' };
621
+ const motivoSii = CafSolicitor.extraerMotivoBloqueoTimbraje(response.body);
622
+ return {
623
+ success: false,
624
+ errorCode: 'TIMBRAJE_BLOQUEADO',
625
+ error: motivoSii
626
+ ? `SII: ${motivoSii}`
627
+ : 'SII: No se autoriza timbraje. Folios acumulados excesivos o situaciones tributarias pendientes. Revisa el portal SII → Factura Electrónica → Solicitud de Timbraje.',
628
+ };
600
629
  }
601
630
 
602
631
  if (response.body && CafSolicitor.esRequiereTramitePresencial(response.body)) {
package/DTE.js CHANGED
@@ -12,6 +12,7 @@ const { DOMParser } = require('@xmldom/xmldom');
12
12
  const {
13
13
  sanitizeSiiText,
14
14
  sanitizeTedText,
15
+ assertLargoMaximo,
15
16
  formatBase64InXml,
16
17
  normalizeEmisor,
17
18
  normalizeReceptor,
@@ -22,6 +23,21 @@ const {
22
23
  } = require('./utils');
23
24
  const { serializeNode, escapeAttr, escapeText, buildSignedInfo, buildSignature } = require('./utils/c14n');
24
25
 
26
+ /**
27
+ * Sanitiza y valida el nombre de un ítem antes de que entre al XML.
28
+ *
29
+ * Lanza si supera los 80 caracteres que admite `NmbItem` en el XSD del SII — el SII
30
+ * rechaza el SOBRE COMPLETO cuando uno solo de sus documentos falla el schema (medido el
31
+ * 2026-09-01: 3 boletas cayeron juntas por un ítem de 83 caracteres, 2 de ellas con datos
32
+ * válidos). Mejor lanzar acá, con el campo y el valor, que dejar que el SII lo rechace
33
+ * tres pasos después con "Error en Schema" sin decir cuál documento ni por qué.
34
+ */
35
+ function sanitizeNmbItem(nombre) {
36
+ const valor = sanitizeSiiText(nombre);
37
+ assertLargoMaximo(valor, 80, 'NmbItem');
38
+ return valor;
39
+ }
40
+
25
41
  // ============================================
26
42
  // CONSTANTES
27
43
  // ============================================
@@ -140,7 +156,7 @@ class DTE {
140
156
  const det = {
141
157
  NroLinDet: idx + 1,
142
158
  ...(esItemExento ? { IndExe: 1 } : {}),
143
- NmbItem: sanitizeSiiText(item.NmbItem),
159
+ NmbItem: sanitizeNmbItem(item.NmbItem),
144
160
  QtyItem: qty,
145
161
  ...(item.UnmdItem ? { UnmdItem: item.UnmdItem } : {}),
146
162
  PrcItem: prc,
@@ -247,7 +263,7 @@ class DTE {
247
263
  _buildDetalle(det) {
248
264
  return (Array.isArray(det) ? det : [det]).map(item => ({
249
265
  ...item,
250
- NmbItem: sanitizeSiiText(item.NmbItem),
266
+ NmbItem: sanitizeNmbItem(item.NmbItem),
251
267
  ...(item.DscItem ? { DscItem: sanitizeSiiText(item.DscItem) } : {}),
252
268
  }));
253
269
  }
package/dte-sii.d.ts CHANGED
@@ -888,6 +888,11 @@ export function sanitizeSiiText(text: string): string;
888
888
  */
889
889
  export function sanitizeTedText(text: string): string;
890
890
  export function truncateText(text: string, maxLen: number, preserveWords?: boolean): string;
891
+ /**
892
+ * Lanza si `text` supera `maxLength` caracteres — para campos con `xs:maxLength` fijo en
893
+ * el XSD (ej. NmbItem: 80). Se valida el texto YA sanitizado, el que efectivamente va al XML.
894
+ */
895
+ export function assertLargoMaximo(text: string, maxLength: number, campo: string): void;
891
896
  export function sanitizeGiroRecep(giro: string): string;
892
897
  export function sanitizeRazonSocial(razonSocial: string): string;
893
898
  export function sanitizeNombreItem(nombre: string): string;
@@ -1032,6 +1037,7 @@ export const utils: {
1032
1037
  sanitizeSiiText: typeof sanitizeSiiText;
1033
1038
  sanitizeTedText: typeof sanitizeTedText;
1034
1039
  truncateText: typeof truncateText;
1040
+ assertLargoMaximo: typeof assertLargoMaximo;
1035
1041
  sanitizeGiroRecep: typeof sanitizeGiroRecep;
1036
1042
  sanitizeRazonSocial: typeof sanitizeRazonSocial;
1037
1043
  sanitizeNombreItem: typeof sanitizeNombreItem;
package/index.js CHANGED
@@ -46,6 +46,7 @@ const {
46
46
  sanitizeSiiText,
47
47
  sanitizeTedText,
48
48
  truncateText,
49
+ assertLargoMaximo,
49
50
  sanitizeGiroRecep,
50
51
  sanitizeRazonSocial,
51
52
  sanitizeNombreItem,
@@ -260,6 +261,7 @@ module.exports = {
260
261
  sanitizeSiiText,
261
262
  sanitizeTedText,
262
263
  truncateText,
264
+ assertLargoMaximo,
263
265
  sanitizeGiroRecep,
264
266
  sanitizeRazonSocial,
265
267
  sanitizeNombreItem,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.19.1",
3
+ "version": "2.21.0",
4
4
  "description": "Facturación y boletas electrónicas para el SII de Chile. Genera, timbra, firma y envía DTEs, libros electrónicos y automatiza la certificación.",
5
5
  "main": "index.js",
6
6
  "types": "dte-sii.d.ts",
package/utils/index.js CHANGED
@@ -54,6 +54,7 @@ const {
54
54
  sanitizeSiiText,
55
55
  sanitizeTedText,
56
56
  truncateText,
57
+ assertLargoMaximo,
57
58
  sanitizeGiroRecep,
58
59
  sanitizeRazonSocial,
59
60
  sanitizeNombreItem,
@@ -179,6 +180,7 @@ module.exports = {
179
180
  sanitizeSiiText,
180
181
  sanitizeTedText,
181
182
  truncateText,
183
+ assertLargoMaximo,
182
184
  sanitizeGiroRecep,
183
185
  sanitizeRazonSocial,
184
186
  sanitizeNombreItem,
package/utils/sanitize.js CHANGED
@@ -118,6 +118,41 @@ function truncateText(text, maxLen, preserveWords = false) {
118
118
  return sanitized.substring(0, maxLen);
119
119
  }
120
120
 
121
+ /**
122
+ * Lanza si `text` supera `maxLength` caracteres — para campos con `xs:maxLength` fijo en
123
+ * el XSD del SII (ej. NmbItem: 80).
124
+ *
125
+ * ── Por qué lanza y no trunca ─────────────────────────────────────────────────
126
+ * Esta librería ya tenía `sanitizeNombreItem`/`sanitizeRazonSocial`/etc. (más abajo),
127
+ * que truncan en silencio — pero nunca se conectaron a los campos que serializa `DTE.js`
128
+ * (`sanitizeSiiText` corre sola ahí, sin ningún control de largo). El resultado real:
129
+ * un `NmbItem` de 83 caracteres pasaba intacto y el SII rechazaba recién al validar el
130
+ * XML — y **rechaza el sobre completo**, no solo ese documento (medido el 2026-09-01,
131
+ * 3 boletas de un mismo envío cayeron juntas, 2 de ellas con datos válidos).
132
+ *
133
+ * Truncar en una librería de serialización es una decisión de negocio disfrazada de
134
+ * detalle técnico: recorta lo que el cliente ve impreso en su documento tributario, y
135
+ * hacerlo en silencio esconde el dato malo en vez de exponerlo. Esta librería conoce el
136
+ * contrato del XSD — le corresponde avisar temprano, con un mensaje que diga campo,
137
+ * límite y valor, para que el consumidor decida qué hacer (corregir el dato, truncar con
138
+ * su propia política, etc.) en vez de que el SII lo rechace tres pasos después con un
139
+ * error genérico de schema.
140
+ *
141
+ * @param {string} text - Texto YA sanitizado (post `sanitizeSiiText`) — se valida el que
142
+ * efectivamente va al XML, no el original.
143
+ * @param {number} maxLength
144
+ * @param {string} campo - Nombre del elemento XSD, para el mensaje de error.
145
+ * @throws {Error} si `text.length > maxLength`
146
+ */
147
+ function assertLargoMaximo(text, maxLength, campo) {
148
+ const valor = String(text ?? '');
149
+ if (valor.length > maxLength) {
150
+ throw new Error(
151
+ `${campo} excede el largo maximo del XSD (${maxLength} caracteres, tiene ${valor.length}): "${valor}"`
152
+ );
153
+ }
154
+ }
155
+
121
156
  /**
122
157
  * Sanitizar giro para receptor (máximo 40 caracteres)
123
158
  *
@@ -184,6 +219,7 @@ module.exports = {
184
219
  sanitizeSiiText,
185
220
  sanitizeTedText,
186
221
  truncateText,
222
+ assertLargoMaximo,
187
223
  sanitizeGiroRecep,
188
224
  sanitizeRazonSocial,
189
225
  sanitizeNombreItem,