Passed
Push — master ( a8bdfa...0cad3e )
by Esteban De La Fuente
09:10
created

DocumentUploader   A

Complexity

Total Complexity 32

Size/Duplication

Total Lines 330
Duplicated Lines 0 %

Test Coverage

Coverage 7.3%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 129
c 1
b 0
f 0
dl 0
loc 330
ccs 10
cts 137
cp 0.073
rs 9.84
wmc 32

6 Methods

Rating   Name   Duplication   Size   Complexity  
A sendXml() 0 54 3
C validateUploadXmlResponse() 0 70 13
A __construct() 0 8 1
A createXmlFile() 0 34 5
B uploadXml() 0 66 8
A getXmlFilePath() 0 13 2
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * LibreDTE: Biblioteca PHP (Núcleo).
7
 * Copyright (C) LibreDTE <https://www.libredte.cl>
8
 *
9
 * Este programa es software libre: usted puede redistribuirlo y/o modificarlo
10
 * bajo los términos de la Licencia Pública General Affero de GNU publicada
11
 * por la Fundación para el Software Libre, ya sea la versión 3 de la Licencia,
12
 * o (a su elección) cualquier versión posterior de la misma.
13
 *
14
 * Este programa se distribuye con la esperanza de que sea útil, pero SIN
15
 * GARANTÍA ALGUNA; ni siquiera la garantía implícita MERCANTIL o de APTITUD
16
 * PARA UN PROPÓSITO DETERMINADO. Consulte los detalles de la Licencia Pública
17
 * General Affero de GNU para obtener una información más detallada.
18
 *
19
 * Debería haber recibido una copia de la Licencia Pública General Affero de
20
 * GNU junto a este programa.
21
 *
22
 * En caso contrario, consulte <http://www.gnu.org/licenses/agpl.html>.
23
 */
24
25
namespace libredte\lib\Core\Sii\HttpClient\WebService;
26
27
use libredte\lib\Core\Helper\Rut;
28
use libredte\lib\Core\Signature\Certificate;
29
use libredte\lib\Core\Sii\HttpClient\ConnectionConfig;
30
use libredte\lib\Core\Sii\HttpClient\SiiClientException;
31
use libredte\lib\Core\Sii\HttpClient\TokenManager;
32
use libredte\lib\Core\Xml\XmlConverter;
33
use libredte\lib\Core\Xml\XmlDocument;
34
use UnexpectedValueException;
35
36
/**
37
 * Clase para el envío de documentos al SII.
38
 *
39
 * Principalmente es para el envío y consulta de estado del envío de documentos
40
 * tributarios electrónicos en formato XML.
41
 */
