@albertomarturelo/sii-core 0.7.0 → 0.8.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/CHANGELOG.md +72 -0
- package/dist/config/config.d.ts +10 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +10 -0
- package/dist/config/config.js.map +1 -1
- package/dist/errors/errors.d.ts +5 -1
- package/dist/errors/errors.d.ts.map +1 -1
- package/dist/errors/errors.js +5 -1
- package/dist/errors/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/portal/dte-mipyme.d.ts +228 -0
- package/dist/portal/dte-mipyme.d.ts.map +1 -0
- package/dist/portal/dte-mipyme.js +890 -0
- package/dist/portal/dte-mipyme.js.map +1 -0
- package/dist/tasks/dte.d.ts +124 -0
- package/dist/tasks/dte.d.ts.map +1 -1
- package/dist/tasks/dte.js +471 -5
- package/dist/tasks/dte.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
// MIPYME FACTURA (borradores) — the SII's FREE facturación electrónica portal, `Portal001`
|
|
2
|
+
// CGIs on www1.sii.cl. Wire contract captured live 2026-09-08 (own session, a real borrador
|
|
3
|
+
// created/listed/deleted end-to-end); see docs/sii-contract/dte-mipyme.md. NOT a third-party
|
|
4
|
+
// library (ADR-004): every field, endpoint and selector below is first-hand-observed.
|
|
5
|
+
//
|
|
6
|
+
// BORRADORES ONLY (ADR-023). The portal's emisión path is `mipeDisplayPreView.cgi` →
|
|
7
|
+
// `mipeGenXMLFirma.cgi` (SII signs SERVER-SIDE — no certificado digital is involved, so the
|
|
8
|
+
// Clave alone would be enough to issue a legally-binding factura). This module deliberately
|
|
9
|
+
// stops at the borrador + preview: `mipeGenXMLFirma.cgi` is NEVER called from here.
|
|
10
|
+
//
|
|
11
|
+
// TWO WIRE SHAPES, like the BTE facade:
|
|
12
|
+
// * the FORM (`mipeGenFacEx.cgi`) is an HTML skeleton whose emisor context (sucursal, giro,
|
|
13
|
+
// domicilio, acteco) and detail grid are built CLIENT-SIDE by JS — the static HTML carries
|
|
14
|
+
// EMPTY values. So it is driven through `PortalSession.goto` + `evaluate` (never a cold
|
|
15
|
+
// `requestForm` GET, which would read blanks), exactly as the convention requires.
|
|
16
|
+
// * the CGIs that consume the filled form (`mipeGrabaBorrador` / `mipeEliminaBorrador` /
|
|
17
|
+
// `mipeDisplayPreView`) take `x-www-form-urlencoded` and return HTML ⇒ `requestForm`.
|
|
18
|
+
// * the borrador LIST is a plain SPA JSON GET on www4 ⇒ `requestJson`.
|
|
19
|
+
//
|
|
20
|
+
// EMPRESA-KEYED (ADR-023): the working empresa is whichever RUT was last POSTed to
|
|
21
|
+
// `mipeSelEmpresa.cgi` — the MIPYME "usuario autorizado" list, which is its OWN value domain,
|
|
22
|
+
// distinct from the operate pointer's operable set. Every operation selects it first — except
|
|
23
|
+
// on an account authorized for a SINGLE empresa, where SII skips the chooser (a JS launcher
|
|
24
|
+
// instead of the select) and the session is already scoped; then the empresa is read off the
|
|
25
|
+
// factura form's DTE header box and nothing is POSTed (observed 2026-09-09, #95).
|
|
26
|
+
import { HOSTS } from '../config/index.js';
|
|
27
|
+
import { DteError } from '../errors/index.js';
|
|
28
|
+
import { Rut } from '../rut/index.js';
|
|
29
|
+
const CGI = HOSTS.mipeCgi;
|
|
30
|
+
const SEL_EMPRESA_URL = `${CGI}/mipeSelEmpresa.cgi`;
|
|
31
|
+
const FORM_URL = `${CGI}/mipeGenFacEx.cgi`;
|
|
32
|
+
const GRABA_URL = `${CGI}/mipeGrabaBorrador.cgi`;
|
|
33
|
+
const ELIMINA_URL = `${CGI}/mipeEliminaBorrador.cgi`;
|
|
34
|
+
const PREVIEW_URL = `${CGI}/mipeDisplayPreView.cgi`;
|
|
35
|
+
/** "Ver documentos emitidos" — the listing of DTEs already ISSUED by the selected empresa,
|
|
36
|
+
* reached from `mipeLaunchPage.cgi?OPCION=2&TIPO=4` (observed 2026-09-08). Read-only. */
|
|
37
|
+
const EMITIDOS_URL = `${CGI}/mipeAdminDocsEmi.cgi`;
|
|
38
|
+
/** The emitted document's PDF, keyed by the listing row's `CODIGO`. A plain authenticated GET
|
|
39
|
+
* answering `application/pdf` — no review/iframe dance, unlike the borrador preview
|
|
40
|
+
* (observed 2026-09-08). */
|
|
41
|
+
const EMITIDO_PDF_URL = `${CGI}/mipeDisplayPDF.cgi`;
|
|
42
|
+
/** Renders the (unsigned) document as a real `application/pdf` — the preview the portal embeds
|
|
43
|
+
* in its `framePdf` iframe. Takes the review page's `PreViewDTE` body (observed 2026-09-08). */
|
|
44
|
+
const PDF_URL = `${CGI}/mipePreView.cgi`;
|
|
45
|
+
/** The iframe that issues the PDF POST. SII checks the Referer: a POST without it comes back
|
|
46
|
+
* as HTML, not `application/pdf` (observed 2026-09-08 — the browser sends
|
|
47
|
+
* `referer: .../PreViewFrame.html`, `origin: https://www1.sii.cl`, `sec-fetch-dest: iframe`). */
|
|
48
|
+
const PREVIEW_FRAME_URL = `${HOSTS.mipeStatic}/PreViewFrame.html`;
|
|
49
|
+
/** Same-origin `Origin` header the iframe sends — derived from the configured host, never
|
|
50
|
+
* hard-coded (ADR-004: hostnames live only in the config module). */
|
|
51
|
+
const MIPE_ORIGIN = new URL(HOSTS.mipeCgi).origin;
|
|
52
|
+
/** The borradores list is served by the MIPYME SPA, not the CGIs (observed 2026-09-08). */
|
|
53
|
+
const LISTA_BORRADOR_URL = `${HOSTS.portalApi}/mipymeinternetui/services/data/borradorService/listaBorrador`;
|
|
54
|
+
/** `DESDE_DONDE_URL` — the post-selection destination `mipeSelEmpresa.cgi` forwards to. It is
|
|
55
|
+
* an unkeyed `OPCION=<tipo>&TIPO=4` pair (observed); `OPCION` is the DTE code. */
|
|
56
|
+
const desdeDonde = (tipoDte) => `OPCION=${tipoDte}&TIPO=4`;
|
|
57
|
+
/** The DTE types the MIPYME portal exposes as their own `mipeLaunchPage` OPCION (observed
|
|
58
|
+
* 2026-09-08 on sii.cl/servicios_online/1039-1183.html). Only 33/34 are wired here — the
|
|
59
|
+
* others need their own live capture before their extra fields can be trusted. */
|
|
60
|
+
export const TIPOS_DTE = {
|
|
61
|
+
33: 'Factura electrónica',
|
|
62
|
+
34: 'Factura no afecta o exenta electrónica',
|
|
63
|
+
};
|
|
64
|
+
export const isTipoDte = (n) => n === 33 || n === 34;
|
|
65
|
+
/** SII's `EFXP_FMA_PAGO` codes (observed in the form's `<select>`). */
|
|
66
|
+
export const FORMA_PAGO = { contado: '1', credito: '2', sin_costo: '3' };
|
|
67
|
+
/** The grid's hard ceiling — the form's own `cantTotCol` (observed). */
|
|
68
|
+
export const MAX_ITEMS = 10;
|
|
69
|
+
/** SII's `ESTADO` filter values on the emitted listing (observed in the form's <select>). */
|
|
70
|
+
export const ESTADO_EMITIDO = { emitido: 'EMI', preview: 'PRV' };
|
|
71
|
+
/** Classify the chooser response. Read the `<option value="RUT">NOMBRE RUT` rows when there is
|
|
72
|
+
* a select: SII closes neither the `<option>` nor quotes the label, so match up to the next
|
|
73
|
+
* `<` or newline (observed). A page that is none of the three shapes is "scraper roto". */
|
|
74
|
+
export function parseChooser(html) {
|
|
75
|
+
if (/Facturacion Electronica - Launcher/i.test(html) && /FacturaOpenEnlace\(/.test(html)) {
|
|
76
|
+
return { kind: 'launcher' };
|
|
77
|
+
}
|
|
78
|
+
const select = /<select[^>]*name="RUT_EMP"[\s\S]*?<\/select>/i.exec(html)?.[0];
|
|
79
|
+
if (!select) {
|
|
80
|
+
throw new DteError('La página de selección de empresa del Portal MIPYME (mipeSelEmpresa.cgi) no tiene una ' +
|
|
81
|
+
'forma conocida: ni selector RUT_EMP ni launcher. El portal puede haber cambiado.');
|
|
82
|
+
}
|
|
83
|
+
const empresas = [];
|
|
84
|
+
const re = /<option\s+value="([^"]+)"\s*>([^<\n]*)/gi;
|
|
85
|
+
for (let m = re.exec(select); m; m = re.exec(select)) {
|
|
86
|
+
const rut = (m[1] ?? '').trim();
|
|
87
|
+
// The label repeats the RUT ("RAZON SOCIAL 76192083-9") — strip it for a clean name.
|
|
88
|
+
const nombre = (m[2] ?? '')
|
|
89
|
+
.trim()
|
|
90
|
+
.replace(new RegExp(`\\s*${rut.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '')
|
|
91
|
+
.trim();
|
|
92
|
+
if (rut)
|
|
93
|
+
empresas.push({ rut, nombre });
|
|
94
|
+
}
|
|
95
|
+
return empresas.length === 0 ? { kind: 'sinAutorizacion' } : { kind: 'chooser', empresas };
|
|
96
|
+
}
|
|
97
|
+
/** Read the message out of SII's server-side rejection page: a 200 "Redireccionando" document
|
|
98
|
+
* whose script is `alert('…')` followed by `window.history.go(-1)` (observed 2026-09-08).
|
|
99
|
+
* Returns the alert text with its escaped newlines turned into separators, or null. */
|
|
100
|
+
export function serverAlert(html) {
|
|
101
|
+
if (!/Redireccionando|history\.go\(-1\)/i.test(html))
|
|
102
|
+
return null;
|
|
103
|
+
const raw = /alert\('([\s\S]*?)'\)/.exec(html)?.[1];
|
|
104
|
+
if (!raw)
|
|
105
|
+
return null;
|
|
106
|
+
return raw
|
|
107
|
+
.replace(/\\n/g, ' | ')
|
|
108
|
+
.replace(/\s*\|\s*$/, '')
|
|
109
|
+
.replace(/\s+/g, ' ')
|
|
110
|
+
.trim();
|
|
111
|
+
}
|
|
112
|
+
/** SII answers 200 with a human message on both success and refusal, so decide on the text.
|
|
113
|
+
* The success wording is observed 2026-09-08; anything else is surfaced VERBATIM (ADR-004). */
|
|
114
|
+
const CGI_OK = {
|
|
115
|
+
// Keyed per step: a delete that answered with a "grabado" page must NOT read as deleted.
|
|
116
|
+
grabaBorrador: /ha sido grabado|ha sido actualizado|grabado\/actualizado/i,
|
|
117
|
+
eliminaBorrador: /ha sido eliminad[oa]/i,
|
|
118
|
+
};
|
|
119
|
+
function assertCgiOk(html, step) {
|
|
120
|
+
const expected = CGI_OK[step];
|
|
121
|
+
if (expected && expected.test(html))
|
|
122
|
+
return;
|
|
123
|
+
const rejection = serverAlert(html);
|
|
124
|
+
if (rejection)
|
|
125
|
+
throw new DteError(`El SII rechazó el documento: ${rejection}`);
|
|
126
|
+
const msg = /<(?:p|div|td|span|h\d)[^>]*>\s*([^<]{15,300}?)\s*<\//i.exec(html.replace(/<script[\s\S]*?<\/script>/gi, ''))?.[1];
|
|
127
|
+
throw new DteError(`El SII no confirmó la operación de borrador (paso: ${step}).` +
|
|
128
|
+
(msg ? ` Respuesta: ${msg.replace(/\s+/g, ' ').trim()}` : ''));
|
|
129
|
+
}
|
|
130
|
+
/** Percent-encode a form body as ISO-8859-1, the charset every `Portal001` page declares.
|
|
131
|
+
* `URLSearchParams` encodes UTF-8, so "Diseño" went out as `Dise%C3%B1o` and SII stored — and
|
|
132
|
+
* printed — "Diseño" (observed 2026-09-08, both in the saved borrador and in the preview PDF).
|
|
133
|
+
* The browser encodes per the PAGE charset, so we must too. Characters outside Latin-1 are sent
|
|
134
|
+
* as an HTML numeric reference, exactly as a browser does for an unrepresentable character.
|
|
135
|
+
*
|
|
136
|
+
* The label says ISO-8859-1 but the encoding is WINDOWS-1252: the HTML spec requires browsers to
|
|
137
|
+
* treat a declared ISO-8859-1 document as windows-1252, and SII's own <select> options come back
|
|
138
|
+
* holding characters from its 0x80–0x9F block (e.g. U+2018 in "ENSE\u00c3\u2018ANZA"). Encoding
|
|
139
|
+
* those as Latin-1 turned them into `‘` in the rendered document, so the 1252 block is
|
|
140
|
+
* mapped back explicitly. */
|
|
141
|
+
const CP1252_HIGH = {
|
|
142
|
+
0x20ac: 0x80,
|
|
143
|
+
0x201a: 0x82,
|
|
144
|
+
0x0192: 0x83,
|
|
145
|
+
0x201e: 0x84,
|
|
146
|
+
0x2026: 0x85,
|
|
147
|
+
0x2020: 0x86,
|
|
148
|
+
0x2021: 0x87,
|
|
149
|
+
0x02c6: 0x88,
|
|
150
|
+
0x2030: 0x89,
|
|
151
|
+
0x0160: 0x8a,
|
|
152
|
+
0x2039: 0x8b,
|
|
153
|
+
0x0152: 0x8c,
|
|
154
|
+
0x017d: 0x8e,
|
|
155
|
+
0x2018: 0x91,
|
|
156
|
+
0x2019: 0x92,
|
|
157
|
+
0x201c: 0x93,
|
|
158
|
+
0x201d: 0x94,
|
|
159
|
+
0x2022: 0x95,
|
|
160
|
+
0x2013: 0x96,
|
|
161
|
+
0x2014: 0x97,
|
|
162
|
+
0x02dc: 0x98,
|
|
163
|
+
0x2122: 0x99,
|
|
164
|
+
0x0161: 0x9a,
|
|
165
|
+
0x203a: 0x9b,
|
|
166
|
+
0x0153: 0x9c,
|
|
167
|
+
0x017e: 0x9e,
|
|
168
|
+
0x0178: 0x9f,
|
|
169
|
+
};
|
|
170
|
+
export function latin1FormBody(fields) {
|
|
171
|
+
const enc = (raw) => {
|
|
172
|
+
let out = '';
|
|
173
|
+
for (const ch of raw) {
|
|
174
|
+
const c = ch.codePointAt(0) ?? 0;
|
|
175
|
+
if (/[A-Za-z0-9*\-._]/.test(ch))
|
|
176
|
+
out += ch;
|
|
177
|
+
else if (ch === ' ')
|
|
178
|
+
out += '+';
|
|
179
|
+
else {
|
|
180
|
+
const byte = c <= 0xff ? c : CP1252_HIGH[c];
|
|
181
|
+
out +=
|
|
182
|
+
byte === undefined
|
|
183
|
+
? encodeURIComponent(`&#${c};`) // truly unrepresentable — browser behaviour
|
|
184
|
+
: `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
};
|
|
189
|
+
const parts = [];
|
|
190
|
+
for (const [k, v] of fields)
|
|
191
|
+
parts.push(`${enc(k)}=${enc(v)}`);
|
|
192
|
+
return parts.join('&');
|
|
193
|
+
}
|
|
194
|
+
/** The `name`s of the `VIEW` form inside `PreViewFrame.html` — the EXACT field set the iframe
|
|
195
|
+
* posts to the PDF CGI, in order (observed 2026-09-08: 239 inputs, matching the browser's own
|
|
196
|
+
* request byte-for-byte in count). Read from SII's frame at runtime so an upstream edit is
|
|
197
|
+
* followed rather than drifting against a hardcoded list. */
|
|
198
|
+
export function frameFields(html) {
|
|
199
|
+
const form = /<form[^>]*name="VIEW"[\s\S]*?<\/form>/i.exec(html)?.[0];
|
|
200
|
+
if (!form)
|
|
201
|
+
return [];
|
|
202
|
+
const out = [];
|
|
203
|
+
const re = /<input[^>]*>/gi;
|
|
204
|
+
for (let m = re.exec(form); m; m = re.exec(form)) {
|
|
205
|
+
const name = /\bname="([^"]+)"/i.exec(m[0])?.[1];
|
|
206
|
+
if (!name || out.some((f) => f.name === name))
|
|
207
|
+
continue;
|
|
208
|
+
out.push({ name, value: unescapeHtml(/\bvalue="([^"]*)"/i.exec(m[0])?.[1] ?? '') });
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
/** `content-type` says PDF AND the body starts with the `%PDF` magic. Status is never a
|
|
213
|
+
* success signal here — SII answers 200 for its own error page (ADR-022). */
|
|
214
|
+
export function isPdfBytes(bytes, contentType) {
|
|
215
|
+
return ((contentType ?? '').toLowerCase().includes('application/pdf') &&
|
|
216
|
+
bytes.length > 4 &&
|
|
217
|
+
bytes[0] === 0x25 &&
|
|
218
|
+
bytes[1] === 0x50 &&
|
|
219
|
+
bytes[2] === 0x44 &&
|
|
220
|
+
bytes[3] === 0x46);
|
|
221
|
+
}
|
|
222
|
+
/** Also relay SII's generic failure page ("Error al contribuyente"), which carries a support
|
|
223
|
+
* code rather than a validation message — it is a SII-side refusal, not our bad input. */
|
|
224
|
+
export function contribuyenteError(bytes, contentType) {
|
|
225
|
+
if (!(contentType ?? '').toLowerCase().includes('html'))
|
|
226
|
+
return null;
|
|
227
|
+
const html = new TextDecoder('iso-8859-1').decode(bytes);
|
|
228
|
+
if (!/Error al contribuyente/i.test(html))
|
|
229
|
+
return null;
|
|
230
|
+
const msg = /alert\('([\s\S]*?)'\)/.exec(html)?.[1];
|
|
231
|
+
return (msg ?? 'Error al contribuyente').replace(/\\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
232
|
+
}
|
|
233
|
+
/** Read a form's `<input type="hidden" name="X" value="Y">` pairs. SII's preview page carries
|
|
234
|
+
* the whole document that way (uniform markup, double-quoted, observed 2026-09-08). */
|
|
235
|
+
function parseHiddenInputs(html, formName) {
|
|
236
|
+
const form = new RegExp(`<form[^>]*name="${formName}"[\\s\\S]*?</form>`, 'i').exec(html)?.[0];
|
|
237
|
+
if (!form)
|
|
238
|
+
return {};
|
|
239
|
+
const out = {};
|
|
240
|
+
const re = /<input[^>]*type="hidden"[^>]*>/gi;
|
|
241
|
+
for (let m = re.exec(form); m; m = re.exec(form)) {
|
|
242
|
+
const tag = m[0];
|
|
243
|
+
const name = /name="([^"]*)"/i.exec(tag)?.[1];
|
|
244
|
+
if (!name)
|
|
245
|
+
continue;
|
|
246
|
+
out[name] = unescapeHtml(/value="([^"]*)"/i.exec(tag)?.[1] ?? '');
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
/** The five predefined XML entities — enough for SII's form values (no numeric refs observed). */
|
|
251
|
+
const unescapeHtml = (v) => v
|
|
252
|
+
.replace(/</g, '<')
|
|
253
|
+
.replace(/>/g, '>')
|
|
254
|
+
.replace(/"/g, '"')
|
|
255
|
+
.replace(/'/g, "'")
|
|
256
|
+
.replace(/&/g, '&');
|
|
257
|
+
// --- The in-page fill script -------------------------------------------------------
|
|
258
|
+
/** Build the expression evaluated INSIDE the loaded form page. It grows the detail grid, sets
|
|
259
|
+
* the fields, runs SII's OWN validator (`validaFacEx`) so the user sees SII's message
|
|
260
|
+
* verbatim, and returns the serialized form exactly as a browser would submit it.
|
|
261
|
+
*
|
|
262
|
+
* Two observed hazards it works around:
|
|
263
|
+
* * firing `change` on `EFXP_RUT_RECEP` triggers the portal's receptor-autofill round trip,
|
|
264
|
+
* which RELOADS the page and wipes the detail grid — so the receptor fields are assigned
|
|
265
|
+
* WITHOUT events; only the numeric item fields need `change` to drive the total recompute.
|
|
266
|
+
* * `DESCRIP_nn` is a checkbox whose `onclick` DRAWS the `EFXP_DSC_ITEM_nn` textarea; the
|
|
267
|
+
* textarea does not exist until it is clicked. */
|
|
268
|
+
function fillScript(payload) {
|
|
269
|
+
return `(async () => {
|
|
270
|
+
const P = ${JSON.stringify(payload)};
|
|
271
|
+
const f = document.forms['VIEW_EFXP'];
|
|
272
|
+
if (!f) return { scraper: 'no se encontró el formulario VIEW_EFXP' };
|
|
273
|
+
|
|
274
|
+
// BUG-2 (live): the detail grid is drawn ASYNCHRONOUSLY by the page's own JS, so
|
|
275
|
+
// \`DESCRIP_01\` / \`EFXP_NMB_01\` may not exist yet the instant domcontentloaded fires.
|
|
276
|
+
// Wait for the REAL elements (never a blind sleep) before touching anything.
|
|
277
|
+
const waitFor = async (names, ms) => {
|
|
278
|
+
const deadline = Date.now() + ms;
|
|
279
|
+
for (;;) {
|
|
280
|
+
if (names.every((n) => f.elements[n])) return true;
|
|
281
|
+
if (Date.now() > deadline) return false;
|
|
282
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
if (!(await waitFor(['EFXP_NMB_01', 'DESCRIP_01', 'CANT_DET'], 15000))) {
|
|
286
|
+
return { scraper: 'la grilla de detalle no se dibujó (EFXP_NMB_01/DESCRIP_01 ausentes)' };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const missing = [];
|
|
290
|
+
const coerced = [];
|
|
291
|
+
// Every direct element read goes through el()/cantDet() so a renamed field becomes a clean
|
|
292
|
+
// "scraper roto" error instead of a raw TypeError escaping page.evaluate.
|
|
293
|
+
const el = (n) => f.elements[n] || null;
|
|
294
|
+
const cantDet = () => Number((el('CANT_DET') || {}).value || 1);
|
|
295
|
+
const numOf = (n) => {
|
|
296
|
+
const e = el(n);
|
|
297
|
+
if (!e) { missing.push(n); return 0; }
|
|
298
|
+
return Number(e.value || 0);
|
|
299
|
+
};
|
|
300
|
+
const norm = (v) =>
|
|
301
|
+
String(v ?? '')
|
|
302
|
+
.normalize('NFD')
|
|
303
|
+
.replace(/[\\u0300-\\u036f]/g, '')
|
|
304
|
+
.replace(/\\s+/g, ' ')
|
|
305
|
+
.trim()
|
|
306
|
+
.toUpperCase();
|
|
307
|
+
|
|
308
|
+
// BUG-3 (live): when a saved borrador is REOPENED, SII renders EFXP_DIR_RECEP and
|
|
309
|
+
// EFXP_GIRO_RECEP as <select> (the receptor's registered addresses / giros) instead of
|
|
310
|
+
// <input>. Assigning a value that is not exactly one of the options silently leaves the
|
|
311
|
+
// select EMPTY, which is how the receptor block came back blank. So: match an option by
|
|
312
|
+
// value, then by accent/case-insensitive text; if nothing matches, KEEP SII's canonical
|
|
313
|
+
// selection rather than blanking it, and report the coercion.
|
|
314
|
+
const put = (n, v, fire) => {
|
|
315
|
+
const e = f.elements[n];
|
|
316
|
+
if (!e) { missing.push(n); return; }
|
|
317
|
+
if (e.tagName === 'SELECT') {
|
|
318
|
+
const want = norm(v);
|
|
319
|
+
if (want === '') return; // nothing to set — keep SII's own option
|
|
320
|
+
const opts = Array.from(e.options);
|
|
321
|
+
const hit =
|
|
322
|
+
opts.find((o) => o.value === v) ??
|
|
323
|
+
opts.find((o) => norm(o.value) === want) ??
|
|
324
|
+
opts.find((o) => norm(o.text) === want) ??
|
|
325
|
+
opts.find((o) => norm(o.text).startsWith(want) || want.startsWith(norm(o.text)));
|
|
326
|
+
if (hit) { e.value = hit.value; return; }
|
|
327
|
+
// Nothing matched: keep SII's canonical option and report BOTH what was kept and the
|
|
328
|
+
// options it does offer, so the caller (or a model) can pick a real one next time.
|
|
329
|
+
coerced.push({
|
|
330
|
+
campo: n,
|
|
331
|
+
solicitado: String(v),
|
|
332
|
+
usado: e.options[e.selectedIndex] ? e.options[e.selectedIndex].text : '',
|
|
333
|
+
opciones: opts.map((o) => o.text).filter((t) => t !== ''),
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
e.value = v;
|
|
338
|
+
// Only the numeric item fields need an event (they drive the total recompute). NEVER fire
|
|
339
|
+
// on the receptor RUT: it triggers the portal's autofill round trip, which RELOADS the
|
|
340
|
+
// page and wipes the grid (observed).
|
|
341
|
+
if (fire) e.dispatchEvent(new Event('change', { bubbles: true }));
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// BOUNDED: if modCantLineaDet ever stops incrementing CANT_DET — SII caps the grid for this
|
|
345
|
+
// DTE type, renames the function, or AGREGA_DETALLE disappears — an unbounded loop would spin
|
|
346
|
+
// in the renderer forever (page.evaluate does not time out), hanging the CLI and the MCP
|
|
347
|
+
// server with no error. Fail LOUD through the scraper channel instead (ADR-004).
|
|
348
|
+
const addBtn = el('AGREGA_DETALLE');
|
|
349
|
+
if (!addBtn && P.items.length > 1) return { scraper: 'falta el botón AGREGA_DETALLE' };
|
|
350
|
+
for (let guard = 0; cantDet() < P.items.length; guard += 1) {
|
|
351
|
+
if (guard >= P.items.length) {
|
|
352
|
+
return {
|
|
353
|
+
scraper:
|
|
354
|
+
'el formulario no aceptó más líneas de detalle (CANT_DET quedó en ' + cantDet() + ')',
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const before = cantDet();
|
|
358
|
+
modCantLineaDet(addBtn);
|
|
359
|
+
const row = String(before + 1).padStart(2, '0');
|
|
360
|
+
// each click re-renders the grid — wait for the row it just added
|
|
361
|
+
if (!(await waitFor(['EFXP_NMB_' + row], 10000)) || cantDet() <= before) {
|
|
362
|
+
return { scraper: 'no se dibujó la línea de detalle ' + row };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
put('EFXP_CIUDAD_ORIGEN', P.ciudadEmisor);
|
|
367
|
+
put('EFXP_FCH_EMIS', P.fechaEmision);
|
|
368
|
+
put('EFXP_FMA_PAGO', P.formaPago);
|
|
369
|
+
put('EFXP_RUT_RECEP', P.receptor.rut);
|
|
370
|
+
put('EFXP_DV_RECEP', P.receptor.dv);
|
|
371
|
+
put('EFXP_RZN_SOC_RECEP', P.receptor.razonSocial);
|
|
372
|
+
put('EFXP_DIR_RECEP', P.receptor.direccion);
|
|
373
|
+
put('EFXP_CMNA_RECEP', P.receptor.comuna);
|
|
374
|
+
put('EFXP_CIUDAD_RECEP', P.receptor.ciudad);
|
|
375
|
+
put('EFXP_GIRO_RECEP', P.receptor.giro);
|
|
376
|
+
put('EFXP_CONTACTO', P.receptor.contacto);
|
|
377
|
+
if (P.borradorId) {
|
|
378
|
+
const h = el('EHDR_CODIGO');
|
|
379
|
+
if (!h) return { scraper: 'falta EHDR_CODIGO en el formulario' };
|
|
380
|
+
h.value = P.borradorId;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (let i = 0; i < P.items.length; i += 1) {
|
|
384
|
+
const it = P.items[i];
|
|
385
|
+
const s = String(i + 1).padStart(2, '0');
|
|
386
|
+
if (it.descripcion) {
|
|
387
|
+
const c = el('DESCRIP_' + s);
|
|
388
|
+
if (!c || typeof c.onclick !== 'function') {
|
|
389
|
+
return { scraper: 'falta la casilla de descripción DESCRIP_' + s };
|
|
390
|
+
}
|
|
391
|
+
if (!c.checked) { c.checked = true; c.onclick(); }
|
|
392
|
+
// the textarea is DRAWN by that onclick — wait for it, don't assume
|
|
393
|
+
if (!(await waitFor(['EFXP_DSC_ITEM_' + s], 10000))) {
|
|
394
|
+
return { scraper: 'no se dibujó la descripción EFXP_DSC_ITEM_' + s };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
put('EFXP_NMB_' + s, it.nombre);
|
|
398
|
+
put('EFXP_UNMD_' + s, it.unidad);
|
|
399
|
+
if (it.descripcion) put('EFXP_DSC_ITEM_' + s, it.descripcion);
|
|
400
|
+
if (it.descuentoPct) put('EFXP_PCTD_' + s, it.descuentoPct, true);
|
|
401
|
+
put('EFXP_QTY_' + s, it.cantidad, true);
|
|
402
|
+
put('EFXP_PRC_' + s, it.precioUnitario, true);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const msgs = [];
|
|
406
|
+
const prev = window.alert;
|
|
407
|
+
window.alert = (m) => msgs.push(String(m).trim());
|
|
408
|
+
let ok = false;
|
|
409
|
+
try {
|
|
410
|
+
const btn = el('Button_Update');
|
|
411
|
+
if (!btn) { window.alert = prev; return { scraper: 'falta el botón Button_Update' }; }
|
|
412
|
+
ok = !!validaFacEx(btn);
|
|
413
|
+
} catch (e) { window.alert = prev; return { scraper: String((e && e.message) || e) }; }
|
|
414
|
+
window.alert = prev;
|
|
415
|
+
|
|
416
|
+
const fields = {};
|
|
417
|
+
for (const e of f.elements) {
|
|
418
|
+
if (!e.name || e.type === 'button' || e.type === 'submit') continue;
|
|
419
|
+
if ((e.type === 'checkbox' || e.type === 'radio') && !e.checked) continue;
|
|
420
|
+
fields[e.name] = e.value;
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
ok, msgs, missing, coerced, fields,
|
|
424
|
+
totales: {
|
|
425
|
+
neto: numOf('EFXP_MNT_NETO'),
|
|
426
|
+
iva: numOf('EFXP_IVA'),
|
|
427
|
+
total: numOf('EFXP_MNT_TOTAL'),
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
})()`;
|
|
431
|
+
}
|
|
432
|
+
/** POST a form body encoded as ISO-8859-1. Uses `requestText` (raw authenticated body) rather
|
|
433
|
+
* than `requestForm`, whose `form` option is UTF-8-encoded by the driver — that encoding is what
|
|
434
|
+
* mangled every accented value the portal stored. */
|
|
435
|
+
async function postLatin1(session, url, fields) {
|
|
436
|
+
return session.requestText(url, {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=ISO-8859-1' },
|
|
439
|
+
body: latin1FormBody(Object.entries(fields)),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
// --- Operations ---------------------------------------------------------------------
|
|
443
|
+
/** In-page: read the empresa the portal ALREADY scoped this session to, off the factura form.
|
|
444
|
+
* Two sources, both observed 2026-09-09 on a single-empresa persona account:
|
|
445
|
+
* * the RUT is the DTE header box — `<div class="well well-sm"><strong>Rut 76192083-9</strong>
|
|
446
|
+
* FACTURA ELECTRÓNICA N° folio no asignado</div>` — the EMISOR by construction of the
|
|
447
|
+
* document. NOT the navbar's `Rut:` (`ul#conAutenticacion`), not `cook_rut`, not the
|
|
448
|
+
* `NETSCAPE_LIVEWIRE.rut` / `RUT_NS` cookies: those are the LOGGED-IN principal, which on a
|
|
449
|
+
* persona account is a different RUT from the empresa it invoices for.
|
|
450
|
+
* * the razón social is `EFXP_RZN_SOC`, JS-populated like the rest of the emisor block.
|
|
451
|
+
* Both are awaited with a deadline-bounded 50 ms poll, never a blind sleep (like fillScript). */
|
|
452
|
+
const SCOPED_EMPRESA_SCRIPT = `(async () => {
|
|
453
|
+
const RUT = /^\\s*Rut\\s+([\\d.]{7,10}-[\\dkK])\\s*$/i;
|
|
454
|
+
const header = () => {
|
|
455
|
+
for (const s of document.querySelectorAll('div.well strong')) {
|
|
456
|
+
const m = RUT.exec(s.textContent || '');
|
|
457
|
+
if (m) return m[1].replace(/\\./g, '');
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
};
|
|
461
|
+
const f = document.forms['VIEW_EFXP'];
|
|
462
|
+
if (!f) return { scraper: 'la página no trae el formulario VIEW_EFXP — puede que tu cuenta no esté habilitada para emitir facturas en el Portal MIPYME' };
|
|
463
|
+
const el = f.elements['EFXP_RZN_SOC'] || null;
|
|
464
|
+
if (!el) return { scraper: 'falta el campo EFXP_RZN_SOC (razón social del emisor)' };
|
|
465
|
+
const deadline = Date.now() + 10000;
|
|
466
|
+
let rut = header();
|
|
467
|
+
while ((!rut || !String(el.value || '').trim()) && Date.now() < deadline) {
|
|
468
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
469
|
+
rut = rut || header();
|
|
470
|
+
}
|
|
471
|
+
if (!rut) return { scraper: 'no se encontró el RUT del emisor en el cuadro del documento (div.well strong "Rut …")' };
|
|
472
|
+
const nombre = String(el.value || '').trim();
|
|
473
|
+
if (!nombre) return { scraper: 'EFXP_RZN_SOC quedó vacío' };
|
|
474
|
+
return { rut, nombre };
|
|
475
|
+
})()`;
|
|
476
|
+
/** The DTE type used to READ the emisor identity. The scoped empresa is the same whichever
|
|
477
|
+
* document you are about to write, but the per-type forms are NOT equally available: an
|
|
478
|
+
* account not authorized for factura exenta gets `PTDC_CODIGO=34` back as a page with NO
|
|
479
|
+
* forms at all (title "SISTEMA DE FACTURACIÓN SII", observed 2026-09-09). Pinning identity
|
|
480
|
+
* to 33 keeps "who am I operating as" independent of "may I emit this type", so `--tipo 34`
|
|
481
|
+
* fails where it should — on the document — not on resolving the empresa. */
|
|
482
|
+
const IDENTITY_TIPO_DTE = 33;
|
|
483
|
+
async function readScopedEmpresa(session) {
|
|
484
|
+
const landed = await session.goto(`${FORM_URL}?PTDC_CODIGO=${IDENTITY_TIPO_DTE}`);
|
|
485
|
+
if (!landed.includes('mipeGenFacEx.cgi')) {
|
|
486
|
+
throw new DteError(`El SII no entregó el formulario de factura (llegamos a ${landed}).`);
|
|
487
|
+
}
|
|
488
|
+
const r = await session.evaluate(SCOPED_EMPRESA_SCRIPT);
|
|
489
|
+
if (!r || r.scraper || !r.rut || !r.nombre) {
|
|
490
|
+
throw new DteError(`Formulario de factura del SII no reconocido (${r?.scraper ?? 'sin RUT/razón social del emisor'}).`);
|
|
491
|
+
}
|
|
492
|
+
// The scraped RUT is load-bearing: it becomes the value `--empresa` is checked against, so a
|
|
493
|
+
// garbled scrape would silently reject the user's legitimate empresa. Mod-11 it here and fail
|
|
494
|
+
// as "scraper roto" instead (ADR-004); `canonical` also normalises SII's dotted rendering.
|
|
495
|
+
let empresa;
|
|
496
|
+
try {
|
|
497
|
+
empresa = Rut.parse(r.rut);
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
throw new DteError(`El RUT del emisor leído del formulario no es válido ("${r.rut}") — el portal cambió de forma.`);
|
|
501
|
+
}
|
|
502
|
+
return { rut: empresa.canonical, nombre: r.nombre };
|
|
503
|
+
}
|
|
504
|
+
/** GET the chooser and resolve the authorized empresas for this session. `scoped` says the
|
|
505
|
+
* portal already pointed the session at the single empresa (launcher shape) — the caller must
|
|
506
|
+
* then NOT POST `mipeSelEmpresa.cgi`. `sinAutorizacion` is the only path to the "not
|
|
507
|
+
* authorized" message, so the wording can be actionable (#95). */
|
|
508
|
+
async function resolveChooser(session, tipoDte, sleep) {
|
|
509
|
+
const res = await session.requestForm(`${SEL_EMPRESA_URL}?DESDE_DONDE_URL=${encodeURIComponent(desdeDonde(tipoDte))}`, { method: 'GET' });
|
|
510
|
+
const shape = parseChooser(res.body);
|
|
511
|
+
if (shape.kind === 'chooser')
|
|
512
|
+
return { empresas: shape.empresas, scoped: false };
|
|
513
|
+
if (shape.kind === 'sinAutorizacion') {
|
|
514
|
+
throw new DteError('Tu cuenta no figura como usuario autorizado de ninguna empresa en el Portal MIPYME ' +
|
|
515
|
+
'(mipeSelEmpresa.cgi sin opciones). El portal se opera normalmente con la PERSONA que ' +
|
|
516
|
+
'representa a la empresa, no con la cuenta de la empresa: si entraste como empresa, ' +
|
|
517
|
+
'cierra sesión (`sii auth logout`) y entra con tu RUT personal.');
|
|
518
|
+
}
|
|
519
|
+
await sleep(); // the chooser GET and the form load are two hops — pace them (ADR-004)
|
|
520
|
+
return { empresas: [await readScopedEmpresa(session)], scoped: true };
|
|
521
|
+
}
|
|
522
|
+
/** The empresas the authenticated user may invoice for. This is the MIPYME "usuario
|
|
523
|
+
* autorizado" list — its own value domain, NOT the operate pointer's operable set. On a
|
|
524
|
+
* single-empresa account (no chooser) it is the one empresa the portal scoped the session to. */
|
|
525
|
+
export async function fetchEmpresas(session, tipoDte, sleep = () => Promise.resolve()) {
|
|
526
|
+
return (await resolveChooser(session, tipoDte, sleep)).empresas;
|
|
527
|
+
}
|
|
528
|
+
/** Point the MIPYME session at `empresa`. EVERY other operation depends on this having run:
|
|
529
|
+
* the form, the borrador CRUD and the borradores list are all scoped to it (observed). */
|
|
530
|
+
async function selectEmpresa(session, empresa, tipoDte) {
|
|
531
|
+
const res = await session.requestForm(SEL_EMPRESA_URL, {
|
|
532
|
+
form: { DESDE_DONDE_URL: desdeDonde(tipoDte), RUT_EMP: empresa.rut },
|
|
533
|
+
});
|
|
534
|
+
// The redirect target IS the factura form; a bounce back to the chooser means SII refused.
|
|
535
|
+
if (/name="RUT_EMP"/i.test(res.body)) {
|
|
536
|
+
throw new DteError(`El SII no aceptó la empresa ${empresa.rut} en el Portal MIPYME. ` +
|
|
537
|
+
'Verifica que sigas siendo usuario autorizado de esa empresa.');
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** Resolve `rut` against the live authorized list and select it. Returns the matched empresa so
|
|
541
|
+
* callers can echo SII's own razón social. An unknown RUT fails with the list (ADR-005 style). */
|
|
542
|
+
export async function resolveAndSelectEmpresa(session, rut, tipoDte, sleep = () => Promise.resolve()) {
|
|
543
|
+
const { empresas, scoped } = await resolveChooser(session, tipoDte, sleep);
|
|
544
|
+
const match = empresas.find((e) => e.rut.split('-')[0] === String(rut.body));
|
|
545
|
+
if (!match) {
|
|
546
|
+
throw new DteError(`${rut.formatted} no está en tus empresas del Portal MIPYME. Disponibles: ` +
|
|
547
|
+
empresas.map((e) => `${e.rut} (${e.nombre})`).join(', ') +
|
|
548
|
+
'.');
|
|
549
|
+
}
|
|
550
|
+
// Launcher shape: SII already scoped the session to this empresa — a POST would be a
|
|
551
|
+
// no-op at best and there is no chooser to accept it (observed 2026-09-09, #95).
|
|
552
|
+
if (scoped)
|
|
553
|
+
return match;
|
|
554
|
+
await sleep(); // pace the two consecutive hops like every other pair (ADR-004)
|
|
555
|
+
await selectEmpresa(session, match, tipoDte);
|
|
556
|
+
return match;
|
|
557
|
+
}
|
|
558
|
+
/** Load the factura form for `empresa` (a fresh one, or an existing borrador) and fill it with
|
|
559
|
+
* `input`, letting SII's own client-side validator pass judgement. Returns the POST-ready body
|
|
560
|
+
* + SII's computed totals, which every downstream CGI (graba / elimina / preview) consumes. */
|
|
561
|
+
export async function fillFactura(session, empresa, input) {
|
|
562
|
+
const [body, dv] = empresa.rut.split('-');
|
|
563
|
+
const url = input.borradorId
|
|
564
|
+
? `${FORM_URL}?PTDC_CODIGO=${input.tipoDte}&ES_BORR=TRUE&VALOR=${input.borradorId}` +
|
|
565
|
+
`&IGUAL=CODIGO&RUT=${body}&DV=${dv}&TPO_DOC_GEN=${input.tipoDte}`
|
|
566
|
+
: `${FORM_URL}?PTDC_CODIGO=${input.tipoDte}`;
|
|
567
|
+
const landed = await session.goto(url);
|
|
568
|
+
if (!landed.includes('mipeGenFacEx.cgi')) {
|
|
569
|
+
throw new DteError(`El SII no entregó el formulario de factura (llegamos a ${landed}). ` +
|
|
570
|
+
'Puede que la empresa no esté autorizada a emitir este tipo de documento.');
|
|
571
|
+
}
|
|
572
|
+
const r = await session.evaluate(fillScript({
|
|
573
|
+
ciudadEmisor: input.ciudadEmisor,
|
|
574
|
+
fechaEmision: input.fechaEmision,
|
|
575
|
+
formaPago: FORMA_PAGO[input.formaPago],
|
|
576
|
+
receptor: { ...input.receptor, contacto: input.receptor.contacto ?? '' },
|
|
577
|
+
items: input.items.map((it) => ({
|
|
578
|
+
nombre: it.nombre,
|
|
579
|
+
descripcion: it.descripcion ?? '',
|
|
580
|
+
cantidad: String(it.cantidad),
|
|
581
|
+
unidad: it.unidad ?? '',
|
|
582
|
+
precioUnitario: String(it.precioUnitario),
|
|
583
|
+
descuentoPct: it.descuentoPct ? String(it.descuentoPct) : '',
|
|
584
|
+
})),
|
|
585
|
+
borradorId: input.borradorId ?? '',
|
|
586
|
+
}));
|
|
587
|
+
if (r.scraper) {
|
|
588
|
+
throw new DteError(`Formulario de factura del SII no reconocido (${r.scraper}).`);
|
|
589
|
+
}
|
|
590
|
+
if (r.missing && r.missing.length > 0) {
|
|
591
|
+
throw new DteError(`El formulario del SII cambió de forma: faltan los campos ${r.missing.join(', ')}.`);
|
|
592
|
+
}
|
|
593
|
+
// SII's own validator refused — pass its Spanish message through UNCHANGED (ADR-004).
|
|
594
|
+
if (!r.ok)
|
|
595
|
+
throw new DteError((r.msgs ?? []).join(' ') || 'El SII rechazó el documento.');
|
|
596
|
+
if (!r.fields || !r.totales)
|
|
597
|
+
throw new DteError('El formulario del SII no entregó datos.');
|
|
598
|
+
return { fields: r.fields, totales: r.totales, avisos: r.coerced ?? [] };
|
|
599
|
+
}
|
|
600
|
+
/** Serialize an EXISTING borrador's form without changing it — the body `mipeEliminaBorrador`
|
|
601
|
+
* needs (the CGI takes the whole form back, keyed by `EHDR_CODIGO`). Totals come from the
|
|
602
|
+
* form as SII rendered it. */
|
|
603
|
+
export async function loadBorrador(session, empresa, tipoDte, borradorId) {
|
|
604
|
+
const [body, dv] = empresa.rut.split('-');
|
|
605
|
+
const landed = await session.goto(`${FORM_URL}?PTDC_CODIGO=${tipoDte}&ES_BORR=TRUE&VALOR=${borradorId}` +
|
|
606
|
+
`&IGUAL=CODIGO&RUT=${body}&DV=${dv}&TPO_DOC_GEN=${tipoDte}`);
|
|
607
|
+
if (!landed.includes('mipeGenFacEx.cgi')) {
|
|
608
|
+
throw new DteError(`El SII no entregó el borrador ${borradorId} (llegamos a ${landed}).`);
|
|
609
|
+
}
|
|
610
|
+
const r = await session.evaluate(`(async () => {
|
|
611
|
+
const f = document.forms['VIEW_EFXP'];
|
|
612
|
+
if (!f) return { scraper: 'no se encontró el formulario VIEW_EFXP' };
|
|
613
|
+
const el = (n) => f.elements[n] || null;
|
|
614
|
+
const hdr = el('EHDR_CODIGO');
|
|
615
|
+
if (!hdr) return { scraper: 'falta EHDR_CODIGO en el borrador' };
|
|
616
|
+
if (hdr.value !== ${JSON.stringify(borradorId)}) {
|
|
617
|
+
return { scraper: 'el SII devolvió otro borrador (' + hdr.value + ')' };
|
|
618
|
+
}
|
|
619
|
+
// The detail grid is drawn ASYNCHRONOUSLY (same hazard as fillScript). Serializing before
|
|
620
|
+
// it exists yielded a form with NO detail lines, and SII then rejected the preview with
|
|
621
|
+
// "Debe ingresar nombre del primer item del detalle" (observed 2026-09-08).
|
|
622
|
+
const deadline = Date.now() + 15000;
|
|
623
|
+
while (!(el('EFXP_NMB_01') && el('EFXP_QTY_01') && el('EFXP_PRC_01'))) {
|
|
624
|
+
if (Date.now() > deadline) return { scraper: 'la grilla de detalle del borrador no se dibujó' };
|
|
625
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
626
|
+
}
|
|
627
|
+
// and wait for the values to actually land (the grid is filled after it is drawn)
|
|
628
|
+
const valued = Date.now() + 10000;
|
|
629
|
+
while (String((el('EFXP_NMB_01') || {}).value || '').trim() === '') {
|
|
630
|
+
if (Date.now() > valued) break; // a genuinely empty borrador is possible
|
|
631
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
632
|
+
}
|
|
633
|
+
const fields = {};
|
|
634
|
+
for (const e of f.elements) {
|
|
635
|
+
if (!e.name || e.type === 'button' || e.type === 'submit') continue;
|
|
636
|
+
if ((e.type === 'checkbox' || e.type === 'radio') && !e.checked) continue;
|
|
637
|
+
fields[e.name] = e.value;
|
|
638
|
+
}
|
|
639
|
+
const num = (n) => Number((el(n) || {}).value || 0);
|
|
640
|
+
return { ok: true, fields, totales: {
|
|
641
|
+
neto: num('EFXP_MNT_NETO'), iva: num('EFXP_IVA'), total: num('EFXP_MNT_TOTAL'),
|
|
642
|
+
} };
|
|
643
|
+
})()`);
|
|
644
|
+
if (r.scraper)
|
|
645
|
+
throw new DteError(`No se pudo leer el borrador ${borradorId}: ${r.scraper}.`);
|
|
646
|
+
if (!r.fields || !r.totales)
|
|
647
|
+
throw new DteError(`El borrador ${borradorId} llegó vacío.`);
|
|
648
|
+
return { fields: r.fields, totales: r.totales, avisos: [] };
|
|
649
|
+
}
|
|
650
|
+
/** Persist the filled form as a borrador (create, or update when `EHDR_CODIGO` is set).
|
|
651
|
+
* `ES_BORR=TRUE` is what tells the CGI this is a draft, not a document to sign (observed). */
|
|
652
|
+
export async function grabaBorrador(session, filled) {
|
|
653
|
+
const res = await postLatin1(session, GRABA_URL, { ...filled.fields, ES_BORR: 'TRUE' });
|
|
654
|
+
assertCgiOk(res.body, 'grabaBorrador');
|
|
655
|
+
}
|
|
656
|
+
/** Delete a borrador. SII takes the WHOLE form back, keyed by `EHDR_CODIGO` (observed). */
|
|
657
|
+
export async function eliminaBorrador(session, filled) {
|
|
658
|
+
const res = await postLatin1(session, ELIMINA_URL, { ...filled.fields, ES_BORR: 'TRUE' });
|
|
659
|
+
assertCgiOk(res.body, 'eliminaBorrador');
|
|
660
|
+
}
|
|
661
|
+
/** Repair SII's DOUBLE-ENCODED text. `listaBorrador` declares `charset=ISO-8859-1` but serves
|
|
662
|
+
* UTF-8 bytes of an ALREADY-mojibaked string: "Á" arrives as `C3 83 C2 81`, i.e. UTF-8 of
|
|
663
|
+
* ("Ã", U+0081) — so a correct UTF-8 decode still yields "Fundación" / "Ñuñoa" (observed
|
|
664
|
+
* 2026-09-08). Undo the extra layer by re-encoding the decoded string as Latin-1 and decoding
|
|
665
|
+
* it as UTF-8 again. Applied ONLY when the string carries the tell-tale `Ã`/`Â` + continuation
|
|
666
|
+
* pair AND the round trip decodes cleanly, so legitimately-accented text is left untouched.
|
|
667
|
+
* This only fixes what we DISPLAY — nothing sent to SII is altered. */
|
|
668
|
+
export function repairMojibake(v) {
|
|
669
|
+
if (!/[\u00c2-\u00c3][\u0080-\u00bf]/.test(v))
|
|
670
|
+
return v;
|
|
671
|
+
const bytes = Uint8Array.from(v, (ch) => {
|
|
672
|
+
const c = ch.charCodeAt(0);
|
|
673
|
+
return c > 0xff ? 0x3f : c; // non-Latin-1 char ⇒ not a mojibake string
|
|
674
|
+
});
|
|
675
|
+
if (Array.from(v).some((ch) => ch.charCodeAt(0) > 0xff))
|
|
676
|
+
return v;
|
|
677
|
+
try {
|
|
678
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
679
|
+
return decoded.includes('\ufffd') ? v : decoded;
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return v; // not valid UTF-8 once re-encoded ⇒ leave the original alone
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/** The borradores of the CURRENTLY SELECTED empresa. A plain JSON array (no SDI `respEstado`
|
|
686
|
+
* envelope — observed), served ISO-8859-1; the seam decodes it. Rows carry ~250 mostly-null
|
|
687
|
+
* form columns; only the listing columns are curated (no `raw` — the row is receptor +
|
|
688
|
+
* emisor identity, i.e. PII, ADR-004). */
|
|
689
|
+
export async function fetchBorradores(session) {
|
|
690
|
+
const data = await session.requestJson(LISTA_BORRADOR_URL, { method: 'GET' });
|
|
691
|
+
if (!Array.isArray(data)) {
|
|
692
|
+
throw new DteError('El SII no entregó la lista de borradores en el formato esperado.');
|
|
693
|
+
}
|
|
694
|
+
const num = (v) => typeof v === 'string' && v.trim() !== '' ? Number(v) : null;
|
|
695
|
+
const str = (v) => typeof v === 'string' && v !== '' ? repairMojibake(v) : null;
|
|
696
|
+
return data.map((r) => {
|
|
697
|
+
const row = r;
|
|
698
|
+
const body = str(row['efxp_RUT_RECEP']);
|
|
699
|
+
const dv = str(row['efxp_DV_RECEP']);
|
|
700
|
+
return {
|
|
701
|
+
id: String(row['ehdr_CODIGO'] ?? ''),
|
|
702
|
+
tipoDte: Number(row['ptdc_CODIGO'] ?? 0),
|
|
703
|
+
tipoDteDesc: String(row['ptdc_CODIGO_DESC'] ?? ''),
|
|
704
|
+
fecha: str(row['efxp_FCH_EMIS']),
|
|
705
|
+
receptorRut: body && dv ? `${body}-${dv}` : null,
|
|
706
|
+
receptorNombre: str(row['efxp_RZN_SOC_RECEP']),
|
|
707
|
+
emisorNombre: str(row['efxp_RZN_SOC']),
|
|
708
|
+
iva: num(row['efxp_IVA']),
|
|
709
|
+
total: num(row['efxp_MNT_TOTAL']),
|
|
710
|
+
};
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
/** Strip tags from one HTML cell and normalise whitespace. ` ` counts as whitespace here:
|
|
714
|
+
* a cell holding only a spacer entity is a BLANK cell, and the callers below decide what a blank
|
|
715
|
+
* means positionally — they must not receive the literal string ` ` as a value (#92). */
|
|
716
|
+
const cellText = (html) => unescapeHtml(html.replace(/<[^>]*>/g, ' '))
|
|
717
|
+
.replace(/ /gi, ' ')
|
|
718
|
+
.replace(/\s+/g, ' ')
|
|
719
|
+
.trim();
|
|
720
|
+
/** A blank cell is a MISSING value, not an absent column. Deliberately a twin of `cell()` in
|
|
721
|
+
* `dte-public.ts`: both are portal modules, and ADR-007 forbids one importing the other's
|
|
722
|
+
* internals — sharing would mean a new leaf, which this fix does not warrant. */
|
|
723
|
+
const cellValue = (v) => v === undefined || v === '' ? null : v;
|
|
724
|
+
/** Same, for a numeric cell: SII pads amounts with separators, and a blank reads as absent. */
|
|
725
|
+
const cellNumber = (v) => {
|
|
726
|
+
const d = (v ?? '').replace(/[^\d-]/g, '');
|
|
727
|
+
return d === '' ? null : Number(d);
|
|
728
|
+
};
|
|
729
|
+
/** The cells the emitted row carries after the "Ver" cell, in order: receptor RUT · receptor
|
|
730
|
+
* nombre · tipo · folio · fecha · monto · estado (observed 2026-09-08). */
|
|
731
|
+
const CELDAS_EMITIDA = 7;
|
|
732
|
+
/** Parse the emitted-documents table. The markup is MALFORMED — SII leaves the receptor cell
|
|
733
|
+
* unclosed (`<td>64000001-5 <td>NOMBRE</td>`, observed 2026-09-08) — so rows are split on the
|
|
734
|
+
* `mipeGesDocEmi.cgi?...CODIGO=` anchor and cells on `<td`, never with a strict parser.
|
|
735
|
+
*
|
|
736
|
+
* Cells map POSITIONALLY and a blank one is NEVER dropped. A `PRV` (vista previa) document has
|
|
737
|
+
* no folio; dropping that cell before indexing slid every later column one place left — fecha
|
|
738
|
+
* into folio, monto into fecha, estado into monto — producing a plausible row with the values
|
|
739
|
+
* under the wrong names and no error at all (#92). A row that does not carry the known cells is
|
|
740
|
+
* a changed portal, so it raises instead of realigning silently. */
|
|
741
|
+
export function parseEmitidas(html) {
|
|
742
|
+
const out = [];
|
|
743
|
+
const re = /<a[^>]*mipeGesDocEmi\.cgi\?[^"']*CODIGO=(\d+)[^>]*>[\s\S]*?<\/tr>/gi;
|
|
744
|
+
for (let m = re.exec(html); m; m = re.exec(html)) {
|
|
745
|
+
const codigo = m[1];
|
|
746
|
+
if (!codigo)
|
|
747
|
+
continue;
|
|
748
|
+
// cells AFTER the "Ver" cell that holds the anchor
|
|
749
|
+
const cells = m[0]
|
|
750
|
+
.split(/<td[^>]*>/i)
|
|
751
|
+
.slice(1)
|
|
752
|
+
.map(cellText);
|
|
753
|
+
// EXACT, not "at least": a blank cell BEFORE the receptor RUT would shift the row just as
|
|
754
|
+
// badly as a dropped one, and the old filter hid both. Any count but the observed seven is a
|
|
755
|
+
// changed portal, and a loud, countable error is diagnosable where a mislabeled row is not.
|
|
756
|
+
if (cells.length !== CELDAS_EMITIDA) {
|
|
757
|
+
throw new DteError(`La tabla de documentos emitidos del Portal MIPYME cambió de forma: la fila ${codigo} ` +
|
|
758
|
+
`trae ${cells.length} celda(s) y se esperan ${CELDAS_EMITIDA} ` +
|
|
759
|
+
`(RUT receptor, nombre, tipo, folio, fecha, monto, estado). El scraper está roto.`);
|
|
760
|
+
}
|
|
761
|
+
out.push({
|
|
762
|
+
codigo,
|
|
763
|
+
receptorRut: cellValue(cells[0]),
|
|
764
|
+
receptorNombre: cellValue(cells[1]),
|
|
765
|
+
tipoDteDesc: cellValue(cells[2]),
|
|
766
|
+
folio: cellNumber(cells[3]),
|
|
767
|
+
fecha: cellValue(cells[4]),
|
|
768
|
+
monto: cellNumber(cells[5]),
|
|
769
|
+
estado: cellValue(cells[6]),
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
/** The DTEs already emitted by the selected empresa. Read-only: this touches nothing and
|
|
775
|
+
* cannot issue a document (ADR-023). Every filter is optional; SII takes them as query
|
|
776
|
+
* params and answers ISO-8859-1 HTML (observed 2026-09-08). */
|
|
777
|
+
export async function fetchEmitidas(session, filtro = {}) {
|
|
778
|
+
const q = new URLSearchParams({
|
|
779
|
+
RUT_RECP: filtro.receptor ?? '',
|
|
780
|
+
FOLIO: filtro.folio === undefined ? '' : String(filtro.folio),
|
|
781
|
+
RZN_SOC: '',
|
|
782
|
+
FEC_DESDE: filtro.desde ?? '',
|
|
783
|
+
FEC_HASTA: filtro.hasta ?? '',
|
|
784
|
+
TPO_DOC: filtro.tipoDoc === undefined ? '' : String(filtro.tipoDoc),
|
|
785
|
+
ESTADO: filtro.estado ? ESTADO_EMITIDO[filtro.estado] : '',
|
|
786
|
+
ORDEN: '',
|
|
787
|
+
NUM_PAG: String(filtro.pagina ?? 1),
|
|
788
|
+
});
|
|
789
|
+
const res = await session.requestForm(`${EMITIDOS_URL}?${q.toString()}`, { method: 'GET' });
|
|
790
|
+
const rejection = serverAlert(res.body);
|
|
791
|
+
if (rejection)
|
|
792
|
+
throw new DteError(`El SII rechazó la consulta: ${rejection}`);
|
|
793
|
+
if (!/mipeGesDocEmi\.cgi/i.test(res.body) &&
|
|
794
|
+
!/Documentos Emitidos|No se encontraron/i.test(res.body)) {
|
|
795
|
+
throw new DteError('El SII no entregó el listado de documentos emitidos (mipeAdminDocsEmi.cgi). ' +
|
|
796
|
+
'Puede que la sesión ya no esté viva o que el portal haya cambiado.');
|
|
797
|
+
}
|
|
798
|
+
return parseEmitidas(res.body);
|
|
799
|
+
}
|
|
800
|
+
/** The PDF of an ALREADY EMITTED document, by its listing `codigo` (`DHDR_CODIGO`). A plain
|
|
801
|
+
* authenticated GET — far simpler than the borrador preview, which needs the review page and
|
|
802
|
+
* the iframe's own form. Success is decided by `content-type` + `%PDF` magic, NEVER by HTTP
|
|
803
|
+
* status: SII answers 200 for its error page and for the login-wall bounce too (ADR-022). */
|
|
804
|
+
export async function fetchEmitidaPdf(session, codigo) {
|
|
805
|
+
const res = await session.requestBinary(`${EMITIDO_PDF_URL}?DHDR_CODIGO=${encodeURIComponent(codigo)}`, {
|
|
806
|
+
method: 'GET',
|
|
807
|
+
headers: {
|
|
808
|
+
Referer: `${CGI}/mipeGesDocEmi.cgi?ALL_PAGE_ANT=2&CODIGO=${encodeURIComponent(codigo)}`,
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
if (!isPdfBytes(res.bytes, res.contentType)) {
|
|
812
|
+
const sii = contribuyenteError(res.bytes, res.contentType);
|
|
813
|
+
throw new DteError(sii
|
|
814
|
+
? `El SII no entregó el documento emitido: ${sii}`
|
|
815
|
+
: `El SII no devolvió un PDF del documento emitido (content-type: ${res.contentType ?? 'desconocido'}).`);
|
|
816
|
+
}
|
|
817
|
+
return res.bytes;
|
|
818
|
+
}
|
|
819
|
+
/** Fetch the PREVIEW PDF of a filled (unsigned) document — the "Validar y visualizar" path.
|
|
820
|
+
*
|
|
821
|
+
* TWO hops, both first-hand-observed 2026-09-08:
|
|
822
|
+
* 1. POST the VIEW_EFXP body to `mipeDisplayPreView.cgi` → the review page, whose
|
|
823
|
+
* `PreViewDTE` form carries the document as ~245 HIDDEN inputs.
|
|
824
|
+
* 2. POST that `PreViewDTE` body to `mipePreView.cgi` → `application/pdf`.
|
|
825
|
+
*
|
|
826
|
+
* Hop 2 is the PDF the portal embeds in its `framePdf` iframe: the document stamped
|
|
827
|
+
* "VISTA PREVIA / DOCUMENTO NO VALIDO", folio NOT assigned. Reaching the review page is NOT
|
|
828
|
+
* signing it — its `Firmar` button posts to `mipeGenXMLFirma.cgi`, which this module never
|
|
829
|
+
* calls (ADR-023).
|
|
830
|
+
*
|
|
831
|
+
* Success is decided by `content-type` + the `%PDF` magic, NEVER by HTTP status — SII answers
|
|
832
|
+
* 200 for its own error page and for the login-wall bounce too (the ADR-022 rule). */
|
|
833
|
+
export async function fetchPreviewPdf(session, filled, sleep = () => Promise.resolve()) {
|
|
834
|
+
const review = await postLatin1(session, PREVIEW_URL, filled.fields);
|
|
835
|
+
// SII rejects a bad document with a 200 "Redireccionando" page whose only content is an
|
|
836
|
+
// alert() + history.go(-1) (observed 2026-09-08). Surface ITS message verbatim (ADR-004)
|
|
837
|
+
// instead of a generic failure — that is the real reason the PDF never came back.
|
|
838
|
+
const rejection = serverAlert(review.body);
|
|
839
|
+
if (rejection)
|
|
840
|
+
throw new DteError(`El SII rechazó el documento: ${rejection}`);
|
|
841
|
+
const fields = parseHiddenInputs(review.body, 'PreViewDTE');
|
|
842
|
+
if (Object.keys(fields).length === 0) {
|
|
843
|
+
throw new DteError('El SII no entregó la vista previa del documento (no se encontró el formulario PreViewDTE). ' +
|
|
844
|
+
'Puede que haya rechazado algún dato del documento.');
|
|
845
|
+
}
|
|
846
|
+
// Reproduce the iframe's request as the browser issues it — captured from the live network
|
|
847
|
+
// panel, NOT guessed. Without the Referer/Origin/sec-fetch-dest trio SII answers with an
|
|
848
|
+
// HTML page instead of the PDF (that was the "recibe HTML en vez de PDF" failure).
|
|
849
|
+
// The iframe does NOT post the review page's fields wholesale: it owns a form of its own
|
|
850
|
+
// (`VIEW`, 239 inputs) and copies the values across, so the PDF CGI receives that exact,
|
|
851
|
+
// narrower field set. Posting all 243 PreViewDTE hidden inputs instead made SII answer with
|
|
852
|
+
// its generic "Error al contribuyente" page (observed 2026-09-08). Derive the field list from
|
|
853
|
+
// SII's own frame at runtime rather than hardcoding 239 names — if SII edits the frame, the
|
|
854
|
+
// request follows.
|
|
855
|
+
await sleep();
|
|
856
|
+
const frame = await session.requestForm(PREVIEW_FRAME_URL, { method: 'GET' });
|
|
857
|
+
const wanted = frameFields(frame.body);
|
|
858
|
+
if (wanted.length === 0) {
|
|
859
|
+
throw new DteError('El SII cambió PreViewFrame.html: no se pudo determinar los campos de la vista previa.');
|
|
860
|
+
}
|
|
861
|
+
// The frame copies 238 of its 239 inputs from the review page; the odd one out, `EFXP_FOLIO`,
|
|
862
|
+
// keeps the frame's OWN default (`value="0"`) because an unsigned preview has no folio. So a
|
|
863
|
+
// field absent from the review page must fall back to the frame's declared value, NOT to ''
|
|
864
|
+
// — sending `EFXP_FOLIO=` made SII answer "Error al contribuyente" (observed 2026-09-08).
|
|
865
|
+
const body = latin1FormBody(wanted.map((f) => [f.name, fields[f.name] ?? f.value]));
|
|
866
|
+
await sleep(); // pace the two consecutive POSTs (the browser does them a beat apart)
|
|
867
|
+
const res = await session.requestBinary(PDF_URL, {
|
|
868
|
+
method: 'POST',
|
|
869
|
+
headers: {
|
|
870
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
871
|
+
Referer: PREVIEW_FRAME_URL,
|
|
872
|
+
Origin: MIPE_ORIGIN,
|
|
873
|
+
'Sec-Fetch-Dest': 'iframe',
|
|
874
|
+
'Sec-Fetch-Mode': 'navigate',
|
|
875
|
+
'Sec-Fetch-Site': 'same-origin',
|
|
876
|
+
'Upgrade-Insecure-Requests': '1',
|
|
877
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
|
878
|
+
},
|
|
879
|
+
body,
|
|
880
|
+
});
|
|
881
|
+
if (!isPdfBytes(res.bytes, res.contentType)) {
|
|
882
|
+
// Relay SII's own message when it gave one (ADR-004) instead of a bare content-type.
|
|
883
|
+
const sii = contribuyenteError(res.bytes, res.contentType);
|
|
884
|
+
throw new DteError(sii
|
|
885
|
+
? `El SII no generó la vista previa: ${sii}`
|
|
886
|
+
: `El SII no devolvió un PDF de vista previa (content-type: ${res.contentType ?? 'desconocido'}).`);
|
|
887
|
+
}
|
|
888
|
+
return res.bytes;
|
|
889
|
+
}
|
|
890
|
+
//# sourceMappingURL=dte-mipyme.js.map
|