Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.83% covered (warning)
68.83%
159 / 231
28.57% covered (danger)
28.57%
2 / 7
CRAP
n/a
0 / 0
ticketsii_xsd_cache_path
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
ticketsii_xsd_cache_filename
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
ticketsii_xsd_absolute_url
62.86% covered (warning)
62.86%
22 / 35
0.00% covered (danger)
0.00%
0 / 1
34.60
ticketsii_xsd_cache_remote_xsd_recursive
48.15% covered (danger)
48.15%
39 / 81
0.00% covered (danger)
0.00%
0 / 1
205.78
ticketsii_xsd_cache_remote_xsd
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
ticketsii_validate_sii_soap_xml
79.10% covered (warning)
79.10%
53 / 67
0.00% covered (danger)
0.00%
0 / 1
32.17
ticketsii_xsd_pick_schema_url
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
18.07
1<?php
2
3declare(strict_types=1);
4
5/**
6 * TicketSII — validación XML frente a XSD del SII (AEAT / WSDL).
7 *
8 * Incluye:
9 * - Caché local bajo ticketsii/xsd/ (mismo criterio que VeriFactu en legacy): lectura si existe,
10 *   descarga + reescritura de import/include si no; fallback a URL remota para schemaValidate().
11 * - Validación del SOAP: fragmento Body + xmlns del sobre, elección de esquema y schemaValidate().
12 */
13
14/* -------------------------------------------------------------------------- */
15/* XSD cache                                                                   */
16/* -------------------------------------------------------------------------- */
17
18/**
19 * Carpeta local de XSD SII (AEAT / diputaciones según URL del WSDL).
20 */
21function ticketsii_xsd_cache_path(): string
22{
23    return __DIR__.'/xsd';
24}
25
26/**
27 * Nombre de fichero estable para una URL de XSD remota.
28 */
29function ticketsii_xsd_cache_filename(string $remoteUrl): string
30{
31    $urlPath = parse_url($remoteUrl, PHP_URL_PATH);
32    $filename = basename((string) $urlPath);
33    if ($filename === '') {
34        $filename = 'xsd_'.md5($remoteUrl).'.xsd';
35    }
36
37    return $filename;
38}
39
40/**
41 * Resuelve schemaLocation relativo a URL absoluta.
42 */
43function ticketsii_xsd_absolute_url(string $baseUrl, string $schemaLocation): ?string
44{
45    if (! is_string($schemaLocation) || ($schemaLocation === '')) {
46        return null;
47    }
48
49    if (preg_match('/^[a-z][a-z0-9+\-.]*:/i', $schemaLocation) === 1) {
50        return $schemaLocation;
51    }
52
53    if (strpos($schemaLocation, '//') === 0) {
54        $scheme = (string) parse_url($baseUrl, PHP_URL_SCHEME);
55        if ($scheme === '') {
56            $scheme = 'https';
57        }
58
59        return $scheme.':'.$schemaLocation;
60    }
61
62    $parts = parse_url($baseUrl);
63    if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
64        return null;
65    }
66
67    $basePath = isset($parts['path']) ? (string) $parts['path'] : '/';
68    $dirPath = preg_replace('/\/[^\/]*$/', '/', $basePath);
69    if (! is_string($dirPath) || ($dirPath === '')) {
70        $dirPath = '/';
71    }
72
73    if (strpos($schemaLocation, '/') === 0) {
74        $rawPath = $schemaLocation;
75    }
76    else {
77        $rawPath = $dirPath.$schemaLocation;
78    }
79
80    $segments = [];
81    foreach (explode('/', $rawPath) as $segment) {
82        if (($segment === '') || ($segment === '.')) {
83            continue;
84        }
85        if ($segment === '..') {
86            if (count($segments) > 0) {
87                array_pop($segments);
88            }
89            continue;
90        }
91        $segments[] = $segment;
92    }
93    unset($segment);
94
95    $normalizedPath = '/'.implode('/', $segments);
96    $absolute = $parts['scheme'].'://'.$parts['host'];
97    if (isset($parts['port'])) {
98        $absolute .= ':'.$parts['port'];
99    }
100    $absolute .= $normalizedPath;
101
102    return $absolute;
103}
104
105/**
106 * Descarga/recursive cache de un XSD y dependencias (import/include).
107 *
108 * @param array<string, string> $visited
109 */
110function ticketsii_xsd_cache_remote_xsd_recursive(string $remoteUrl, ?string &$localPath, array &$visited): int
111{
112    $retval = \ApplicationError::Success;
113    $cachePath = ticketsii_xsd_cache_path();
114
115    if (array_key_exists($remoteUrl, $visited)) {
116        $localPath = $visited[$remoteUrl];
117
118        return $retval;
119    }
120
121    if (! file_exists($cachePath) && ! mkdir($cachePath, 0775, true)) {
122        $error = error_get_last();
123        $reason = (is_array($error) && isset($error['message'])) ? ': '.$error['message'] : '';
124        \addlog(__FILE__, \LogLevel::Warning, "Could not create TICKETSII XSD cache path '".$cachePath."'".$reason);
125        $localPath = null;
126
127        return $retval;
128    }
129
130    $filename = ticketsii_xsd_cache_filename($remoteUrl);
131    $localPath = $cachePath.'/'.$filename;
132    $visited[$remoteUrl] = $localPath;
133
134    $hadLocalFile = file_exists($localPath);
135    $contents = false;
136    if ($hadLocalFile) {
137        $contents = @file_get_contents($localPath);
138        if ($contents === false) {
139            $error = error_get_last();
140            $reason = (is_array($error) && isset($error['message'])) ? ': '.$error['message'] : '';
141            \addlog(__FILE__, \LogLevel::Warning, "TICKETSII XSD exists in local cache but could not be read '".$localPath."'".$reason);
142
143            return $retval;
144        }
145    }
146    else {
147        $ctx = stream_context_create([
148            'http' => [ 'timeout' => 45 ],
149            'https' => [ 'timeout' => 45 ],
150        ]);
151        $contents = @file_get_contents($remoteUrl, false, $ctx);
152        if ($contents === false) {
153            $error = error_get_last();
154            $reason = (is_array($error) && isset($error['message'])) ? ': '.$error['message'] : '';
155            \addlog(__FILE__, \LogLevel::Warning, "Could not download TICKETSII XSD from '".$remoteUrl."'".$reason);
156
157            return $retval;
158        }
159        \addlog(__FILE__, \LogLevel::Information, "Downloaded TICKETSII XSD from external source [file: '".basename($localPath)."', url: '".$remoteUrl."']");
160    }
161
162    $updated = $contents;
163    libxml_use_internal_errors(true);
164    $dom = new \DOMDocument();
165    if ($dom->loadXML((string) $contents)) {
166        $imports = $dom->getElementsByTagNameNS('http://www.w3.org/2001/XMLSchema', 'import');
167        foreach ($imports as $node) {
168            if (! $node->hasAttribute('schemaLocation')) {
169                continue;
170            }
171            $schemaLocation = (string) $node->getAttribute('schemaLocation');
172            if ($schemaLocation === '') {
173                continue;
174            }
175            $absoluteUrl = ticketsii_xsd_absolute_url($remoteUrl, $schemaLocation);
176            if ($absoluteUrl === null) {
177                continue;
178            }
179            $childLocal = null;
180            ticketsii_xsd_cache_remote_xsd_recursive($absoluteUrl, $childLocal, $visited);
181            if (is_string($childLocal) && file_exists($childLocal)) {
182                $node->setAttribute('schemaLocation', basename($childLocal));
183            }
184        }
185        unset($node);
186
187        $includes = $dom->getElementsByTagNameNS('http://www.w3.org/2001/XMLSchema', 'include');
188        foreach ($includes as $node) {
189            if (! $node->hasAttribute('schemaLocation')) {
190                continue;
191            }
192            $schemaLocation = (string) $node->getAttribute('schemaLocation');
193            if ($schemaLocation === '') {
194                continue;
195            }
196            $absoluteUrl = ticketsii_xsd_absolute_url($remoteUrl, $schemaLocation);
197            if ($absoluteUrl === null) {
198                continue;
199            }
200            $childLocal = null;
201            ticketsii_xsd_cache_remote_xsd_recursive($absoluteUrl, $childLocal, $visited);
202            if (is_string($childLocal) && file_exists($childLocal)) {
203                $node->setAttribute('schemaLocation', basename($childLocal));
204            }
205        }
206        unset($node);
207
208        $saved = $dom->saveXML();
209        $updated = $saved !== false ? $saved : $contents;
210    }
211    libxml_clear_errors();
212
213    $mustWrite = (! $hadLocalFile) || ($updated !== $contents);
214    if ($mustWrite && (($updated === false) || (file_put_contents((string) $localPath, (string) $updated) === false))) {
215        $error = error_get_last();
216        $reason = (is_array($error) && isset($error['message'])) ? ': '.$error['message'] : '';
217        \addlog(__FILE__, \LogLevel::Warning, "Could not write TICKETSII XSD cache file '".$localPath."'".$reason);
218    }
219    elseif (! file_exists((string) $localPath)) {
220        \addlog(__FILE__, \LogLevel::Warning, "TICKETSII XSD cache file '".$localPath."' was not created.");
221    }
222    elseif ($mustWrite) {
223        \addlog(__FILE__, \LogLevel::Information, "TICKETSII XSD cached in '".$localPath."'");
224    }
225
226    return $retval;
227}
228
229/**
230 * Asegura XSD disponible en caché local; si no, deja URL remota para que libxml intente cargarla.
231 */
232function ticketsii_xsd_cache_remote_xsd(string $remoteUrl, string &$xsdSource): int
233{
234    $retval = \ApplicationError::Success;
235    $xsdSource = $remoteUrl;
236    $localPath = null;
237    $visited = [];
238    ticketsii_xsd_cache_remote_xsd_recursive($remoteUrl, $localPath, $visited);
239    if (is_string($localPath) && file_exists($localPath)) {
240        $xsdSource = $localPath;
241    }
242
243    return $retval;
244}
245
246/* -------------------------------------------------------------------------- */
247/* XSD validation                                                              */
248/* -------------------------------------------------------------------------- */
249
250/**
251 * @param array<string, string> $siixsd Mapa prefijo → URI de esquema (.xsd) del WSDL
252 * @param \Psr\Log\LoggerInterface|null $logger Si se pasa, se duplica el mensaje vía PSR (p. ej. async)
253 */
254function ticketsii_validate_sii_soap_xml(string $soapXml, array $siixsd, ?\Psr\Log\LoggerInterface $logger = null): int
255{
256    $retval = \ApplicationError::Success;
257
258    $log = static function (string $msg, string $psrLevel = \Psr\Log\LogLevel::INFO) use ($logger): void {
259        $legacy = \LogLevel::Information;
260        if (in_array($psrLevel, [ \Psr\Log\LogLevel::ERROR, \Psr\Log\LogLevel::CRITICAL, \Psr\Log\LogLevel::ALERT, \Psr\Log\LogLevel::EMERGENCY ], true)) {
261            $legacy = \LogLevel::Error;
262        }
263        elseif (in_array($psrLevel, [ \Psr\Log\LogLevel::WARNING, \Psr\Log\LogLevel::NOTICE ], true)) {
264            $legacy = \LogLevel::Warning;
265        }
266        \addlog(__FILE__, $legacy, 'TICKETSII XSD '.$msg);
267        if ($logger !== null) {
268            $logger->log($psrLevel, 'TICKETSII XSD '.$msg);
269        }
270    };
271
272    libxml_use_internal_errors(true);
273    libxml_clear_errors();
274
275    $dom = new \DOMDocument();
276    if (@$dom->loadXML($soapXml) !== true) {
277        $retval = \ApplicationError::Invalid;
278        $log('parse error: SOAP XML is not well-formed', \Psr\Log\LogLevel::ERROR);
279        foreach (libxml_get_errors() as $err) {
280            $log('libxml: '.trim($err->message), \Psr\Log\LogLevel::ERROR);
281        }
282        libxml_clear_errors();
283
284        return $retval;
285    }
286
287    $xpath = new \DOMXPath($dom);
288    $xpath->registerNamespace('soap', 'http://schemas.xmlsoap.org/soap/envelope/');
289    $bodyList = $xpath->query('//soap:Body');
290    $body = ($bodyList !== false && $bodyList->length > 0) ? $bodyList->item(0) : null;
291    $payloadRoot = null;
292    if ($body !== null) {
293        foreach ($body->childNodes as $child) {
294            if ($child->nodeType === XML_ELEMENT_NODE) {
295                $payloadRoot = $child;
296                break;
297            }
298        }
299    }
300    if ($body === null || $payloadRoot === null) {
301        $retval = \ApplicationError::Invalid;
302        $log('SOAP Body is missing or empty', \Psr\Log\LogLevel::ERROR);
303
304        return $retval;
305    }
306
307    $envelope = $dom->documentElement;
308
309    $fragment = new \DOMDocument('1.0', 'UTF-8');
310    $fragment->preserveWhiteSpace = true;
311    $imported = $fragment->importNode($payloadRoot, true);
312    if ($imported === false) {
313        $retval = \ApplicationError::Invalid;
314        $log('could not import SOAP body payload for schema validation', \Psr\Log\LogLevel::ERROR);
315
316        return $retval;
317    }
318    $fragment->appendChild($imported);
319
320    if ($envelope instanceof \DOMElement && $envelope->hasAttributes()) {
321        foreach ($envelope->attributes as $attr) {
322            if ($attr->nodeName === '' || ! str_starts_with($attr->nodeName, 'xmlns')) {
323                continue;
324            }
325            if ($imported instanceof \DOMElement && ! $imported->hasAttribute($attr->nodeName)) {
326                $imported->setAttribute($attr->nodeName, $attr->nodeValue);
327            }
328        }
329    }
330
331    $rootLocal = ($imported instanceof \DOMElement) ? $imported->localName : '';
332    $schemaUrl = ticketsii_xsd_pick_schema_url($siixsd, $rootLocal);
333    if ($schemaUrl === null || $schemaUrl === '') {
334        $log('no .xsd candidate in WSDL namespace map; skipping schemaValidate', \Psr\Log\LogLevel::WARNING);
335
336        return \ApplicationError::Success;
337    }
338
339    $xsdSource = $schemaUrl;
340    ticketsii_xsd_cache_remote_xsd($schemaUrl, $xsdSource);
341
342    libxml_clear_errors();
343    $ok = @$fragment->schemaValidate($xsdSource);
344    if ($ok !== true) {
345        $retval = \ApplicationError::Invalid;
346        $log("schemaValidate failed [source: '".$xsdSource."', remote: '".$schemaUrl."']", \Psr\Log\LogLevel::ERROR);
347        foreach (libxml_get_errors() as $err) {
348            $log('libxml: '.trim($err->message), \Psr\Log\LogLevel::ERROR);
349        }
350    }
351    else {
352        $log("schemaValidate OK [source: '".$xsdSource."']");
353    }
354    libxml_clear_errors();
355
356    return $retval;
357}
358
359/**
360 * @param array<string, string> $siixsd
361 */
362function ticketsii_xsd_pick_schema_url(array $siixsd, string $soapBodyRootLocalName = ''): ?string
363{
364    $candidates = [];
365    foreach ($siixsd as $url) {
366        $url = trim((string) $url);
367        if ($url !== '' && str_ends_with(strtolower($url), '.xsd')) {
368            $candidates[] = $url;
369        }
370    }
371    $candidates = array_values(array_unique($candidates));
372    if ($candidates === []) {
373        return null;
374    }
375
376    $root = strtolower($soapBodyRootLocalName);
377
378    $score = static function (string $u) use ($root): int {
379        $l = strtolower($u);
380        $n = 0;
381        if ($root !== '' && str_contains($root, 'baja')) {
382            if (str_contains($l, 'baja')) {
383                $n += 12;
384            }
385            if (str_contains($l, 'bajalr')) {
386                $n += 4;
387            }
388        }
389        elseif ($root !== '' && str_contains($root, 'suministrolrfacturasemitidas')) {
390            if (str_contains($l, 'baja')) {
391                $n -= 8;
392            }
393            if (str_contains($l, 'suministrolr') || str_contains($l, 'factemitidas') || str_contains($l, 'lrfacturas')) {
394                $n += 10;
395            }
396        }
397        if (str_contains($l, 'lr')) {
398            $n += 3;
399        }
400        if (str_contains($l, 'suministro')) {
401            $n += 2;
402        }
403        if (str_contains($l, 'fact')) {
404            $n += 1;
405        }
406
407        return $n;
408    };
409
410    usort($candidates, static function (string $a, string $b) use ($score): int {
411        return $score($b) <=> $score($a);
412    });
413
414    return $candidates[0];
415}