Completed
Push — master ( 74e0f6...d04c0f )
by Carlos C
03:37 queued 11s
created

Cleaner   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 334
Duplicated Lines 0 %

Test Coverage

Coverage 97.39%

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 131
c 9
b 0
f 0
dl 0
loc 334
ccs 149
cts 153
cp 0.9739
rs 5.5199
wmc 56

20 Methods

Rating   Name   Duplication   Size   Complexity  
A removeAddenda() 0 10 3
A removeNonSatNSNodes() 0 12 4
A removeUnusedNamespaces() 0 16 5
A load() 0 12 3
A removeNonSatNSNode() 0 4 2
A __construct() 0 4 2
A removeIncompleteSchemaLocations() 0 7 3
A retrieveDocument() 0 3 1
A removeIncompleteSchemaLocation() 0 13 3
A obtainXsiSchemaLocations() 0 8 2
A collapseComprobanteComplemento() 0 18 4
A isVersionAllowed() 0 3 1
A retrieveXml() 0 3 1
B removeNonSatNSschemaLocation() 0 25 7
A xpathQuery() 0 14 3
A clean() 0 8 1
A dom() 0 6 2
A staticClean() 0 5 1
A isNameSpaceAllowed() 0 20 5
A removeNonSatNSschemaLocations() 0 7 3

How to fix   Complexity   

Complex Class

Complex classes like Cleaner often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Cleaner, and based on these observations, apply Extract Interface, too.

1
<?php
2
namespace CfdiUtils\Cleaner;
3
4
use CfdiUtils\Cfdi;
5
use CfdiUtils\Utils\Xml;
6
use DOMDocument;
7
use DOMElement;
8
use DOMNode;
9
use DOMNodeList;
10
use DOMXPath;
11
12
/**
13
 * Class to clean CFDI and avoid bad common practices.
14
 *
15
 * Strictly speaking, CFDI must accomplish all XML rules, including that any other
16
 * XML element must be isolated in its own namespace and follow their own XSD rules.
17
 *
18
 * The common practice (allowed by SAT) is that the CFDI is created, signed and
19
 * some nodes are attached after sign, some of them does not follow the XML standard.
20
 *
21
 * This is why it's better to clear Comprobante/Addenda and remove unused namespaces
22
 */
