SendXmlDocumentJob   A
last analyzed

Complexity

Total Complexity 32

Size/Duplication

Total Lines 312
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 0
Metric Value
wmc 32
eloc 122
dl 0
loc 312
ccs 0
cts 132
cp 0
rs 9.84
c 0
b 0
f 0

6 Methods

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