42
class DocumentUploader
43
{
44
    /**
45
     * Certificado digital.
46
     *
47
     * @var Certificate
48
     */
49
    private Certificate $certificate;
50
51
    /**
52
     * Configuración de la conexión al SII.
53
     *
54
     * @var ConnectionConfig
55
     */
56
    private ConnectionConfig $config;
57
58
    /**
59
     * Administrador de tokens de autenticación del SII.
60
     *
61
     * @var TokenManager
62
     */
63
    private TokenManager $tokenManager;
64
65
    /**
66
     * Constructor de la clase que consume servicios web mediante WSDL del SII.
67
     *
68
     * @param Certificate $certificate
69
     * @param ConnectionConfig $config
70
     * @param TokenManager $tokenManager
71
     */
72 6
    public function __construct(
73
        Certificate $certificate,
74
        ConnectionConfig $config,
75
        TokenManager $tokenManager,
76
    ) {
77 6
        $this->certificate = $certificate;
78 6
        $this->config = $config;
79 6
        $this->tokenManager = $tokenManager;
80
    }
81
82
    /**
83
     * Realiza el envío de un XML al SII.
84
     *
85
     * @param XmlDocument $doc Documento XML que se desea enviar al SII.
86
     * @param string $company RUT de la empresa emisora del XML.
87
     * @param bool $compress Indica si se debe enviar comprimido el XML.
88
     * @param int|null $retry Intentos que se realizarán como máximo al enviar.
89
     * @return int Número de seguimiento (Track ID) del envío del XML al SII.
90
     * @throws UnexpectedValueException Si alguno de los RUT son inválidos.
91
     */
92 1
    public function sendXml(
93
        XmlDocument $doc,
94
        string $company,
95
        bool $compress = false,
96
        ?int $retry = null
97
    ): int {
98
        // Crear string del documento XML.
99 1
        $xml = $doc->saveXML();
100 1
        if (empty($xml) || $xml == '<?xml version="1.0" encoding="ISO-8859-1"?>'."\n") {
101 1
            throw new SiiClientException(
102 1
                'El XML que se desea enviar al SII no puede ser vacío.'
103 1
            );
104
        }
105
106
        // Validar los RUT que se utilizarán para el envío y descomponerlos.
107
        $sender = $this->certificate->getID();
108
        Rut::validate($sender);
109
        Rut::validate($company);
110
        [$rutSender, $dvSender] = Rut::toArray($sender);
111
        [$rutCompany, $dvCompany] = Rut::toArray($company);
112
113
        // Crear el archivo que se enviará en el sistema de archivos para poder
114
        // adjuntarlo en la solicitud mediante curl al SII.
115
        [$filepath, $mimetype] = $this->createXmlFile($xml, $compress);
116
        $filename = $company . '_' . basename($filepath);
117
118
        // Preparar los datos que se enviarán mediante POST al SII.
119
        $data = [
120
            'rutSender' => $rutSender,
121
            'dvSender' => $dvSender,
122
            'rutCompany' => $rutCompany,
123
            'dvCompany' => $dvCompany,
124
            'archivo' => curl_file_create($filepath, $mimetype, $filename),
125
        ];
126
127
        // Si no se especificó $retry se obtiene el valor por defecto.
128
        $retry = max(0, min(
129
            $retry ?? $this->config->getReintentos(),
130
            ConnectionConfig::REINTENTOS
131
        ));
132
133
        // Realizar la solicitud mediante POST al SII para subir el archivo.
134
        $xmlResponse = $this->uploadXml($data, $retry);
135
136
        // Eliminar el archivo temporal con el XML.
137
        unlink($filepath);
138
139
        // Procesar respuesta recibida desde el SII.
140
        $response = XmlConverter::xmlToArray($xmlResponse);
141
        $this->validateUploadXmlResponse($response);
142
143
        // Entregar el número de seguimiendo (Track ID) del envío al SII.
144
        $trackId = $response['RECEPCIONDTE']['TRACKID'] ?? 0;
145
        return (int) $trackId;
146
    }
147
148
    /**
149
     * Valida la respuesta recibida desde el SII al enviar un XML.
150
     *
151
     * @param array $response Arreglo con los datos del XML de la respuesta.
152
     * @return void
153
     * @throws SiiClientException Si el envío tuvo algún problema.
154
     */
155
    private function validateUploadXmlResponse(array $response): void
156
    {
157
        $status = $response['RECEPCIONDTE']['STATUS'] ?? null;
158
159
        // Si el estado es `null` la respuesta del SII no es válida. Lo cual
160
        // indicaría que el SII no contestó correctamente a la solicitud o bien
161
        // la misma solicitud se hizo de manera incorrecta produciendo que el
162
        // SII no contestase adecuadamente.
163
        if ($status === null) {
164
            throw new SiiClientException(
165
                'La respuesta del envío del XML al SII no trae un código de estado válido.'
166
            );
167
        }
168
169
        // Si el estado es 0, el envío fue OK.
170
        if ($status == 0) {
171
            return;
172
        }
173
174
        // Se define un mensaje de error según el código de estado.
175
        switch ($status) {
176
            case 1:
177
                $message = sprintf(
178
                    'El usuario %s no tiene permisos para enviar XML al SII.',
179
                    $this->certificate->getId()
180
                );
181
                break;
182
            case 2:
183
                $message = 'Error en el tamaño del archivo enviado con el XML, muy grande o muy chico.';
184
                break;
185
            case 3:
186
                $message = 'El archivo enviado está cortado, el tamaño es diferente al parámetro "size".';
187
                break;
188
            case 5:
189
                $message = sprintf(
190
                    'El usuario %s no está autenticado (posible token expirado).',
191
                    $this->certificate->getId()
192
                );
193
                break;
194
            case 6:
195
                $message = 'La empresa no está autorizada a enviar archivos XML al SII.';
196
                break;
197
            case 7:
198
                $message = 'El esquema del XML es inválido.';
199
                break;
200
            case 8:
201
                $message = 'Existe un error en la firma del documento XML.';
202
                break;
203
            case 9:
204
                $message = 'Los servidores del SII están con problemas internos.';
205
                break;
206
            case 99:
207
                $message = 'El XML enviado ya fue previamente recibido por el SII.';
208
                break;
209
            default:
210
                $message = sprintf(
211
                    'Ocurrió un error con código de estado "%s", que es desconocido por LibreDTE.',
212
                    $status
213
                );
214
                break;
215
        }
216
217
        // Ver si vienen detalles del error.
218
        $error = $response['DETAIL']['ERROR'] ?? null;
219
        if ($error !== null) {
220
            $message .= ' ' . implode(' ', $error);
221
        }
222
223
        // Lanzar una excepción con el mensaje de error determinado.
224
        throw new SiiClientException($message);
225
    }
226
227
    /**
228
     * Sube el archivo XML al SII y retorna la respuesta de este.
229
     *
230
     * Este método emula la subida mendiante los siguientes formularios:
231
     *
232
     *   - Producción: https://palena.sii.cl/cgi_dte/UPL/DTEauth?1
233
     *   - Certificación: https://maullin.sii.cl/cgi_dte/UPL/DTEauth?1
234
     *
235
     * @param array $data Arreglo con los datos del formulario del SII,
236
     * incluyendo el archivo XML que se subirá.
237
     * @param integer $retry
238
     * @return XmlDocument Respuesta del SII al enviar el XML.
239
     * @throws SiiClientException Si no se puede obtener el token para enviar
240
     * el XML al SII o si hubo un problema (error) al enviar el XML al SII.
241
     */
242
    private function uploadXml(array $data, int $retry): XmlDocument
243
    {
244
        // URL que se utilizará para subir el XML al SII.
245
        $url = $this->config->getUrl('/cgi_dte/UPL/DTEUpload');
246
247
        // Obtener el token asociado al certificado digital.
248
        $token = $this->tokenManager->getToken($this->certificate);
249
250
        // Cabeceras HTTP de la solicitud que se hará al SII.
251
        $headers = [
252
            'User-Agent: Mozilla/5.0 (compatible; PROG 1.0; +https://www.libredte.cl)',
253
            'Cookie: TOKEN=' . $token,
254
        ];
255
256
        // Inicializar curl.
257
        $curl = curl_init();
258
        curl_setopt($curl, CURLOPT_POST, true);
259
        curl_setopt($curl, CURLOPT_URL, $url);
260
        curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
261
        curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
262
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
263
264
        // Si no se debe verificar el certificado SSL del servidor del SII se
265
        // agrega la opción a curl.
266
        if (!$this->config->getVerificarSsl()) {
267
            curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
268
            curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0);
269
        }
270
271
        // Realizar el envío del XML al SII con $retry intentos.
272
        $responseBody = null;
273
        for ($i = 0; $i < $retry; $i++) {
274
            // Realizar consulta al SII enviando el XML.
275
            $responseBody = curl_exec($curl);
276
277
            // Si se logró obtener una respuesta, y no es un "Error 500",
278
            // entonces se logró enviar el XML al SII y se rompe el ciclo para
279
            // parar los reintentos.
280
            if ($responseBody && $responseBody !== 'Error 500') {
281
                break;
282
            }
283
284
            // El reitento será con "exponential backoff", por lo que se hace
285
            // una pausa de 0.2 * $retry segundos antes de volver a intentar el
286
            // envio del XML al SII.
287
            usleep(200000 * $retry);
288
        }
289
290
        // Validar si hubo un error en la respuesta.
291
        if (!$responseBody || $responseBody === 'Error 500') {
292
            $message = 'Falló el envío del XML al SII. ';
293
            $message .= !$responseBody
294
                ? curl_error($curl)
295
                : 'El SII tiene problemas en sus servidores (Error 500).'
296
            ;
297
            curl_close($curl); // Se cierra conexión curl acá por error.
298
            throw new SiiClientException($message);
299
        }
300
301
        // Cerrar conexión curl.
302
        curl_close($curl);
303
304
        // Entregar el resultado como un documento XML.
305
        $xmlDocument = new XmlDocument();
306
        $xmlDocument->loadXML((string) $responseBody);
307
        return $xmlDocument;
308
    }