23
class Cleaner
24
{
25
    /** @var DOMDocument|null */
26
    protected $dom;
27
28 14
    public function __construct(string $content)
29
    {
30 14
        if ('' !== $content) {
31 11
            $this->load($content);
32
        }
33 11
    }
34
35
    /**
36
     * Method to clean content and return the result
37
     * If an error occurs, an exception is thrown
38
     * @param string $content
39
     * @return string
40
     */
41 1
    public static function staticClean($content): string
42
    {
43 1
        $cleaner = new self($content);
44 1
        $cleaner->clean();
45 1
        return $cleaner->retrieveXml();
46
    }
47
48
    /**
49
     * Check if the CFDI version is complatible to this class
50
     * @param string $version
51
     * @return bool
52
     */
53 10
    public static function isVersionAllowed(string $version): bool
54
    {
55 10
        return in_array($version, ['3.2', '3.3']);
56
    }
57
58
    /**
59
     * Check if a given namespace is allowed (must not be removed from CFDI)
60
     * @param string $namespace
61
     * @return bool
62
     */
63 2
    public static function isNameSpaceAllowed(string $namespace): bool
64
    {
65
        $fixedNS = [
66 2
            'http://www.w3.org/2001/XMLSchema-instance',
67
            'http://www.w3.org/XML/1998/namespace',
68
        ];
69 2
        foreach ($fixedNS as $ns) {
70 2
            if (0 === strcasecmp($ns, $namespace)) {
71 1
                return true;
72
            }
73
        }
74
        $willcardNS = [
75 2
            'http://www.sat.gob.mx/',
76
        ];
77 2
        foreach ($willcardNS as $ns) {
78 2
            if (0 === strpos($namespace, $ns)) {
79 1
                return true;
80
            }
81
        }
82 2
        return false;
83
    }
84
85
    /**
86
     * Apply all removals (Addenda, Non SAT Nodes and Non SAT namespaces)
87
     * @return void
88
     */
89 1
    public function clean()
90
    {
91 1
        $this->removeAddenda();
92 1
        $this->removeIncompleteSchemaLocations();
93 1
        $this->removeNonSatNSNodes();
94 1
        $this->removeNonSatNSschemaLocations();
95 1
        $this->removeUnusedNamespaces();
96 1
        $this->collapseComprobanteComplemento();
97 1
    }
98
99
    /**
100
     * Load the string content as a CFDI
101
     * This is exposed to reuse the current object instead of create a new instance
102
     *
103
     * @param string $content
104
     *
105
     * @throws CleanerException when the content is not valid xml
106
     * @throws CleanerException when the document does not use the namespace http://www.sat.gob.mx/cfd/3
107
     * @throws CleanerException when cannot find a Comprobante version (or Version) attribute
108
     * @throws CleanerException when the version is not compatible
109
     *
110
     * @return void
111
     */
112 13
    public function load(string $content)
113
    {
114
        try {
115 13
            $cfdi = Cfdi::newFromString($content);
116 3
        } catch (\Throwable $exception) {
117 3
            throw new CleanerException($exception->getMessage(), $exception->getCode(), $exception->getPrevious());
118
        }
119 10
        $version = $cfdi->getVersion();
120 10
        if (! $this->isVersionAllowed($version)) {
121 2
            throw new CleanerException("The CFDI version '$version' is not allowed");
122
        }
123 8
        $this->dom = $cfdi->getDocument();
124 8
    }
125
126
    /**
127
     * Get the XML content of the CFDI
128
     *
129
     * @return string
130
     */
131 4
    public function retrieveXml(): string
132
    {
133 4
        return $this->dom()->saveXML();
134
    }
135
136
    /**
137
     * Get a clone of the XML DOM Document of the CFDI
138
     *
139
     * @return DOMDocument
140
     */
141 1
    public function retrieveDocument(): DOMDocument
142
    {
143 1
        return clone $this->dom();
144
    }
145
146
    /**
147
     * Procedure to remove the Comprobante/Addenda node
148
     *
149
     * @return void
150
     */
151 1
    public function removeAddenda()
152
    {
153 1
        $query = '/cfdi:Comprobante/cfdi:Addenda';
154 1
        $addendas = $this->xpathQuery($query);
155 1
        for ($i = 0; $i < $addendas->length; $i++) {
156 1
            $addenda = $addendas->item($i);
157 1
            if (null === $addenda) {
158
                continue;
159
            }
160 1
            $addenda->parentNode->removeChild($addenda);
161
        }
162 1
    }
163
164
    /**
165
     * Procedure to drop schemaLocations where second part does not ends with '.xsd'
166
     *
167
     * @return void
168
     */
169 1
    public function removeIncompleteSchemaLocations()
170
    {
171 1
        $schemaLocations = $this->obtainXsiSchemaLocations();
172 1
        for ($s = 0; $s < $schemaLocations->length; $s++) {
173 1
            $element = $schemaLocations->item($s);
174 1
            if (null !== $element) {
175 1
                $element->nodeValue = $this->removeIncompleteSchemaLocation($element->nodeValue);
176
            }
177
        }
178 1
    }
179
180 2
    public function removeIncompleteSchemaLocation(string $source): string
181
    {
182 2
        $components = array_values(array_filter(array_map('trim', explode(' ', $source))));
183 2
        $length = count($components);
184 2
        for ($c = 0; $c < $length; $c = $c + 1) {
185 2
            $xsd = $components[$c + 1] ?? '';
186 2
            if ((0 === strcasecmp('.xsd', substr($xsd, -4, 4)))) {
187 2
                $c = $c + 1;
188 2
                continue;
189
            }
190 2
            $components[$c] = '';
191
        }
192 2
        return strval(implode(' ', array_filter($components)));
193
    }
194
195
    /**
196
     * Procedure to drop schemaLocations that are not allowed
197
     * If the schemaLocation is empty then remove the attribute
198
     *
199
     * @return void
200
     */
201 3
    public function removeNonSatNSschemaLocations()
202
    {
203 3
        $schemaLocations = $this->obtainXsiSchemaLocations();
204 3
        for ($s = 0; $s < $schemaLocations->length; $s++) {
205 3
            $element = $schemaLocations->item($s);
206 3
            if (null !== $element) {
207 3
                $this->removeNonSatNSschemaLocation($element);
208
            }
209
        }
210 2
    }
211
212
    /**
213
     * @param DOMNode $schemaLocation This is the attribute
214
     * @return void
215
     */
216 3
    private function removeNonSatNSschemaLocation(DOMNode $schemaLocation)
217
    {
218 3
        $source = $schemaLocation->nodeValue;
219 3
        $parts = array_values(array_filter(explode(' ', $source)));
220 3
        $partsCount = count($parts);
221 3
        if (0 !== $partsCount % 2) {
222 1
            throw new CleanerException("The schemaLocation value '" . $source . "' must have even number of URIs");
223
        }
224 2
        $modified = '';
225 2
        for ($k = 0; $k < $partsCount; $k = $k + 2) {
226 2
            if (! $this->isNameSpaceAllowed($parts[$k])) {
227 2
                continue;
228
            }
229 1
            $modified .= $parts[$k] . ' ' . $parts[$k + 1] . ' ';
230
        }
231 2
        $modified = rtrim($modified, ' ');
232 2
        if ($source == $modified) {
233 1
            return;
234
        }
235 2
        if ('' !== $modified) {
236 1
            $schemaLocation->nodeValue = $modified;
237
        } else {
238 1
            $parentElement = $schemaLocation->parentNode;
239 1
            if ($parentElement instanceof DOMElement) {
240 1
                $parentElement->removeAttributeNS($schemaLocation->namespaceURI, $schemaLocation->localName);
241
            }
242
        }
243 2
    }
244
245
    /**
246
     * Procedure to remove all nodes that are not from an allowed namespace
247
     * @return void
248
     */
249 1
    public function removeNonSatNSNodes()
250
    {
251 1
        $nss = [];
252 1
        foreach ($this->xpathQuery('//namespace::*') as $node) {
253 1
            $namespace = $node->nodeValue;
254 1
            if ($this->isNameSpaceAllowed($namespace)) {
255 1
                continue;
256
            }
257 1
            $nss[] = $namespace;
258
        }
259 1
        foreach ($nss as $namespace) {
260 1
            $this->removeNonSatNSNode($namespace);
261
        }
262 1
    }
263
264
    /**
265
     * Procedure to remove all nodes from an specific namespace
266
     * @param string $namespace
267
     * @return void
268
     */
269 1
    private function removeNonSatNSNode(string $namespace)
270
    {
271 1
        foreach ($this->dom()->getElementsByTagNameNS($namespace, '*') as $children) {
272 1
            $children->parentNode->removeChild($children);
273
        }
274 1
    }
275
276
    /**
277
     * Procedure to remove not allowed xmlns definitions
278
     * @return void
279
     */
280 1
    public function removeUnusedNamespaces()
281
    {
282 1
        $nss = [];
283 1
        $dom = $this->dom();
284 1
        foreach ($this->xpathQuery('//namespace::*') as $node) {
285 1
            $namespace = $node->nodeValue;
286 1
            if (! $namespace || $this->isNameSpaceAllowed($namespace)) {
287 1
                continue;
288
            }
289 1
            $prefix = $dom->lookupPrefix($namespace);
290 1
            $nss[$prefix] = $namespace;
291
        }
292 1
        $nss = array_unique($nss);
293 1
        $documentElement = Xml::documentElement($dom);
294 1
        foreach ($nss as $prefix => $namespace) {
295 1
            $documentElement->removeAttributeNS($namespace, $prefix);
296
        }
297 1
    }
298
299 3
    private function obtainXsiSchemaLocations(): DOMNodeList
300
    {
301
        // Do not assume that prefix for http://www.w3.org/2001/XMLSchema-instance is "xsi"
302 3
        $xsi = $this->dom()->lookupPrefix('http://www.w3.org/2001/XMLSchema-instance');
303 3
        if (! $xsi) {
304
            return new DOMNodeList();
305
        }
306 3
        return $this->xpathQuery("//@$xsi:schemaLocation");
307
    }
308
309
    /**
310
     * Helper function to perform a XPath query using an element (or root element)
311
     * @param string $query
312
     * @param DOMNode|null $element
313
     * @return DOMNodeList
314
     */
315 5
    private function xpathQuery(string $query, DOMNode $element = null): DOMNodeList
316
    {
317 5
        if (null === $element) {
318 3
            $document = $this->dom();
319 3
            $element = Xml::documentElement($document);
320
        } else {
321 3
            $document = Xml::ownerDocument($element);
322
        }
323
        /** @var DOMNodeList|false $nodelist phpstan does not know that query can return false */
324 5
        $nodelist = (new DOMXPath($document))->query($query, $element);
325 5
        if (false === $nodelist) {
326
            $nodelist = new DOMNodeList();
327
        }
328 5
        return $nodelist;
329
    }
330
331 6
    private function dom(): DOMDocument
332
    {
333 6
        if (null === $this->dom) {
334
            throw new \LogicException('No document has been loaded');
335
        }
336 6
        return $this->dom;
337
    }
338
339 3
    public function collapseComprobanteComplemento()
340
    {
341 3
        $comprobante = Xml::documentElement($this->dom());
342 3
        $complementos = $this->xpathQuery('./cfdi:Complemento', $comprobante);
343 3
        if ($complementos->length < 2) {
344 1
            return; // nothing to do, there are less than 2 complemento
345
        }
346
        /** @var DOMNode $first */
347 2
        $first = $complementos->item(0);
348 2
        for ($i = 1; $i < $complementos->length; $i++) { // iterate over all extra children
349
            /** @var DOMNode $extra */
350 2
            $extra = $complementos->item($i);
351 2
            $comprobante->removeChild($extra); // remove extra child from parent
352 2
            while ($extra->childNodes->length > 0) { // append extra child contents into first child
353
                /** @var DOMNode $child */
354 2
                $child = $extra->childNodes->item(0);
355 2
                $extra->removeChild($child);
356 2
                $first->appendChild($child);
357
            }
358
        }
359 2
    }
360
}
361