Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
13.45% covered (danger)
13.45%
106 / 788
0.00% covered (danger)
0.00%
0 / 14
CRAP
n/a
0 / 0
_sii_headers_to_array
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
_sii_fiscal_region
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
342
_extract_wsdl_information
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
156
_sii_representation_certificate
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
210
_send_sii_request
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
110
_parse_sii_reponse_lines
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
_parse_sii_onerror_lines
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
_parse_sii_response
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
210
update_ticketsii_info
86.96% covered (success)
86.96%
60 / 69
0.00% covered (danger)
0.00%
0 / 1
25.28
_sendsii_substituted_ticket_is_valid
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
42
_sendsii_substituted_ticket_expedition_ts
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
182
_sendsii_enrich_recap_substituted
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
240
_sendsii_omit_recap_without_substituted
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
sendsii_run
10.13% covered (danger)
10.13%
39 / 385
0.00% covered (danger)
0.00%
0 / 1
9714.35
1<?php
2    require_once __DIR__.'/../legacy/ddbb/conx.php';
3    include_once __DIR__.'/../legacy/model/synchloader.php';
4    include_once __DIR__.'/../legacy/model/syncchanges.php';
5    include_once __DIR__.'/calcsii.php';
6    include_once __DIR__.'/validate.php';
7    include_once __DIR__.'/../regstr/ticketsii.php';
8    include_once __DIR__.'/../legacy/model/spec/ticketdata.php';
9    
10    // https://blog.devgenius.io/how-to-get-the-response-headers-with-curl-in-php-2173b10d4fc5
11    function _sii_headers_to_array($str) {
12        $headers = array();
13
14        $explode = explode( "\r\n" , $str );
15        for ($i=0; $i < count($explode); $i++){
16            // we dont care about the two \r\n lines at the end of the headers
17            if (strlen($explode[$i]) > 0){
18                $seppos = strpos($explode[$i], ":");
19                
20                // the headers start with HTTP status codes, which do not contain a colon so we can filter them out too
21                if ($seppos){
22                    $key = substr($explode[$i], 0, $seppos);
23                    $val = substr($explode[$i], $seppos + 1);
24                    $headers[strtolower($key)] = $val;
25                }
26            }
27        }
28
29        return $headers;
30    }
31
32    function _sii_fiscal_region($conn, &$place, &$region){
33        $retval = ApplicationError::Success;
34
35        if (success($retval)){
36            $results = [];
37            
38            $res = $conn->query('ADDRESS', [ 'objid' => $place['fiscal'] ], $results);
39            if ($res){
40                if (count($results) > 0){
41                    $formatted = $results[0]['formatted'];
42                }
43                else {
44                    addlog(__FILE__, LogLevel::Warning, "Place [".$place['objid']."] has not provided fiscal address");                    
45                    $retval = ApplicationError::Invalid;
46                }
47            }
48            else {
49                $retval = ApplicationError::Database;
50            }
51        }
52
53        if (success($retval)){
54            $parts = explode(',', $formatted);
55            $country = strtolower(trim($parts[count($parts)-1]));
56            $region = trim($parts[count($parts)-2]);
57            if (($country != 'españa') && ($country != 'spain')){
58                addlog(__FILE__, LogLevel::Warning, "Place [".$place['objid']."] has its fiscal address out of Spain (".$country.")");                    
59                $retval = ApplicationError::Invalid;
60            }
61            else {
62                $ridx = 0;
63
64                addlog(__FILE__, LogLevel::Verbose, "Place [".$place['objid']."] has its fiscal address in region - '".$region."'");
65                if ((stripos($region, 'Bizkaia') !== false) || (stripos($region, 'Vizcaya') !== false)){
66                    addlog(__FILE__, LogLevel::Information, "Applying 'Bizkaia' fiscal considerations");
67                    $ridx = AppConstants::SII_Sandbox() ? 0 : 1;
68                }                    
69
70                if ((stripos($region, 'Guipúzcoa') !== false) || (stripos($region, 'Guipuzcoa') !== false) || (stripos($region, 'Gipuzkoa') !== false)){
71                    addlog(__FILE__, LogLevel::Information, "Applying 'Gipuzkoa' fiscal considerations");
72                    $ridx =  AppConstants::SII_Sandbox() ? 0 : 2;
73                }                    
74
75                if ((stripos($region, 'Álava') !== false) || (stripos($region, 'Alava') !== false) || (stripos($region, 'Araba') !== false)){
76                    addlog(__FILE__, LogLevel::Information, "Applying 'Araba' fiscal considerations");
77                    $ridx =  AppConstants::SII_Sandbox() ? 0 : 3;;
78                }      
79
80                $region = [ 'general', 'bizkaia', 'gipuzkoa', 'araba' ][$ridx];
81            }
82        }
83
84        return $retval;
85    }
86
87    function _extract_wsdl_information($region, $action, &$url, &$nss){
88        $retval = ApplicationError::Success;
89
90        if (success($retval)){
91            /* 
92                Ficheros WSDL para las diferentes admisitraciones 
93                    España: https://sede.agenciatributaria.gob.es/static_files/Sede/Procedimiento_ayuda/G417/FicherosSuministros/V_1_1/WSDL/SuministroFactEmitidas.wsdl
94                    Bizkaia (sólo produccion): https://www.bizkaia.eus/ogasuna/sii/documentos/SuministroFactEmitidas.wsdl
95                    Guipuzkoa (sólo producción): https://egoitza.gipuzkoa.eus/ogasuna/sii/ficheros/v1.1/SuministroFactEmitidas.wsdl
96                    Araba (sólo producción): https://sii.araba.eus/documentos/SuministroFactEmitidas.wsdl 
97            */
98
99            $wsdl = [
100                'general' => 'https://sede.agenciatributaria.gob.es/static_files/Sede/Procedimiento_ayuda/G417/FicherosSuministros/V_1_1/WSDL/SuministroFactEmitidas.wsdl',
101                'bizkaia' => 'https://www.bizkaia.eus/ogasuna/sii/documentos/SuministroFactEmitidas.wsdl',
102                'gipuzkoa' => 'https://egoitza.gipuzkoa.eus/ogasuna/sii/ficheros/v1.1/SuministroFactEmitidas.wsdl',
103                'araba' => 'https://sii.araba.eus/documentos/SuministroFactEmitidas.wsdl '
104            ];
105
106            $wsdl_url = $wsdl[$region];
107        }
108
109        if (success($retval)){
110            $wsdl_raw = file_get_contents($wsdl_url);
111            if ($wsdl_raw === false){
112                addlog(__FILE__, LogLevel::Error, "Cannot read WSDL contents from '".$wsdl_url."'");
113                $retval = ApplicationError::NotFound;
114            }
115            else {
116                libxml_use_internal_errors(true);
117                $xml = simplexml_load_string($wsdl_raw);
118                if ($xml === false){
119                    print("Failed loading XML from WSDL file at '".$wsdl_url."'");
120                    foreach(libxml_get_errors() as $error) {
121                        print($error->message);
122                    }
123                }
124                else {
125                    $url = null;
126                    $avl = [];
127                    $nss = [];
128
129                    // get the target WSDL server URL for the action
130                    $ports = $xml->children('wsdl', true)->service->children('wsdl', true);
131                    foreach($ports as $node => $port){
132                        $name = $avl[] = (string)$port->attributes()->name;
133                        if ($name == $action){
134                            $url = (string)$port->children('soap', true)->attributes()->location;
135                        }
136                    }
137
138                    if ($url == null){
139                        addlog(__FILE__, LogLevel::Error, "Action [".$action." not found in WSDL schema. Available actions are [".PHP_EOL.print_r($avl, true).PHP_EOL);
140                        $retval = ApplicationError::NotFound;                    
141                    }
142
143                    // get all the availabe XSD schemas
144                    $namespaces = $xml->getDocNamespaces();
145                    foreach($namespaces as $namespace => $xsd){
146                        if (substr($xsd, -4) == '.xsd'){
147                            $nss[$namespace] = (string)$xsd;
148                        }
149                    }
150
151                    if (count(array_keys($nss)) == 0){
152                        addlog(__FILE__, LogLevel::Error, "No xsd files found within namespaces for '".$wsdl_url."'");
153                        $retval = ApplicationError::NotFound;                    
154                    }
155                }
156            }
157        }
158
159        return $retval;
160    }
161
162    /**
163     * Certificado PKCS#12 autorizado para presentar en representación (mismo criterio que VeriFactu).
164     * {@see AppConstants::certPath()} y contraseña {@see BUILD_CERT_PASSWORD} en entorno.
165     *
166     * @param-out string|null $certificatePem Certificado en PEM.
167     * @param-out string|null $privateKeyPem Clave privada en PEM.
168     */
169    function _sii_representation_certificate(&$certificatePem, &$privateKeyPem){
170        $retval = ApplicationError::Success;
171        $certificatePem = null;
172        $privateKeyPem = null;
173
174        $password = $_ENV['BUILD_CERT_PASSWORD'] ?? getenv('BUILD_CERT_PASSWORD');
175        if ($password === false || $password === null || $password === ''){
176            addlog(__FILE__, LogLevel::Error, 'BUILD_CERT_PASSWORD is required for SII (certificado de representación).');
177            return ApplicationError::Parameters;
178        }
179        $password = (string) $password;
180
181        $p12path = AppConstants::certPath();
182        if (!file_exists($p12path)){
183            addlog(__FILE__, LogLevel::Error, "Configured SII representation certificate file not found at '".$p12path."'");
184            return ApplicationError::NotFound;
185        }
186
187        $pkcs12 = @file_get_contents($p12path);
188        if (($pkcs12 === false) || ($pkcs12 === '')){
189            addlog(__FILE__, LogLevel::Error, "Could not read SII PKCS#12 certificate '".$p12path."'");
190            return ApplicationError::Certificate;
191        }
192
193        $certs = [];
194        if (!@openssl_pkcs12_read($pkcs12, $certs, $password)){
195            addlog(__FILE__, LogLevel::Error, 'Could not parse SII PKCS#12 with configured BUILD_CERT_PASSWORD.');
196            return ApplicationError::Certificate;
197        }
198
199        $certificatePem = array_key_exists('cert', $certs) ? (string) $certs['cert'] : null;
200        $privateKeyPem = array_key_exists('pkey', $certs) ? (string) $certs['pkey'] : null;
201        if (($certificatePem === null) || ($certificatePem === '') || ($privateKeyPem === null) || ($privateKeyPem === '')){
202            addlog(__FILE__, LogLevel::Error, 'Configured SII PKCS#12 has missing certificate or private key.');
203            $retval = ApplicationError::Certificate;
204        }
205        else {
206            addlog(__FILE__, LogLevel::Information, "SII TLS client cert: representation PKCS#12 [file: '".basename($p12path)."']");
207        }
208
209        return $retval;
210    }
211
212    function _send_sii_request($xml, $siiurl, $certval, $certkey, $action, &$result){
213        $retval = ApplicationError::Success;
214        $result = [
215            'headers' => [],
216            'content' => '',
217        ];
218
219        if (success($retval)){
220            $headers = array(
221                "Content-type: text/xml;charset=\"utf-8\"",
222                "SOAPAction: ".$action 
223            );
224
225            $curl = curl_init();
226            curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
227            curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
228            curl_setopt($curl, CURLOPT_URL, $siiurl);
229
230            if ($certval){
231                curl_setopt($curl, CURLOPT_SSLCERT, $certval);
232            }
233
234            if ($certkey){
235                curl_setopt($curl, CURLOPT_SSLKEY, $certkey);
236            }
237
238            curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_ANY);
239            curl_setopt($curl, CURLOPT_TIMEOUT, 60);
240            curl_setopt($curl, CURLOPT_POST, true);
241            curl_setopt($curl, CURLOPT_POSTFIELDS, $xml);   // the SOAP request
242
243            curl_setopt($curl, CURLOPT_HEADER, true);
244            curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
245
246            $hresponse = '';
247            $bresponse = '';
248            
249            $response = curl_exec($curl);
250            if ($response === false){
251                addlog(__FILE__, LogLevel::Error, "SII WSDL error: ". curl_error($curl). "(response code: ".curl_getinfo($curl, CURLINFO_RESPONSE_CODE).")");
252                $retval = ApplicationError::Generic;
253            } 
254            else {
255                $headersze = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
256                $hresponse = substr($response, 0, $headersze);
257                $bresponse = substr($response, $headersze);
258            }
259
260            curl_close($curl);        
261
262            if ($response !== false){
263                addlog(__FILE__, LogLevel::Debug, "SII XML sent: ".PHP_EOL.$xml.PHP_EOL); 
264
265                $result['headers'] = _sii_headers_to_array($hresponse);
266                $result['content'] = $bresponse;
267
268                addlog(__FILE__, LogLevel::Debug, "SII WSDL response (headers): ".PHP_EOL.print_r($result['headers'], true).PHP_EOL);
269                if (($result['headers']['content-length'] == 0) || (strpos($result['headers']['content-type'], 'text/xml') === false)) {
270                    $retval = ApplicationError::Generic;
271                    if (isset($result['headers']['location']) && (strpos($result['headers']['location'], "erro401") !== false)){
272                        $retval = ApplicationError::Certificate;
273                    }
274                }
275                else {
276                    addlog(__FILE__, LogLevel::Debug, "SII WSDL response (raw content): ".PHP_EOL.$result['content'].PHP_EOL);    
277                }
278            }
279        }
280
281        return $retval;
282    }
283
284    function _parse_sii_reponse_lines(&$response, &$csv, &$ticketsok, &$ticketsko){
285        $retval = ApplicationError::Success;
286
287        if (success($retval)){
288            if (isset($response->RespuestaLinea)){
289                $csv = (string)$response->CSV;
290                $lines = $response->RespuestaLinea;
291    
292                addlog(__FILE__, LogLevel::Information, "Returned CSV code: [".$response->CSV."] (".count($lines)." lines included)");
293                foreach($lines as $line){
294                    $data = $line->children('siiR', true);
295                    $ivce = (string)$data->IDFactura->children('sii', true)->NumSerieFacturaEmisor;
296                    $isok = (string)$data->EstadoRegistro == 'Correcto';
297                    
298                    addlog(__FILE__, $isok ? LogLevel::Information : LogLevel::Warning, "Invoice [".$ivce."] - (".($isok ? "OK" : "KO").")");
299                    if ($isok){
300                        array_push($ticketsok, $ivce);
301                    }
302                    else {
303                        array_push($ticketsko, $ivce);
304                    }
305                }
306            }    
307        }
308
309        return $retval;
310    }
311
312    function _parse_sii_onerror_lines(&$response, &$ticketsok, &$ticketsko){
313        $retval = ApplicationError::Success;
314
315        if (success($retval)){
316            if (isset($response->RespuestaLinea)){
317                $lines = $response->RespuestaLinea;
318    
319                foreach($lines as $line){
320                    $data = $line->children('siiR', true);
321                    $ivce = (string)$data->IDFactura->children('sii', true)->NumSerieFacturaEmisor;
322                    $isok = (string)$data->EstadoRegistro == 'Correcto';
323
324                    $errormesg = "Correcto";
325                    if (!$isok){
326                        $errormesg = "[".(string)$data->CodigoErrorRegistro."]: '".(string)$data->DescripcionErrorRegistro."'";
327                    }
328
329                    addlog(__FILE__, $isok ? LogLevel::Information : LogLevel::Error, "Invoice [".$ivce."] - (".($isok ? "OK" : "KO - ".$errormesg).")");
330                    if ($isok){
331                        array_push($ticketsok, [ $ivce, $errormesg ]);
332                    }
333                    else {
334                        array_push($ticketsko, [ $ivce, $errormesg ]);
335                    }
336                }
337            }    
338        }
339
340        return $retval;
341    }
342
343    function _parse_sii_response(&$response, &$csv, &$ticketsok, &$ticketsko){
344        $retval = ApplicationError::Success;
345
346        if (success($retval)){
347            $xmlstring = preg_replace('(<\?.*?\?>)', '', $response);    // remove XML declaration
348
349            libxml_use_internal_errors(true);
350            $xml = simplexml_load_string($xmlstring);
351            if ($xml === false){
352                addlog(__FILE__, LogLevel::Error, "Failed loading XML from response");
353                foreach(libxml_get_errors() as $error) {
354                    addlog(__FILE__, LogLevel::Error, "\t".$error->message);
355                }  
356
357                $retval = ApplicationError::Invalid;
358            }
359            else {
360                $body = $xml->children('env', true)->Body->children('env', true);
361                if ($body->Fault){
362                    $fault = $body->Fault->children();
363                    if ($fault){    // control fault error
364                        addlog(__FILE__, LogLevel::Error, "Error respose received from server:");
365    
366                        addlog(__FILE__, LogLevel::Error, "\t[faultcode] ".$fault->faultcode);
367                        addlog(__FILE__, LogLevel::Error, "\t[faultstring] ".$fault->faultstring);
368    
369                        $retval = ApplicationError::Invalid;
370                    }
371                }
372                else {
373                    $body = $xml->children('env', true)->Body->children('siiR', true);
374                    
375                    $response = null;
376
377                    // registration or modification of invoices
378                    if (isset($body->RespuestaLRFacturasEmitidas)){
379                        $response = $body->RespuestaLRFacturasEmitidas->children('siiR', true);
380
381                        if ($response){
382                            $success = in_array((string)$response->EstadoEnvio, ['Correcto', 'ParcialmenteCorrecto']);
383                            addlog(__FILE__, ($success) ? LogLevel::Information : LogLevel::Error, "Sent status: [".(string)$response->EstadoEnvio."]");
384
385                            if ($success){
386                                $retval = _parse_sii_reponse_lines($response, $csv, $ticketsok, $ticketsko);
387                            }
388                            else {
389                                _parse_sii_onerror_lines($response, $ticketsok, $ticketsko);
390                                $retval = ApplicationError::Generic;
391                            }
392                        }
393                    }
394
395                    // invoice cancellations
396                    if (isset($body->RespuestaLRBajaFacturasEmitidas)){
397                        $response = $body->RespuestaLRBajaFacturasEmitidas->children('siiR', true);
398                        if ($response){
399                            $success = in_array((string)$response->EstadoEnvio, ['Correcto', 'ParcialmenteCorrecto']);
400                            addlog(__FILE__, ($success) ? LogLevel::Information : LogLevel::Error, "Sent status: [".(string)$response->EstadoEnvio."]");
401
402                            if ($success){
403                                $retval = _parse_sii_reponse_lines($response, $csv, $ticketsok, $ticketsko);
404                            }
405                            else {
406                                $retval = ApplicationError::Generic;
407                            }
408                        }
409                    }
410                }
411            }
412        }
413
414        return $retval;
415    }
416
417    function update_ticketsii_info(\ConxHelper $conx, $transaction, $session, $siinfo, &$change, &$updates = null){
418        $retval = ApplicationError::Success;
419
420        $conn = $conx->tenant;
421        if ($conn === null) {
422            return ApplicationError::Database;
423        }
424
425        if (success($retval)){
426            // update information in the ticket change
427            if ($change['last']['ticket']){
428                if (!array_key_exists('objid', $change['last']) && ($change['last']['objid'] != null)){
429                    return $retval;     // nothing to do: there is no related change
430                }
431
432                $chgid = $change['last']['objid'];
433                $iteminfo = [
434                    'tckchg' => $chgid,
435                    'region' => $siinfo['region'],
436                    'sent' => $siinfo['sent'],
437                    'error' => null,
438                    'valid' => null
439                ];
440    
441                $ivce = $change['last']['series'].$change['last']['invoice'];
442                if (in_array($ivce, $siinfo['ok'])){
443                    $iteminfo['valid'] = $siinfo['control'];
444                }
445
446                if (in_array($ivce, $siinfo['ko'])){
447                    $iteminfo['error'] = $siinfo['control'];
448                }
449
450                $results = [];
451                $res = $conn->query('TICKETSII', [ 'tckchg' => $change['last']['objid']], $results);
452                if ($res){
453                    if (count($results) > 0){
454                        $iteminfo['objid'] = $results[0]['objid'];
455                        $res = $transaction->update('TICKETSII', $iteminfo);
456                    }
457                    else {
458                        $res = $transaction->insert('TICKETSII', $iteminfo, $iteminfo['objid']);
459                    }
460                }
461
462                if (!$res){
463                    $retval = ApplicationError::Database;
464                }
465                else if ($updates !== null && isset($iteminfo['objid'])){
466                    $change = [
467                        'table' => 'TICKETSII',
468                        'objid' => $iteminfo['objid'],
469                    ];
470
471                    $retval = push_change($conx, $change, $updates);
472                }
473            }
474
475            // update information in the account invoice
476            else {
477                $ivcid = $change['last']['invceac'];
478                $iteminfo = [
479                    'invceac' => $ivcid,
480                    'region' => $siinfo['region'],
481                    'sent' => $siinfo['sent'],
482                    'error' => null,
483                    'valid' => null
484                ];
485
486                $ivce = $change['last']['series'].$change['last']['invoice'];
487                if (in_array($ivce, $siinfo['ok'])){
488                    $iteminfo['valid'] = $siinfo['control'];
489                }
490
491                if (in_array($ivce, $siinfo['ko'])){
492                    $iteminfo['error'] = $siinfo['control'];
493                }
494
495                $results = [];
496                $res = $conn->query('TICKETSII', [ 'invceac' => $change['last']['invceac']], $results);
497                if ($res){
498                    if (count($results) > 0){
499                        $iteminfo['objid'] = $results[0]['objid'];
500                        $res = $transaction->update('TICKETSII', $iteminfo);
501                    }
502                    else {
503                        $res = $transaction->insert('TICKETSII', $iteminfo, $iteminfo['objid']);
504                    }
505                }
506
507                if (!$res){
508                    $retval = ApplicationError::Database;
509                }
510                else if ($updates !== null && isset($iteminfo['objid'])){
511                    $change = [
512                        'table' => 'TICKETSII',
513                        'objid' => $iteminfo['objid'],
514                    ];
515                    
516                    $retval = push_change($conx, $change, $updates);
517                }
518            }
519        }
520
521        if (success($retval)){
522            $retval = register_ticketsii_activity($conx, $session, $iteminfo);
523        }
524
525        if (success($retval) && isset($change['last']['ticket']) && ($change['last']['ticket'] != null)){
526            $retval = ticketdata_update_fiscal($conx, (int)$change['last']['ticket']);
527        }
528
529        return $retval;
530    }
531
532    function _sendsii_substituted_ticket_is_valid(?string $series, ?string $invoice){
533        $series = trim((string) $series);
534        $invoice = trim((string) $invoice);
535        if (($series === '') || ($invoice === '') || ($series === 'null') || ($invoice === 'null')){
536            return false;
537        }
538
539        $first = strtoupper($invoice[0]);
540
541        return ($first === 'S') || ($first === 'T');
542    }
543
544    function _sendsii_substituted_ticket_expedition_ts(\ConxHelper $conx, array $ticketRow){
545        $ts = 0;
546        $paidon = $ticketRow['paidon'] ?? null;
547        if ($paidon){
548            $paidTs = strtotime((string) $paidon);
549            if ($paidTs){
550                $ts = $paidTs;
551            }
552        }
553
554        $ticketId = $ticketRow['objid'] ?? null;
555        if ($ticketId !== null && $conx->tenant !== null){
556            $sql = "select TICKETCHANGE.created from TICKETCHANGE ";
557            $sql .= "where TICKETCHANGE.ticket = ".(int) $ticketId." ";
558            $sql .= "and TICKETCHANGE.reason in ('S','T','F') ";
559            $sql .= "order by TICKETCHANGE.objid desc limit 1";
560
561            $results = [];
562            $res = $conx->tenant->runsql(['TICKETCHANGE'], $sql, $results);
563            if ($res && count($results) > 0 && !empty($results[0]['created'])){
564                $chgTs = strtotime((string) $results[0]['created']);
565                if ($chgTs && ($chgTs > $ts)){
566                    $ts = $chgTs;
567                }
568            }
569        }
570
571        if (!$ts){
572            $updated = $ticketRow['updated'] ?? null;
573            if ($updated){
574                $updTs = strtotime((string) $updated);
575                if ($updTs){
576                    $ts = $updTs;
577                }
578            }
579        }
580
581        return $ts;
582    }
583
584    /**
585     * @param array<int, array<string, mixed>> $results
586     */
587    function _sendsii_enrich_recap_substituted(\ConxHelper $conx, array &$results){
588        if ($conx->tenant === null){
589            return;
590        }
591
592        $invceacIds = [];
593        foreach ($results as $change){
594            if (!empty($change['invceac'])){
595                $invceacIds[] = (int) $change['invceac'];
596            }
597        }
598
599        if (count($invceacIds) === 0){
600            return;
601        }
602
603        $invceacIds = array_values(array_unique($invceacIds));
604        $idList = implode(',', $invceacIds);
605
606        $sql = "select TICKET.objid, TICKET.invceac, TICKET.series, TICKET.invceno, TICKET.paidon, TICKET.updated ";
607        $sql .= "from TICKET ";
608        $sql .= "where TICKET.invceac in (".$idList.") ";
609        $sql .= "and TICKET.status not in ('DE', 'CC') ";
610        $sql .= "order by TICKET.invceac, TICKET.paidon";
611
612        addlog(__FILE__, LogLevel::Debug, "SII F3 recap substituted tickets query: ".PHP_EOL.$sql.PHP_EOL);
613
614        $tickets = [];
615        $res = $conx->tenant->runsql(['TICKET'], $sql, $tickets);
616        if (!$res){
617            addlog(__FILE__, LogLevel::Error, "SII F3 recap: failed to load substituted tickets for invceac [".$idList."]");
618            return;
619        }
620
621        $byInvceac = [];
622        foreach ($tickets as $row){
623            $ivcid = $row['invceac'];
624            if (!_sendsii_substituted_ticket_is_valid($row['series'] ?? null, $row['invceno'] ?? null)){
625                addlog(__FILE__, LogLevel::Warning, "SII F3 recap: ticket [".($row['objid'] ?? '?')."] ignored (invalid series/invceno for simplified S/T)");
626                continue;
627            }
628
629            $expeditionTs = _sendsii_substituted_ticket_expedition_ts($conx, $row);
630            if (!$expeditionTs){
631                addlog(__FILE__, LogLevel::Warning, "SII F3 recap: ticket [".($row['objid'] ?? '?')."] ignored (no expedition date)");
632                continue;
633            }
634
635            if (!array_key_exists($ivcid, $byInvceac)){
636                $byInvceac[$ivcid] = [];
637            }
638
639            $byInvceac[$ivcid][] = [
640                'series' => trim((string) $row['series']),
641                'invoice' => trim((string) $row['invceno']),
642                'created' => date('Y-m-d H:i:s', $expeditionTs),
643                '_ts' => $expeditionTs,
644            ];
645        }
646
647        foreach ($results as &$change){
648            if (empty($change['invceac'])){
649                continue;
650            }
651
652            $ivcid = $change['invceac'];
653            $entries = $byInvceac[$ivcid] ?? [];
654            $substituted = [];
655            $maxTs = 0;
656
657            foreach ($entries as $entry){
658                $substituted[] = [
659                    'series' => $entry['series'],
660                    'invoice' => $entry['invoice'],
661                    'created' => $entry['created'],
662                ];
663                if ($entry['_ts'] > $maxTs){
664                    $maxTs = $entry['_ts'];
665                }
666            }
667
668            $change['substituted'] = $substituted;
669            if ($maxTs > 0){
670                $change['substituted_period'] = [
671                    'year' => date('Y', $maxTs),
672                    'month' => date('m', $maxTs),
673                ];
674            }
675        } unset($change);
676    }
677
678    /**
679     * @param array<int, array<string, mixed>> $to_create
680     */
681    function _sendsii_omit_recap_without_substituted(array &$to_create, array &$ticketsko){
682        $valid = [];
683
684        foreach ($to_create as &$change){
685            $last = $change['last'];
686            if (($last['reason'] ?? '') !== 'A'){
687                $valid[] = &$change;
688                continue;
689            }
690
691            $substituted = $last['substituted'] ?? [];
692            if (count($substituted) > 0){
693                $valid[] = &$change;
694                continue;
695            }
696
697            $ivce = $last['series'].$last['invoice'];
698            addlog(__FILE__, LogLevel::Warning, "SII F3 recap [".$ivce."] omitted from batch: no valid substituted tickets");
699            $ticketsko[] = $ivce;
700        } unset($change);
701
702        $to_create = $valid;
703    }
704
705/**
706 * Envío por lotes SII (AEAT) para un periodo de auditoría cerrado.
707 *
708 * @return array{errorcode: int, ticketsok: mixed, ticketsko: mixed}
709 */
710function sendsii_run(\ConxHelper $conx, string $device, string $sessionToken, string $placeId, string $auditId): array
711{
712    $GLOBALS['__SII_ENRICH_CONX'] = $conx;
713
714    $retval = ApplicationError::Success;
715    $userid = null;
716    $sessionObjid = null;
717
718    $ticketsok = [];
719    $ticketsko = [];
720    $results = [];
721    $indexed = [];
722    $to_create = [];
723    $to_update = [];
724    $to_cancel = [];
725    $region = null;
726    $place = null;
727    $siiurl = null;
728    $siixsd = [];
729    $xml_post_string = '';
730    $result = [];
731    $certval = null;
732    $certkey = null;
733    $tmp_certval = null;
734    $tmp_certkey = null;
735    $ini_date = 0;
736    $end_date = '';
737    $csv = null;
738    /** @var array<string, mixed> $auditRow */
739    $auditRow = [];
740
741    if (success($retval)) {
742        if ($device === '') {
743            addlog(__FILE__, LogLevel::Error, 'No device provided on URL. Operation cancelled.');
744            $retval = ApplicationError::Parameters;
745        }
746    }
747
748    if (success($retval)) {
749        if ($sessionToken === '') {
750            addlog(__FILE__, LogLevel::Error, 'No session provided on URL. Operation cancelled.');
751            $retval = ApplicationError::Parameters;
752        } else {
753            $retval = check_session($conx, $sessionToken, $device, $userid, $sessionObjid);
754        }
755    }
756
757    if (success($retval)) {
758        if ($placeId === '' || $placeId === '0') {
759            addlog(__FILE__, LogLevel::Error, "Mandatory parameter 'place' not provided. Operation cancelled.");
760            $retval = ApplicationError::Parameters;
761        }
762    }
763
764    if (success($retval)) {
765        if ($auditId === '' || $auditId === '0') {
766            addlog(__FILE__, LogLevel::Error, "Mandatory parameter 'audit' not provided. Operation cancelled.");
767            $retval = ApplicationError::Parameters;
768        }
769    }
770
771    if (!success($retval)) {
772        unset($GLOBALS['__SII_ENRICH_CONX']);
773
774        return [
775            'errorcode' => $retval,
776            'ticketsok' => $ticketsok,
777            'ticketsko' => $ticketsko,
778        ];
779    }
780
781    $conx->tenant = $placeId;
782    $conn = $conx->getTenant();
783    if ($conn === null) {
784        addlog(__FILE__, LogLevel::Error, 'Tenant connection not available for audit SII.');
785        unset($GLOBALS['__SII_ENRICH_CONX']);
786        return [
787            'errorcode' => ApplicationError::Database,
788            'ticketsok' => $ticketsok,
789            'ticketsko' => $ticketsko,
790        ];
791    }
792
793    // get the audit information
794    if (success($retval)){
795        $results = [];
796        $res = $conn->query('AUDIT', [ 'objid' => $auditId ], $results);
797        if ($res){
798            if (count($results) > 0){
799                $auditRow = $results[0];
800            }
801            else {
802                addlog(__FILE__, LogLevel::Error, "No audit entry found with objid '".$auditId."'");
803                $retval = ApplicationError::NotFound;                
804            }
805        }
806        else {
807            $retval = ApplicationError::Database;
808        }
809    }
810
811    // get the audit period
812    if (success($retval)){
813        $sql = "select * from AUDIT where created < '".$auditRow['created']."' order by created desc limit 1";
814
815        $results = [];
816        $res = $conn->runsql([ 'AUDIT'], $sql, $results);
817        if ($res){
818            $ini_date = (count($results) > 0) ? $results[0]['created'] : 0;
819            $end_date = $auditRow['created'];
820
821            addlog(__FILE__, LogLevel::Information, "Audit period [".$ini_date." - ".$end_date."]");
822        }
823        else {
824            $retval = ApplicationError::Database;
825        }
826    }
827    
828    // get the taxrate from the place configuration
829    if (success($retval)){
830        $results = [];
831        $res = $conn->query('PLACEOPT', [ 'opkey' => 'optionTaxRate'], $results);
832        if ($res){
833            if (count($results) > 0){
834                $taxrate = $results[0]['value'];
835            }
836            else {
837                $taxrate = AppConstants::Taxes_Default;
838            }
839        }
840        else {
841            $retval = ApplicationError::Database;
842        }
843    }
844
845    // get all the ticket changes in the audit period 
846    if (success($retval)){
847        $sql = "select TICKETCHANGE.*, ";
848        $sql .= "concat('T', TICKETCHANGE.ticket) as toindex, ";  // to index all changes
849        $sql .= "TICKETSII.objid as ticketsii, ";                 // relation with the ticketsii  
850        $sql .= "TICKETSII.invceac as invceac ";                  // this will be null
851        $sql .= "from TICKETCHANGE ";
852        $sql .= "inner join TICKET on TICKETCHANGE.ticket = TICKET.objid ";
853        $sql .= "left join TICKETSII on TICKETSII.tckchg = TICKETCHANGE.objid and TICKETSII.valid is not null ";
854        $sql .= "where TICKET.invceac is NULL ";                  // discard paid to account tickets
855        $sql .= "and TICKET.paidon is not NULL ";               // paid tickets
856        $sql .= "and (";
857
858        // paid tickets in the audit period or
859        $sql .= "(TICKET.status not in ('DE', 'CC') and ";
860        $sql .= "TICKET.paidon > '".$ini_date."' and TICKET.paidon <= '".$end_date."') or ";
861
862        // cancelled tickets in the audit period
863        $sql .= "(TICKET.status in ('DE', 'CC') and ";
864        $sql .= "TICKET.updated > '".$ini_date."' and TICKET.updated <= '".$end_date."' and TICKETSII.objid is not null)";
865
866        $sql .= ") ";
867        $sql .= "order by TICKETCHANGE.objid desc";
868
869        addlog(__FILE__, LogLevel::Debug, "Get tickets query (1): ".PHP_EOL.$sql.PHP_EOL);
870
871        $results = [];
872        $res = $conn->runsql([ 'TICKET', 'TICKETCHANGE', 'TICKETSII' ], $sql, $results);
873        if (!$res){
874            $retval = ApplicationError::Database;
875        }
876    }
877
878    // ensure all tickets has al least one change (support for older versions)
879    if (success($retval)){
880        $indexed = [];
881
882        foreach($results as &$change){
883            if (!array_key_exists($change['toindex'], $indexed)){
884                $indexed[$change['toindex']] = [];
885            }
886            $indexed[$change['toindex']][] = &$change;
887        } unset($change);
888
889        $sql = "select TICKET.*, TICKETINVOICE.client as client ";
890        $sql .= "from TICKET ";
891        $sql .= "left join TICKETINVOICE on TICKETINVOICE.ticket = TICKET.objid "; 
892        $sql .= "where TICKET.paidon is not NULL ";               // discard paid to account tickets
893        $sql .= "and TICKET.invceac is NULL ";                  // paid tickets
894        $sql .= "and TICKET.status not in ('DE', 'CC') ";
895        $sql .= "and TICKET.paidon > '".$ini_date."' and TICKET.paidon <= '".$end_date."'";
896        
897        addlog(__FILE__, LogLevel::Debug, "Get all tickets query (2): ".PHP_EOL.$sql.PHP_EOL);
898
899        $tickets = [];
900        $res = $conn->runsql([ 'TICKET' ], $sql, $tickets);
901        if ($res){
902            foreach($tickets as &$ticket){
903                if (!array_key_exists($ticket['objid'], $indexed)){
904                    $total = $ticket['price'];
905                    $rate = $ticket['taxrate'] ? $ticket['taxrate'] : $taxrate;
906                    $taxes = $total / ((100 + $rate) / $rate);
907                    $base = $total - $taxes;
908
909                    $change = [
910                        'ticket' => $ticket['objid'],
911                        'invceac' => null,
912                        'toindex' => 'T'.$ticket['objid'], 
913                        'created' => $ticket['updated'],
914                        'prev' => null,
915                        'reason' => 'F',
916                        'series' => $ticket['series'],
917                        'invoice' => $ticket['invceno'],
918                        'total' => $total,
919                        'base' => $base,
920                        'rate' => $rate,
921                        'taxes' => $taxes,
922                        'client' => $ticket['client'],
923                        'ticketsii' => null
924                    ];
925
926                    $results[] = $change;   // add to the changes resultset
927                }
928            } unset($ticket);
929        }
930        else {
931            $retval = ApplicationError::Database;
932        }
933    }
934
935    // get the cummulative invoices as changes to be sent
936    if (success($retval)){
937        $sql = "select ACCOUNTINVOICE.*, PAYACCOUNT.client as client from ACCOUNTINVOICE ";
938        $sql .=" inner join PAYACCOUNT on ACCOUNTINVOICE.account = PAYACCOUNT.objid ";
939        $sql .= "where ACCOUNTINVOICE.created > '".$ini_date."' and ACCOUNTINVOICE.created <= '".$end_date."'";
940
941        addlog(__FILE__, LogLevel::Debug, "Get all account invoices query (3): ".PHP_EOL.$sql.PHP_EOL);
942
943        $invceacs = [];
944        $res = $conn->runsql([ 'ACCOUNTINVOICE', 'PAYACCOUNT' ], $sql, $invceacs);
945        if (!$res){
946            $retval = ApplicationError::Database;
947        }
948        else {
949            foreach($invceacs as &$invceac){
950                $total = $invceac['amount'];
951                $rate = $taxrate;
952                $taxes = $total / ((100 + $rate) / $rate);
953                $base = $total - $taxes;
954
955                $change = [
956                    'ticket' => null,   
957                    'invceac' => $invceac['objid'],
958                    'toindex' => 'A'.$invceac['objid'],
959                    'created' => $invceac['created'],
960                    'prev' => null,
961                    'reason' => 'A',
962                    'series' => $invceac['series'],
963                    'invoice' => $invceac['invoice'],
964                    'total' => $total,
965                    'base' => $base,
966                    'rate' => $rate,
967                    'taxes' => $taxes,
968                    'client' => $invceac['client'],
969                    'ticketsii' => null
970                ];                
971
972                $results[] = $change;   // add to the changes resultset                
973            } unset($invceac);
974        }
975    }
976    
977    // get all the changes client data
978    if (success($retval)){
979        $indexed = [];
980
981        $clntids = [];
982        foreach($results as &$change){
983            if ($change['client']){
984                // keep the client objid
985                if (!array_key_exists($change['client'], $clntids)){
986                    $clntids[] = $change['client'];
987                }
988
989                // keep the change for client
990                if (!array_key_exists($change['client'], $indexed)){
991                    $indexed[$change['client']] = [];
992                }
993                $indexed[$change['client']][] = &$change;
994            }
995        } unset($change);
996
997        if (count($clntids) > 0){
998            $clients = [];
999            $res = $conn->query('INVOICEBUSINESS', [ 'objid' => $clntids ], $clients);
1000            if ($res){
1001                foreach($clients as &$client){
1002                    foreach($indexed[$client['objid']] as &$change){
1003                        $change['client'] = &$client;
1004                    } unset($change);
1005                } unset($client);
1006            }
1007            else {
1008                $retval = ApplicationError::Database;
1009            }    
1010        }
1011    }
1012
1013    if (success($retval)){
1014        _sendsii_enrich_recap_substituted($conx, $results);
1015    }
1016
1017    // get the last change to be updated and the last change sent (if any)
1018    if (success($retval)){
1019        $indexed = [];
1020        foreach($results as &$change){
1021            if (!array_key_exists($change['toindex'], $indexed)){
1022                $indexed[$change['toindex']] = [
1023                    'sent' => null,   // sent to SII change
1024                    'last' => null,   // last recorded change
1025                ];
1026            }
1027
1028            // keep the last recorded ticket change
1029            if ($indexed[$change['toindex']]['last'] == null) {  
1030                $indexed[$change['toindex']]['last'] = &$change; 
1031            }
1032
1033            // keep the last sent to sii change
1034            if ($indexed[$change['toindex']]['sent'] == null) {  
1035                if ($change['ticketsii'] != null){
1036                    $indexed[$change['toindex']]['sent'] = &$change; 
1037                }
1038            }
1039        } unset($change);
1040    }
1041
1042    // separate the changes in (to create / to modify / to cancel)
1043    if (success($retval)){
1044        $to_create = [];
1045        $to_update = [];
1046        $to_cancel = [];
1047
1048        foreach($indexed as &$change){
1049            $last = &$change['last'];
1050            $sent = &$change['sent'];
1051
1052            if ($sent == null){     // not sent to SII
1053                if ($last['reason'] != 'C'){    
1054                    $to_create[] = &$change;
1055                }
1056            }
1057            else {                  // already sent to sii
1058                if ($last['objid'] != $sent['objid']){
1059                    if ($last['reason'] != 'C'){
1060                        $to_update[] = &$change; 
1061                    }   
1062                    else {
1063                        $to_cancel[] = &$change; 
1064                    }
1065                }
1066            }
1067        } unset($change);
1068    }
1069
1070    if (success($retval)){
1071        _sendsii_omit_recap_without_substituted($to_create, $ticketsko);
1072    }
1073
1074    // Log the ticket changes to be updated
1075    if (success($retval)){
1076        addlog(__FILE__, LogLevel::Information, "TICKETS TO CREATE (".count($to_create).")");
1077        foreach($to_create as &$change){
1078            addlog(__FILE__, LogLevel::Information, "\tTICKET [".$change['last']['series'].$change['last']['invoice']."] - updated: '".$change['last']['created']."'");
1079        } unset($change);
1080
1081        addlog(__FILE__, LogLevel::Information, "TICKETS TO UPDATE (".count($to_update).")");
1082        foreach($to_update as &$change){
1083            addlog(__FILE__, LogLevel::Information, "\tTICKET [".$change['last']['series'].$change['last']['invoice']."] - created: '".$change['sent']['created']."' - updated: '".$change['last']['created']."'");
1084        } unset($change);
1085
1086        addlog(__FILE__, LogLevel::Information, "TICKETS TO CANCEL (".count($to_cancel).")");
1087        foreach($to_cancel as &$change){
1088            addlog(__FILE__, LogLevel::Information, "\tTICKET [".$change['last']['series'].$change['last']['invoice']."] - created: '".$change['sent']['created']."' - updated: '".$change['last']['created']."'");
1089        } unset($change);
1090    }    
1091
1092    if (count($results) > 0){
1093        $action = "SuministroFactEmitidas";
1094        if (AppConstants::SII_Sandbox()){
1095            $action = "SuministroFactEmitidasPruebas";
1096        }
1097
1098        // obtain the place information
1099        if (success($retval)){
1100            $results = [];
1101            $res = $conx->global->query('PLACE', [ 'objid' => $placeId ], $results);
1102            if ($res){
1103                if (count($results) > 0){
1104                    $place = $results[0];
1105                }
1106                else {
1107                    addlog(__FILE__, LogLevel::Error, "Place not found for objid [".$placeId."]");
1108                    $retval = ApplicationError::NotFound;        
1109                }
1110            }
1111            else {
1112                $retval = ApplicationError::Database;
1113            }
1114        }
1115
1116        // Certificado de representación (único .p12 corporativo, igual que VeriFactu — no PEM por local)
1117        if (success($retval)){
1118            $certval = null;
1119            $certkey = null;
1120            $retval = _sii_representation_certificate($certval, $certkey);
1121        }
1122
1123        // obtain the place fiscal region (if located in Spain)
1124        if (success($retval)){
1125            $retval = _sii_fiscal_region($conn, $place, $region);
1126        }
1127
1128        // obtain server information, certificates and schemas
1129        if (success($retval)){
1130            $retval = _extract_wsdl_information($region, $action, $siiurl, $siixsd);
1131            if (success($retval)){
1132                addlog(__FILE__, LogLevel::Information, "Sending tickets (".$action.") to SII at '".$siiurl."'");            
1133                foreach($siixsd as $key => $xsd) {
1134                    addlog(__FILE__, LogLevel::Information, "\t".'- xmlns:ns'.$key.'="'.$xsd);
1135                }
1136            }
1137        }
1138
1139        // store the certificates in tempotal files to be used by CURL
1140        if (success($retval)){
1141            $tmp_certval = null;
1142            $tmp_certkey = null;
1143
1144            if ($certval){
1145                $tmp_certval = tempnam(sys_get_temp_dir(), 'c_');
1146                chmod($tmp_certval, 0600);
1147
1148                if (!file_put_contents($tmp_certval, $certval)){
1149                    addlog(__FILE__, LogLevel::Error, "Could not write certificate contents to '".$tmp_certval."' (".error_get_last()['message'].")");
1150                    $retval = ApplicationError::Generic;
1151                }
1152            }
1153
1154            if ($certkey){
1155                $tmp_certkey = tempnam(sys_get_temp_dir(), 'c_');
1156                chmod($tmp_certkey, 0600);
1157
1158                if (!file_put_contents($tmp_certkey, $certkey)){
1159                    addlog(__FILE__, LogLevel::Error, "Could not write certificate contents to '".$tmp_certkey."' (".error_get_last()['message'].")");
1160                    $retval = ApplicationError::Generic;                    
1161                }
1162            }
1163        }
1164
1165        if (count($to_create) > 0){
1166            $offset = 0;
1167            do {
1168                $to_send = array_slice($to_create, $offset, 9999);
1169                $offset += 9999;
1170
1171                if (count($to_send) > 0){
1172                    // build the SII XML content
1173                    if (success($retval)){
1174                        $retval = _build_sii_request_AltaFacturas($place, $to_send, $siixsd, $xml_post_string);
1175                        if (!success($retval)){
1176                            addlog(__FILE__, LogLevel::Error, "Error [".$retval."] received in sii request (AltaFacturas)");
1177                        }
1178                    }
1179
1180                    if (success($retval)){
1181                        $retval = ticketsii_validate_sii_soap_xml($xml_post_string, $siixsd, null);
1182                        if (!success($retval)){
1183                            addlog(__FILE__, LogLevel::Error, 'TICKETSII XSD validation failed before SII HTTP send (AltaFacturas)');
1184                        }
1185                    }
1186    
1187                    // send the XML to the SII server
1188                    if (success($retval)){
1189                        $retval = _send_sii_request($xml_post_string, $siiurl, $tmp_certval, $tmp_certkey, $action, $result);
1190                    }
1191
1192                    // parse the result XML
1193                    if (success($retval)){
1194                        $retval = _parse_sii_response($result['content'], $csv, $ticketsok, $ticketsko);
1195                    }
1196                }
1197            } while(count($to_send) > 0);
1198        }
1199
1200        if (count($to_update) > 0){
1201            $offset = 0;
1202            do {
1203                $to_send = array_slice($to_update, $offset, 9999);
1204                $offset += 9999;
1205
1206                if (count($to_send) > 0){
1207                    // build the SII XML content
1208                    if (success($retval)){
1209                        $retval = _build_sii_request_RectificacionFacturas($place, $to_send, $siixsd, $xml_post_string);
1210                        if (!success($retval)){
1211                            addlog(__FILE__, LogLevel::Error, "Error [".$retval."] received in sii request (RectificacionFacturas)");
1212                        }
1213                    }
1214
1215                    if (success($retval)){
1216                        $retval = ticketsii_validate_sii_soap_xml($xml_post_string, $siixsd, null);
1217                        if (!success($retval)){
1218                            addlog(__FILE__, LogLevel::Error, 'TICKETSII XSD validation failed before SII HTTP send (RectificacionFacturas)');
1219                        }
1220                    }
1221
1222                    // send the XML to the SII server
1223                    if (success($retval)){
1224                        $retval = _send_sii_request($xml_post_string, $siiurl, $tmp_certval, $tmp_certkey, $action, $result);
1225                    }
1226
1227                    // parse the result XML
1228                    if (success($retval)){
1229                        $retval = _parse_sii_response($result['content'], $csv, $ticketsok, $ticketsko);
1230                    }
1231                }
1232            } while(count($to_send) > 0);
1233        }
1234
1235        if (count($to_cancel) > 0){
1236            $offset = 0;
1237            do {
1238                $to_send = array_slice($to_cancel, $offset, 9999);
1239                $offset += 9999;
1240
1241                if (count($to_send) > 0){
1242                    // build the SII XML content
1243                    if (success($retval)){
1244                        $retval = _build_sii_request_BajaFacturas($place, $to_send, $siixsd, $xml_post_string);
1245                        if (!success($retval)){
1246                            addlog(__FILE__, LogLevel::Error, "Error [".$retval."] received in sii request (BajaFacturas)");
1247                        }
1248                    }
1249
1250                    if (success($retval)){
1251                        $retval = ticketsii_validate_sii_soap_xml($xml_post_string, $siixsd, null);
1252                        if (!success($retval)){
1253                            addlog(__FILE__, LogLevel::Error, 'TICKETSII XSD validation failed before SII HTTP send (BajaFacturas)');
1254                        }
1255                    }
1256
1257                    // send the XML to the SII server
1258                    if (success($retval)){
1259                        $retval = _send_sii_request($xml_post_string, $siiurl, $tmp_certval, $tmp_certkey, $action, $result);
1260                    }
1261
1262                    // parse the result XML
1263                    if (success($retval)){
1264                        $retval = _parse_sii_response($result['content'], $csv, $ticketsok, $ticketsko);
1265                    }
1266                }
1267            } while(count($to_send) > 0);
1268        }
1269    }
1270
1271    // remove the temporal certificate files
1272    if (isset($tmp_certval) && $tmp_certval){
1273        unlink($tmp_certval);
1274    }
1275
1276    if (isset($tmp_certkey) && $tmp_certkey){
1277        unlink($tmp_certkey);
1278    }        
1279
1280    if (success($retval)){
1281        addlog(__FILE__, LogLevel::Information, "OK tickets: ".PHP_EOL.print_r($ticketsok, true).PHP_EOL);          
1282        addlog(__FILE__, LogLevel::Information, "KO tickets: ".PHP_EOL.print_r($ticketsko, true).PHP_EOL);              
1283    }
1284
1285    // add SII information to the last ticket change
1286    if (success($retval) && ((count($ticketsok) > 0) || (count($ticketsko) > 0))) {
1287        $siiUpdates = [];
1288        $ok = $conx->transactional(function (\ConxHelper $conx) use ($sessionObjid, $region, $csv, $ticketsok, $ticketsko, $to_create, $to_update, $to_cancel, $placeId, &$retval, &$siiUpdates) {
1289            $transaction = $conx->trn('TICKETSII');
1290            if ($transaction === null) {
1291                $retval = ApplicationError::Database;
1292                return false;
1293            }
1294            $sentAt = gmdate('Y-m-d H:i:s');
1295            $siinfo = [
1296                'region' => $region,
1297                'sent' => $sentAt,
1298                'control' => $csv,
1299                'ok' => $ticketsok,
1300                'ko' => $ticketsko,
1301            ];
1302
1303            $all_changes = array_merge($to_create, $to_update, $to_cancel);
1304            foreach ($all_changes as &$change) {
1305                if (success($retval)) {
1306                    $retval = update_ticketsii_info($conx, $transaction, $sessionObjid, $siinfo, $change, $siiUpdates);
1307                }
1308            }
1309            unset($change);
1310
1311            if (success($retval)) {
1312                $retval = stage_changes($conx, $placeId, $siiUpdates);
1313            }
1314            return success($retval);
1315        });
1316        
1317        if (success($retval) && !$ok) {
1318            $retval = ApplicationError::Database;
1319            addlog(__FILE__, LogLevel::Error, "TICKETSII commit failed at COMMIT time; treating as write failure.");
1320        }
1321        
1322        if (success($retval)) {
1323            notify_changes($conx, $sessionObjid, $siiUpdates);
1324        }
1325    }
1326
1327    unset($GLOBALS['__SII_ENRICH_CONX']);
1328
1329    return [
1330        'errorcode' => $retval,
1331        'ticketsok' => $ticketsok,
1332        'ticketsko' => $ticketsko,
1333    ];
1334}