309
310
    /**
311
     * Guarda el XML en un archivo temporal y, si es necesario, lo comprime.
312
     *
313
     * @param string $xml Documento XML que se guardará en el archivo..
314
     * @param bool $compress Indica si se debe crear un archivo comprimido.
315
     * @return array Arreglo con la ruta al archivo creado y su mimetype.
316
     */
317
    private function createXmlFile(string $xml, bool $compress): array
318
    {
319
        // Normalizar el XML agregando el encabezado si no viene en el
320
        // documento. El SII recibe los documentos en ISO-8859-1 por lo que se
321
        // asume (y no valida) que el contenido del XML en $xml viene ya
322
        // codificado en ISO-8859-1.
323
        if (!str_contains($xml, '<?xml')) {
324
            $xml = '<?xml version="1.0" encoding="ISO-8859-1"?>' . "\n" . $xml;
325
        }
326
327
        // Comprimir el XML si es necesario. En caso de error al comprimir se
328
        // creará el archivo XML igualmente, pero sin comprimir. Ya que no
329
        // debería fallar la creación del XML para el envío si falló la
330
        // compresión al ser una funcionalidad opcional del SII.
331
        if ($compress) {
332
            $xmlGzEncoded = gzencode($xml);
333
            if ($xmlGzEncoded !== false) {
334
                $xml = $xmlGzEncoded;
335
            } else {
336
                $compress = false;
337
            }
338
        }
339
340
        // Crear archivo temporal y guardar los datos del XML en el archivo.
341
        $filepath = $this->getXmlFilePath($compress);
342
        file_put_contents($filepath, $xml);
343
344
        // Determinar mimetype que tiene el archivo.
345
        $mimetype = $compress ? 'application/gzip' : 'application/xml';
346
347
        // Entregar la ruta al archivo creado con el contenido del XML y su
348
        // mimetype final (pues no necesariamente será gzip aunque se haya así
349
        // solicitado).
350
        return [$filepath, $mimetype];
351
    }
352
353
    /**
354
     * Obtiene un nombre único para el archivo del XML que se desea crear.
355
     *
356
     * @param bool $compress Indica si se debe crear un archivo comprimido.
357
     * @return string Arreglo con la ruta al archivo.
358
     */
359
    private function getXmlFilePath(bool $compress): string
360
    {
361
        // Genera un archivo temporal para el XML.
362
        $tempDir = sys_get_temp_dir();
363
        $prefix = 'libredte_xml_document_for_upload_to_sii_';
364
        $filepath = tempnam($tempDir, $prefix);
365
366
        // Renombrar la ruta asignando la extensión al archivo.
367
        $realFilepath = $filepath . ($compress ? '.xml.gz' : '.xml');
368
        rename($filepath, $realFilepath);
369
370
        // Entregar la ruta que se determinó para el archivo.
371
        return $realFilepath;
372
    }
373
}
374