diff --git a/.gitattributes b/.gitattributes index 5b5ddb3..1f0f9eb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,7 @@ * text=auto # Do not put this files on a distribution package (by .gitignore) +/tools/ export-ignore /vendor/ export-ignore /composer.lock export-ignore .phpunit.result.cache export-ignore @@ -9,6 +10,7 @@ # Do not put this files on a distribution package /build/ export-ignore /tests/ export-ignore +/.phive/ export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.php_cs.dist export-ignore diff --git a/.gitignore b/.gitignore index 840d101..d69384a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # do not include this files on git -/vendor +/tools/ +/vendor/ /composer.lock .phpunit.result.cache diff --git a/.phive/phars.xml b/.phive/phars.xml new file mode 100644 index 0000000..a4a614e --- /dev/null +++ b/.phive/phars.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.php_cs.dist b/.php_cs.dist index 5ae8296..ef6a952 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -46,6 +46,6 @@ return PhpCsFixer\Config::create() ->setFinder( PhpCsFixer\Finder::create() ->in(__DIR__) - ->exclude(['vendor', 'build']) + ->exclude(['tools', 'vendor', 'build']) ) ; diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 8a08383..6ee0f9b 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -7,16 +7,15 @@ filter: build: dependencies: override: - - composer self-update --no-interaction --no-progress - - composer remove squizlabs/php_codesniffer friendsofphp/php-cs-fixer phpstan/phpstan --dev --no-interaction --no-progress --no-update - - composer install --no-interaction --prefer-dist + - composer update --no-interaction --prefer-dist nodes: - analysis: # see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/ - project_setup: {override: true} + analysis: # see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/ + project_setup: + override: true tests: override: - php-scrutinizer-run --enable-security-analysis - - command: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover + - command: vendor/bin/phpunit --verbose --testdox --coverage-clover=coverage.clover coverage: file: coverage.clover format: clover diff --git a/.travis.yml b/.travis.yml index 7ad80ed..17ed2a2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,21 +6,21 @@ php: ["7.3", "7.4", "8.0"] cache: - directories: - $HOME/.composer - -env: - global: - - PHP_CS_FIXER_IGNORE_ENV=yes + - $HOME/.phive before_script: - phpenv config-rm xdebug.ini || true - travis_retry composer self-update --no-interaction --2 - travis_retry composer upgrade --no-interaction --prefer-dist + - wget -O phive https://phar.io/releases/phive.phar && chmod +x phive + - ./phive install --force-accept-unsigned --trust-gpg-keys 0x4AA394086372C20A,0x31C7E470E2138192,0xE82B2FB314E9906E,0xCF1A108D0E7AE720,0xC5095986493B4AA0 script: - - vendor/bin/php-cs-fixer fix --dry-run --verbose - - vendor/bin/phpcbf --colors -sp src/ tests/ bin/ + - PHP_CS_FIXER_IGNORE_ENV=yes tools/php-cs-fixer fix --dry-run --verbose + - tools/phpcs --colors -sp src/ tests/ bin/ - vendor/bin/phpunit --testdox --verbose - - vendor/bin/phpstan analyse --no-progress --verbose --level max src/ tests/ bin/ + - tools/phpstan analyse --no-progress --verbose --level max src/ tests/ bin/ + - bash bin/check-current-max-occurs-paths.bash notifications: email: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dc6b4b..5b67cd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,8 +62,15 @@ Considera las siguientes directrices: ## Proceso de construcción ```shell +# Instala phive, sigue las indicaciones seguras de https://phar.io/#Install o la forma insegura: +wget https://phar.io/releases/phive.phar -O ~/.local/bin/phive + +## Si no se han instalado las herramientas previamente +phive install --force-accept-unsigned --trust-gpg-keys 0x4AA394086372C20A,0x31C7E470E2138192,0xE82B2FB314E9906E,0xCF1A108D0E7AE720,0xC5095986493B4AA0 + # Actualiza tus dependencias composer update +phive update # Verificación de estilo de código composer dev:check-style @@ -76,6 +83,9 @@ composer dev:test # Ejecución todo en uno, corregir estilo, verificar estilo y correr pruebas composer dev:build + +# Opcional: correr las pruebas de mutación +composer dev:infection ``` [phpCfdi]: https://github.com/phpcfdi/ diff --git a/bin/check-current-max-occurs-paths.bash b/bin/check-current-max-occurs-paths.bash new file mode 100644 index 0000000..f4b1849 --- /dev/null +++ b/bin/check-current-max-occurs-paths.bash @@ -0,0 +1,13 @@ +#!/usr/bin/env bash -e + +TEMPFILE="$(mktemp)" +BINPATH="$(dirname $0)" + +echo "Creating UnboundedOccursPaths.json on $TEMPFILE" +php "${BINPATH}/max-occurs-paths.php" > "$TEMPFILE" + +echo "Comparing to current UnboundedOccursPaths.json" +diff -u -b -B "${BINPATH}/../src/UnboundedOccursPaths.json" "$TEMPFILE" + +echo "OK: Files match" +rm "$TEMPFILE" diff --git a/composer.json b/composer.json index 77839e6..c6a48b3 100644 --- a/composer.json +++ b/composer.json @@ -17,10 +17,7 @@ "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.0", - "friendsofphp/php-cs-fixer": "^2.4", - "phpstan/phpstan": "^0.12" + "phpunit/phpunit": "^9.5" }, "autoload": { "psr-4": { @@ -35,26 +32,30 @@ "scripts": { "dev:build": ["@dev:fix-style", "@dev:check-style", "@dev:test"], "dev:check-style": [ - "vendor/bin/php-cs-fixer fix --dry-run --verbose", - "vendor/bin/phpcs --colors -sp src/ tests/ bin/" + "@php tools/php-cs-fixer fix --dry-run --verbose", + "@php tools/phpcs --colors -sp src/ tests/ bin/" ], "dev:fix-style": [ - "vendor/bin/php-cs-fixer fix --verbose", - "vendor/bin/phpcbf --colors -sp src/ tests/ bin/" + "@php tools/php-cs-fixer fix --verbose", + "@php tools/phpcbf --colors -sp src/ tests/ bin/" ], "dev:test": [ - "vendor/bin/phpunit --testdox --verbose --stop-on-failure", - "vendor/bin/phpstan analyse --verbose --level max src/ tests/ bin/" + "@php vendor/bin/phpunit --testdox --verbose --stop-on-failure", + "@php tools/phpstan analyse --verbose --level max src/ tests/ bin/" ], "dev:coverage": [ - "@php -dzend_extension=xdebug.so vendor/bin/phpunit --coverage-html build/coverage/html/" + "@php -dzend_extension=xdebug.so -dxdebug.mode=coverage vendor/bin/phpunit --coverage-xml build/coverage/xml/ --coverage-html build/coverage/html/" + ], + "dev:infection": [ + "@php tools/infection --show-mutations --no-progress" ] }, "scripts-descriptions": { "dev:build": "DEV: run dev:fix-style and dev:tests, run before pull request", "dev:check-style": "DEV: search for code style errors using php-cs-fixer and phpcs", "dev:fix-style": "DEV: fix code style errors using php-cs-fixer and phpcbf", - "dev:test": "DEV: run phpunit and phpstan", - "dev:coverage": "DEV: run phpunit with xdebug and storage coverage in build/coverage/html/" + "dev:test": "DEV: run dev:fix-style, phpunit and phpstan", + "dev:coverage": "DEV: run phpunit with xdebug and storage coverage in build/coverage/html/", + "dev:infection": "DEV: run mutation tests using infection" } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f990e2f..ed6b4b6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,15 @@ versión aunque sí su incorporación en la rama principal de trabajo, generalme ## Listado de cambios +### Versión 0.2.0 2021-03-22 + +- Se extrae la lógica del conteo de hijos de `Nodes\Children` a `Nodes\KeysCounter`. +- Se corrigen los test y las llamadas de `file_get_contents`. +- Conseguir el 100% de testeo. +- Agregar a Travis-CI la comprobación de que el archivo `src/UnboundedOccursPaths.json` no ha cambiado. +- Usar `phive` para las herramientas de desarrollo. +- Se agrega `infection` para correr pruebas de mutación. No es mandatorio por el momento. + ### Versión 0.1.0 2021-02-02 ¡Feliz cumpleaños Dany! - Primera liberación para su uso público. diff --git a/docs/TODO.md b/docs/TODO.md index 1dea29d..2b86145 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,8 +2,6 @@ ## Ideas, por favor, levante un ticket para discutirlas -- Agregar a Travis la comprobación del archivo `src/UnboundedOccursPaths.json` - - ¿Los elementos `Comprobante/Complemento` deberían colapsarse? -- Usar infection +- Hacer que `infection` se ejecute con un mínimo requerido en la integración contínua. diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..3c0deae --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,14 @@ +{ + "source": { + "directories": [ + "src" + ] + }, + "logs": { + "text": "build\/infection.log" + }, + "mutators": { + "@default": true + }, + "initialTestsPhpOptions": "-dzend_extension=xdebug.so -dxdebug.mode=coverage" +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 24596ad..dabf248 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -5,6 +5,7 @@ + diff --git a/src/CfdiToDataNode.php b/src/CfdiToDataNode.php index cb16a4b..0f82c65 100644 --- a/src/CfdiToDataNode.php +++ b/src/CfdiToDataNode.php @@ -15,9 +15,14 @@ final class CfdiToDataNode /** @var UnboundedOccursPaths */ private $unboundedOccursPaths; - public function __construct(UnboundedOccursPaths $multipleChildrenPaths) + public function __construct(UnboundedOccursPaths $unboundedOccursPaths) { - $this->unboundedOccursPaths = $multipleChildrenPaths; + $this->unboundedOccursPaths = $unboundedOccursPaths; + } + + public function getUnboundedOccursPaths(): UnboundedOccursPaths + { + return $this->unboundedOccursPaths; } public function convertXmlContent(string $xmlContents): Nodes\Node diff --git a/src/Nodes/Children.php b/src/Nodes/Children.php index 26df61f..890af83 100644 --- a/src/Nodes/Children.php +++ b/src/Nodes/Children.php @@ -14,31 +14,27 @@ final class Children /** @var UnboundedOccursPaths */ private $unboundedOccursPaths; - /** @var array */ - private $childrenCountByKey = []; + /** @var KeysCounter */ + private $keysCounter; public function __construct(UnboundedOccursPaths $unboundedOccursPaths) { $this->unboundedOccursPaths = $unboundedOccursPaths; + $this->keysCounter = new KeysCounter(); } public function append(Node $child): void { $this->children[] = $child; - $this->childrenCountByKey[$child->getKey()] = $this->getChildrenCountByKey($child->getKey()); + $this->keysCounter->register($child->getKey()); } public function isChildrenMultiple(Node $child): bool { - return ($this->getChildrenCountByKey($child->getKey()) > 1) + return $this->keysCounter->hasMany($child->getKey()) || $this->unboundedOccursPaths->match($child->getPath()); } - private function getChildrenCountByKey(string $key): int - { - return $this->childrenCountByKey[$key] ?? 0; - } - /** @return array */ public function toArray(): array { diff --git a/src/Nodes/KeysCounter.php b/src/Nodes/KeysCounter.php new file mode 100644 index 0000000..cbf721e --- /dev/null +++ b/src/Nodes/KeysCounter.php @@ -0,0 +1,26 @@ + */ + private $counts; + + public function register(string $key): void + { + $this->counts[$key] = $this->get($key) + 1; + } + + public function get(string $key): int + { + return $this->counts[$key] ?? 0; + } + + public function hasMany(string $key): bool + { + return $this->get($key) > 1; + } +} diff --git a/src/XsdMaxOccurs/Downloader.php b/src/XsdMaxOccurs/Downloader.php index 84a1a42..b9d67e5 100644 --- a/src/XsdMaxOccurs/Downloader.php +++ b/src/XsdMaxOccurs/Downloader.php @@ -5,17 +5,13 @@ namespace PhpCfdi\CfdiToJson\XsdMaxOccurs; use RuntimeException; -use Throwable; final class Downloader implements DownloaderInterface { public function get(string $url): string { - try { - $contents = file_get_contents($url); - } catch (Throwable $exception) { - throw new RuntimeException("Unable to get $url contents", 0, $exception); - } + /** @noinspection PhpUsageOfSilenceOperatorInspection */ + $contents = @file_get_contents($url); if (false === $contents) { throw new RuntimeException("Unable to get $url contents"); } diff --git a/src/XsdMaxOccurs/Finder.php b/src/XsdMaxOccurs/Finder.php index 8d978d3..a614160 100644 --- a/src/XsdMaxOccurs/Finder.php +++ b/src/XsdMaxOccurs/Finder.php @@ -17,9 +17,9 @@ public function obtainPathsFromXsdContents(string $xsdContents): array $document->loadXML($xsdContents); return array_merge( - $this->obtainPathsForXPathQuery($document, '//xs:element[@maxOccurs="unbounded"]'), - $this->obtainPathsForXPathQuery($document, '//xs:sequence[@maxOccurs="unbounded"]/xs:element'), - $this->obtainPathsForXPathQuery($document, '//xs:choice[@maxOccurs="unbounded"]/xs:element'), + $this->obtainPathsForXPathQuery($document, '//x:element[@maxOccurs="unbounded"]'), + $this->obtainPathsForXPathQuery($document, '//x:sequence[@maxOccurs="unbounded"]/x:element'), + $this->obtainPathsForXPathQuery($document, '//x:choice[@maxOccurs="unbounded"]/x:element'), ); } @@ -32,7 +32,7 @@ private function obtainPathsForXPathQuery(DOMDocument $document, string $query): { $paths = []; $xpath = new DOMXPath($document); - $xpath->registerNamespace('xs', 'http://www.w3.org/2001/XMLSchema'); + $xpath->registerNamespace('x', 'http://www.w3.org/2001/XMLSchema'); $nodes = $xpath->query($query) ?: new DOMNodeList(); foreach ($nodes as $node) { if ($node instanceof DOMElement) { diff --git a/tests/Functional/ConverterTest.php b/tests/Functional/ConverterTest.php index 4b9ec21..44fe33c 100644 --- a/tests/Functional/ConverterTest.php +++ b/tests/Functional/ConverterTest.php @@ -57,8 +57,10 @@ public function testConverterExportsNodesAsArrayWhenTheyAreKnownFromComplemento( public function testJsonConverter(): void { $xmlContents = $this->fileContents('cfdi-example.xml'); + $jsonFile = $this->filePath('cfdi-example.json'); /** @noinspection PhpUnhandledExceptionInspection */ $json = JsonConverter::convertToJson($xmlContents); - $this->assertJsonStringEqualsJsonString(json_encode($this->data) ?: '', $json); + $this->assertJsonStringEqualsJsonFile($jsonFile, $json); + $this->assertStringEqualsFile($jsonFile, $json, 'Check that the default format is preserved'); } } diff --git a/tests/Unit/FactoryTest.php b/tests/Unit/FactoryTest.php new file mode 100644 index 0000000..ac62c50 --- /dev/null +++ b/tests/Unit/FactoryTest.php @@ -0,0 +1,80 @@ +assertEquals($factory->createDefaultUnboundedOccursPaths(), $factory->getUnboundedOccursPaths()); + } + + public function testConstructFactoryUsesGivenUnboundedOccursPaths(): void + { + $unboundedOccursPaths = new UnboundedOccursPaths(); + $factory = new Factory($unboundedOccursPaths); + $this->assertEquals($unboundedOccursPaths, $factory->getUnboundedOccursPaths()); + $converter = $factory->createConverter(); + $this->assertSame($unboundedOccursPaths, $converter->getUnboundedOccursPaths()); + } + + public function testCreateUnboundedOccursPathsUsingJsonFileUsingInvalidFileWithErrorReporting(): void + { + $factory = new Factory(new UnboundedOccursPaths()); + $this->expectWarning(); + $factory->createUnboundedOccursPathsUsingJsonFile(__DIR__ . '/not-found'); + } + + public function testCreateUnboundedOccursPathsUsingJsonFileUsingInvalidFileWithoutErrorReporting(): void + { + $factory = new Factory(new UnboundedOccursPaths()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unable to open file'); + + /** @noinspection PhpUsageOfSilenceOperatorInspection */ + @$factory->createUnboundedOccursPathsUsingJsonFile(__DIR__ . '/not-found'); + } + + public function testCreateUnboundedOccursPathsUsingJsonFileUsingInvalidContents(): void + { + $factory = new Factory(new UnboundedOccursPaths()); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('has invalid contents'); + $factory->createUnboundedOccursPathsUsingJsonFile(__FILE__); + } + + public function testCreateUnboundedOccursPathsUsingJsonSourceWithInvalidJson(): void + { + $factory = new Factory(new UnboundedOccursPaths()); + $this->expectException(JsonException::class); + $factory->createUnboundedOccursPathsUsingJsonSource(''); + } + + public function testCreateUnboundedOccursPathsUsingJsonNotArray(): void + { + $factory = new Factory(new UnboundedOccursPaths()); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('JSON does not contains an array of entries'); + $factory->createUnboundedOccursPathsUsingJsonSource('""'); + } + + public function testCreateUnboundedOccursPathsUsingJsonNotArrayOfStrings(): void + { + $factory = new Factory(new UnboundedOccursPaths()); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('JSON does not contains a string on index 1'); + $factory->createUnboundedOccursPathsUsingJsonSource('["string", 2]'); + } +} diff --git a/tests/Unit/Nodes/ChildrenTest.php b/tests/Unit/Nodes/ChildrenTest.php new file mode 100644 index 0000000..e8b06a7 --- /dev/null +++ b/tests/Unit/Nodes/ChildrenTest.php @@ -0,0 +1,24 @@ +append($nodeChapter); + $children->append($nodeChapter); + + $this->assertTrue($children->isChildrenMultiple($nodeChapter)); + } +} diff --git a/tests/Unit/Nodes/KeysCounterTest.php b/tests/Unit/Nodes/KeysCounterTest.php new file mode 100644 index 0000000..e1c6184 --- /dev/null +++ b/tests/Unit/Nodes/KeysCounterTest.php @@ -0,0 +1,38 @@ +register($key); + } + return $counter; + } + + public function testHasMany(): void + { + $counter = $this->createPopulatedCounter('author', 'chapter', 'chapter'); + + $this->assertTrue($counter->hasMany('chapter')); + $this->assertFalse($counter->hasMany('author')); + $this->assertFalse($counter->hasMany('non-existent')); + } + + public function testGet(): void + { + $counter = $this->createPopulatedCounter('author', 'chapter', 'chapter'); + + $this->assertSame(2, $counter->get('chapter')); + $this->assertSame(1, $counter->get('author')); + $this->assertSame(0, $counter->get('non-existent')); + } +} diff --git a/tests/Unit/XsdMaxOccurs/DownloaderTest.php b/tests/Unit/XsdMaxOccurs/DownloaderTest.php index d970b1a..3031ba1 100644 --- a/tests/Unit/XsdMaxOccurs/DownloaderTest.php +++ b/tests/Unit/XsdMaxOccurs/DownloaderTest.php @@ -22,16 +22,14 @@ public function testDownloaderThrowsExceptionWhenCannotGetContents(): void public function testDownloaderThrowsExceptionWhenCannotGetContentsWithoutErrorReporting(): void { - $previousErrorReporting = error_reporting(0); - $url = __DIR__ . '/file-not-found'; $downloader = new Downloader(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage("Unable to get $url contents"); - $downloader->get($url); - error_reporting($previousErrorReporting); + /** @noinspection PhpUsageOfSilenceOperatorInspection */ + @$downloader->get($url); } public function testDownloaderThrowsExceptionWhenContentIsEmpty(): void diff --git a/tests/_files/cfdi-example.json b/tests/_files/cfdi-example.json new file mode 100644 index 0000000..34fbaa1 --- /dev/null +++ b/tests/_files/cfdi-example.json @@ -0,0 +1,115 @@ +{ + "Certificado": "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", + "CondicionesDePago": "CONTADO", + "Fecha": "2018-01-12T08:15:01", + "Folio": "11541", + "FormaPago": "04", + "LugarExpedicion": "76802", + "MetodoPago": "PUE", + "Moneda": "MXN", + "NoCertificado": "00001000000401220451", + "Sello": "Xt7tK83WumikNMyx4Y/Z3R7D0rOjqTrrLu8wBlCnvXrpMFgWtyrcFUttGnevvUqCnQjuVUSpFcXqbzIQEUYNKFjxmtjwGHN+b15xUvcnfqpJRBoJe2IKd5YMZqYp9NhTJIMBYsE7+fhP1+mHcKdKn9WwXrar8uXzISqPgZ97AORBsMWmXxbVWYRtqT4MX/Xq4yhbT4jaoaut5AwhVzE1TUyZ10/C2gGySQeFVyEp9aqNScIxPotVDb7fMIWxsV26XODf6GK14B0TJNmRlCIfmfb2rQeskiYeiF5AQPb6Z2gmGLHcNks7qC+eO3EsGVr1/ntmGcwTurbGXmE4/OAgdg==", + "Serie": "H", + "SubTotal": "1709.12", + "TipoDeComprobante": "I", + "Total": "2010.01", + "Version": "3.3", + "xsi:schemaLocation": "http://www.sat.gob.mx/cfd/3 http://www.sat.gob.mx/sitio_internet/cfd/3/cfdv33.xsd http://www.sat.gob.mx/implocal http://www.sat.gob.mx/sitio_internet/cfd/implocal/implocal.xsd", + "Emisor": { + "Nombre": "PROMOTORA OTIR SA DE CV", + "RegimenFiscal": "601", + "Rfc": "POT9207213D6" + }, + "Receptor": { + "Nombre": "DAY INTERNATIONAL DE MEXICO SA DE CV", + "Rfc": "DIM8701081LA", + "UsoCFDI": "G03" + }, + "Conceptos": { + "Concepto": [ + { + "Cantidad": "2.00", + "ClaveProdServ": "90111501", + "ClaveUnidad": "E48", + "Descripcion": "Paquete", + "Importe": "1355.67", + "Unidad": "UNIDAD DE SERVICIO", + "ValorUnitario": "677.83", + "Impuestos": { + "Traslados": { + "Traslado": [ + { + "Base": "1355.67", + "Importe": "216.91", + "Impuesto": "002", + "TasaOCuota": "0.160000", + "TipoFactor": "Tasa" + } + ] + } + } + }, + { + "Cantidad": "1.00", + "ClaveProdServ": "90101501", + "ClaveUnidad": "E48", + "Descripcion": "Restaurante", + "Importe": "353.45", + "Unidad": "UNIDAD DE SERVICIO", + "ValorUnitario": "353.45", + "Impuestos": { + "Traslados": { + "Traslado": [ + { + "Base": "353.45", + "Importe": "56.55", + "Impuesto": "002", + "TasaOCuota": "0.160000", + "TipoFactor": "Tasa" + } + ] + } + } + } + ] + }, + "Impuestos": { + "TotalImpuestosTrasladados": "273.46", + "Traslados": { + "Traslado": [ + { + "Importe": "273.46", + "Impuesto": "002", + "TasaOCuota": "0.160000", + "TipoFactor": "Tasa" + } + ] + } + }, + "Complemento": [ + { + "ImpuestosLocales": { + "TotaldeRetenciones": "0.00", + "TotaldeTraslados": "27.43", + "version": "1.0", + "TrasladosLocales": [ + { + "ImpLocTrasladado": "IH", + "Importe": "27.43", + "TasadeTraslado": "2.50" + } + ] + }, + "TimbreFiscalDigital": { + "FechaTimbrado": "2018-01-12T08:17:54", + "NoCertificadoSAT": "00001000000406258094", + "RfcProvCertif": "DCD090706E42", + "SelloCFD": "Xt7tK83WumikNMyx4Y/Z3R7D0rOjqTrrLu8wBlCnvXrpMFgWtyrcFUttGnevvUqCnQjuVUSpFcXqbzIQEUYNKFjxmtjwGHN+b15xUvcnfqpJRBoJe2IKd5YMZqYp9NhTJIMBYsE7+fhP1+mHcKdKn9WwXrar8uXzISqPgZ97AORBsMWmXxbVWYRtqT4MX/Xq4yhbT4jaoaut5AwhVzE1TUyZ10/C2gGySQeFVyEp9aqNScIxPotVDb7fMIWxsV26XODf6GK14B0TJNmRlCIfmfb2rQeskiYeiF5AQPb6Z2gmGLHcNks7qC+eO3EsGVr1/ntmGcwTurbGXmE4/OAgdg==", + "SelloSAT": "IRy7wQnKnlIsN/pSZSR7qEm/SOJuLIbNjj/S3EAd278T2uo0t73KXfXzUbbfWOwpdZEAZeosq/yEiStTaf44ZonqRS1fq6oYk12udMmT4NFrEYbPEEKLn4lqdhuW4v8ZK2Vos/pjCtYtpT+/oVIXiWg9KrGVGuMvygRPWSmd+YJq3Jm7qTz0ON0vzBOvXralSZ4Q14xUvt6ZDM9gYqIzTtCjIWaNrAdEYyqfZjvfy0uCyThh6HvCbMsX9gq4RsQj3SIoA56g+1SJevoZ6Jr722mDCLcPox3KCN75Bk8ALJI6G0weP7rQO5jEtulTRNWN3w+tlryZWElkD79MDZA6Zg==", + "UUID": "CEE4BE01-ADFA-4DEB-8421-ADD60F0BEDAC", + "Version": "1.1", + "xsi:schemaLocation": "http://www.sat.gob.mx/TimbreFiscalDigital http://www.sat.gob.mx/sitio_internet/cfd/TimbreFiscalDigital/TimbreFiscalDigitalv11.xsd" + } + } + ] +} \ No newline at end of file