├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── docs ├── CHANGELOG.md ├── EjemploConsumo.md ├── SEMVER.md ├── TODO.md ├── UPGRADE-2-3.md └── UPGRADE-3-4.md └── src ├── Contracts ├── FilterOption.php ├── MetadataMessageHandler.php ├── QueryInterface.php ├── ResourceDownloadHandlerInterface.php ├── ResourceDownloaderPromiseHandlerInterface.php ├── ResourceFileNamerInterface.php └── SatScraperInterface.php ├── Exceptions ├── InvalidArgumentException.php ├── LogicException.php ├── LoginException.php ├── ResourceDownloadError.php ├── ResourceDownloadRequestExceptionError.php ├── ResourceDownloadResponseError.php ├── RuntimeException.php ├── SatException.php ├── SatHttpGatewayClientException.php ├── SatHttpGatewayException.php └── SatHttpGatewayResponseException.php ├── Filters ├── DownloadType.php └── Options │ ├── ComplementsOption.php │ ├── RfcOnBehalfOption.php │ ├── RfcOption.php │ ├── StatesVoucherOption.php │ └── UuidOption.php ├── Inputs ├── InputsByFilters.php ├── InputsByFiltersIssued.php ├── InputsByFiltersReceived.php ├── InputsByUuid.php ├── InputsGeneric.php └── InputsInterface.php ├── Internal ├── CaptchaBase64Extractor.php ├── DownloadTypePropertyTrait.php ├── Headers.php ├── HtmlForm.php ├── MetaRefreshInspector.php ├── MetadataDownloader.php ├── MetadataExtractor.php ├── ParserFormatSAT.php ├── QueryResolver.php ├── ResourceDownloadStoreInFolder.php ├── ResourceDownloaderPromiseHandler.php └── ResourceFileNamerByType.php ├── Metadata.php ├── MetadataList.php ├── NullMetadataMessageHandler.php ├── QueryByFilters.php ├── QueryByUuid.php ├── ResourceDownloader.php ├── ResourceType.php ├── SatHttpGateway.php ├── SatScraper.php ├── Sessions ├── AbstractSessionManager.php ├── Ciec │ ├── CiecLoginException.php │ ├── CiecSessionData.php │ └── CiecSessionManager.php ├── Fiel │ ├── ChallengeResolver.php │ ├── FielLoginException.php │ ├── FielSessionData.php │ └── FielSessionManager.php └── SessionManager.php └── URLS.php /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Código de Conducta Convenido para Contribuyentes 2 | 3 | ## Nuestro compromiso 4 | 5 | Nosotros, como miembros, contribuyentes y administradores nos comprometemos a hacer de la participación en nuestra comunidad una experiencia libre de acoso para todo el mundo, independientemente de la edad, dimensión corporal, minusvalía visible o invisible, etnicidad, características sexuales, identidad y expresión de género, nivel de experiencia, educación, nivel socioeconómico, nacionalidad, apariencia personal, raza, religión, o identidad u orientación sexual. 6 | 7 | Nos comprometemos a actuar e interactuar de maneras que contribuyan a una comunidad abierta, acogedora, diversa, inclusiva y sana. 8 | 9 | ## Nuestros estándares 10 | 11 | Ejemplos de comportamiento que contribuyen a crear un ambiente positivo para nuestra comunidad: 12 | 13 | * Demostrar empatía y amabilidad ante otras personas 14 | * Respeto a diferentes opiniones, puntos de vista y experiencias 15 | * Dar y aceptar adecuadamente retroalimentación constructiva 16 | * Aceptar la responsabilidad y disculparse ante quienes se vean afectados por nuestros errores, aprendiendo de la experiencia 17 | * Centrarse en lo que sea mejor no solo para nosotros como individuos, sino para la comunidad en general 18 | 19 | Ejemplos de comportamiento inaceptable: 20 | 21 | * El uso de lenguaje o imágenes sexualizadas, y aproximaciones o 22 | atenciones sexuales de cualquier tipo 23 | * Comentarios despectivos (_trolling_), insultantes o derogatorios, y ataques personales o políticos 24 | * El acoso en público o privado 25 | * Publicar información privada de otras personas, tales como direcciones físicas o de correo 26 | electrónico, sin su permiso explícito 27 | * Otras conductas que puedan ser razonablemente consideradas como inapropiadas en un 28 | entorno profesional 29 | 30 | ## Aplicación de las responsabilidades 31 | 32 | Los administradores de la comunidad son responsables de aclarar y hacer cumplir nuestros estándares de comportamiento aceptable y tomarán acciones apropiadas y correctivas de forma justa en respuesta a cualquier comportamiento que consideren inapropiado, amenazante, ofensivo o dañino. 33 | 34 | Los administradores de la comunidad tendrán el derecho y la responsabilidad de eliminar, editar o rechazar comentarios, _commits_, código, ediciones de páginas de wiki, _issues_ y otras contribuciones que no se alineen con este Código de Conducta, y comunicarán las razones para sus decisiones de moderación cuando sea apropiado. 35 | 36 | ## Alcance 37 | 38 | Este código de conducta aplica tanto a espacios del proyecto como a espacios públicos donde un individuo esté en representación del proyecto o comunidad. Ejemplos de esto incluyen el uso de la cuenta oficial de correo electrónico, publicaciones a través de las redes sociales oficiales, o presentaciones con personas designadas en eventos en línea o no. 39 | 40 | ## Aplicación 41 | 42 | Instancias de comportamiento abusivo, acosador o inaceptable de otro modo podrán ser reportadas a los administradores de la comunidad responsables del cumplimiento a través de [coc@phpcfdi.com](). Todas las quejas serán evaluadas e investigadas de una manera puntual y justa. 43 | 44 | Todos los administradores de la comunidad están obligados a respetar la privacidad y la seguridad de quienes reporten incidentes. 45 | 46 | ## Guías de Aplicación 47 | 48 | Los administradores de la comunidad seguirán estas Guías de Impacto en la Comunidad para determinar las consecuencias de cualquier acción que juzguen como un incumplimiento de este Código de Conducta: 49 | 50 | ### 1. Corrección 51 | 52 | **Impacto en la Comunidad**: El uso de lenguaje inapropiado u otro comportamiento considerado no profesional o no acogedor en la comunidad. 53 | 54 | **Consecuencia**: Un aviso escrito y privado por parte de los administradores de la comunidad, proporcionando claridad alrededor de la naturaleza de este incumplimiento y una explicación de por qué el comportamiento es inaceptable. Una disculpa pública podría ser solicitada. 55 | 56 | ### 2. Aviso 57 | 58 | **Impacto en la Comunidad**: Un incumplimiento causado por un único incidente o por una cadena de acciones. 59 | 60 | **Consecuencia**: Un aviso con consecuencias por comportamiento prolongado. No se interactúa con las personas involucradas, incluyendo interacción no solicitada con quienes se encuentran aplicando el Código de Conducta, por un periodo especificado de tiempo. Esto incluye evitar las interacciones en espacios de la comunidad, así como a través de canales externos como las redes sociales. Incumplir estos términos puede conducir a una expulsión temporal o permanente. 61 | 62 | ### 3. Expulsión temporal 63 | 64 | **Impacto en la Comunidad**: Una serie de incumplimientos de los estándares de la comunidad, incluyendo comportamiento inapropiado continuo. 65 | 66 | **Consecuencia**: Una expulsión temporal de cualquier forma de interacción o comunicación pública con la comunidad durante un intervalo de tiempo especificado. No se permite interactuar de manera pública o privada con las personas involucradas, incluyendo interacciones no solicitadas con quienes se encuentran aplicando el Código de Conducta, durante este periodo. Incumplir estos términos puede conducir a una expulsión permanente. 67 | 68 | ### 4. Expulsión permanente 69 | 70 | **Impacto en la Comunidad**: Demostrar un patrón sistemático de incumplimientos de los estándares de la comunidad, incluyendo conductas inapropiadas prolongadas en el tiempo, acoso de individuos, o agresiones o menosprecio a grupos de individuos. 71 | 72 | **Consecuencia**: Una expulsión permanente de cualquier tipo de interacción pública con la comunidad del proyecto. 73 | 74 | ## Atribución 75 | 76 | Este Código de Conducta es una adaptación del [Contributor Covenant][homepage], versión 2.0, 77 | disponible en . 78 | 79 | Las Guías de Impacto en la Comunidad están inspiradas en la [escalera de aplicación del código de conducta de Mozilla](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | Para respuestas a las preguntas frecuentes de este código de conducta, consulta las FAQ en . 84 | Hay traducciones disponibles en . 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribuciones 2 | 3 | Las contribuciones son bienvenidas. Aceptamos *Pull Requests* en el [repositorio GitHub][project]. 4 | 5 | Este proyecto se apega al siguiente [Código de Conducta][coc]. 6 | Al participar en este proyecto y en su comunidad, deberás seguir este código. 7 | 8 | ## Miembros del equipo 9 | 10 | * [phpCfdi][] - Organización que mantiene el proyecto. 11 | * [Contribuidores][contributors]. 12 | 13 | ## Canales de comunicación 14 | 15 | Puedes encontrar ayuda y comentar asuntos relacionados con este proyecto en estos lugares: 16 | 17 | * Comunidad Discord: 18 | * GitHub Issues: 19 | 20 | ## Reportar Bugs 21 | 22 | Publica los *Bugs* en la sección [GitHub Issues][issues] del proyecto. 23 | 24 | Sigue las recomendaciones generales de [phpCfdi][] para reportar problemas 25 | . 26 | 27 | Cuando se reporte un *Bug*, por favor incluye la mayor información posible para reproducir el problema, preferentemente 28 | con ejemplos de código o cualquier otra información técnica que nos pueda ayudar a identificar el caso. 29 | 30 | **Recuerda no incluir contraseñas, información personal o confidencial.** 31 | 32 | ## Corrección de Bugs 33 | 34 | Apreciamos mucho los *Pull Request* para corregir Bugs. 35 | 36 | Si encuentras un reporte de Bug y te gustaría solucionarlo siéntete libre de hacerlo. 37 | Sigue las directrices de "Agregar nuevas funcionalidades" a continuación. 38 | 39 | ## Agregar nuevas funcionalidades 40 | 41 | Si tienes una idea para una nueva funcionalidad revisa primero que existan discusiones o *Pull Requests* 42 | en donde ya se esté trabajando en la funcionalidad. 43 | 44 | Antes de trabajar en la nueva característica, utiliza los "Canales de comunicación" mencionados 45 | anteriormente para platicar acerca de tu idea. Si dialogas tus ideas con la comunidad y los 46 | mantenedores del proyecto, podrás ahorrar mucho esfuerzo de desarrollo y prevenir que tu 47 | *Pull Request* sea rechazado. No nos gusta rechazar contribuciones, pero algunas características 48 | o la forma de desarrollarlas puede que no estén alineadas con el proyecto. 49 | 50 | Considera las siguientes directrices: 51 | 52 | * Usa una rama única que se desprenda de la rama principal. 53 | No mezcles dos diferentes funcionalidades en una misma rama o *Pull Request*. 54 | * Describe claramente y en detalle los cambios que hiciste. 55 | * **Escribe pruebas** para la funcionalidad que deseas agregar. 56 | * **Asegúrate que las pruebas pasan** antes de enviar tu contribución. 57 | Usamos integración contínua donde se hace esta verificación, pero es mucho mejor si lo pruebas localmente. 58 | * Intenta enviar una historia coherente, entenderemos cómo cambia el código si los *commits* tienen significado. 59 | * La documentación es parte del proyecto. 60 | Realiza los cambios en los archivos de ayuda para que reflejen los cambios en el código. 61 | 62 | ## Proceso de construcción 63 | 64 | ```shell 65 | # Actualiza tus dependencias 66 | composer update 67 | phive update 68 | 69 | # Verificación de estilo de código 70 | composer dev:check-style 71 | 72 | # Corrección de estilo de código 73 | composer dev:fix-style 74 | 75 | # Ejecución de pruebas 76 | composer dev:test 77 | 78 | # Ejecución todo en uno: corregir estilo, verificar estilo y correr pruebas 79 | composer dev:build 80 | ``` 81 | 82 | ## Ejecutar GitHub Actions localmente 83 | 84 | Puedes usar [`act`](https://github.com/nektos/act) para ejecutar GitHub Actions localmente, tal como se 85 | muestra en [`actions/setup-php-action`](https://github.com/marketplace/actions/setup-php-action#local-testing-setup) 86 | puedes ejecutar el siguiente comando: 87 | 88 | ```shell 89 | act -P ubuntu-latest=shivammathur/node:latest 90 | ``` 91 | 92 | [phpCfdi]: https://github.com/phpcfdi/ 93 | [project]: https://github.com/phpcfdi/cfdi-sat-scraper 94 | [contributors]: https://github.com/phpcfdi/cfdi-sat-scraper/graphs/contributors 95 | [coc]: https://github.com/phpcfdi/cfdi-sat-scraper/blob/main/CODE_OF_CONDUCT.md 96 | [issues]: https://github.com/phpcfdi/cfdi-sat-scraper/issues 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 - 2024 PhpCfdi https://www.phpcfdi.com/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpcfdi/cfdi-sat-scraper", 3 | "description": "Web Scraping para extraer facturas electrónicas desde la página del SAT", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "sat", 8 | "cfdi", 9 | "scrap", 10 | "mexico" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Cesar Aguilera", 15 | "email": "cesargnu29@gmail.com" 16 | }, 17 | { 18 | "name": "Carlos C Soto", 19 | "email": "eclipxe13@gmail.com" 20 | } 21 | ], 22 | "require": { 23 | "php": ">=7.3", 24 | "ext-curl": "*", 25 | "ext-dom": "*", 26 | "ext-fileinfo": "*", 27 | "ext-json": "*", 28 | "ext-mbstring": "*", 29 | "ext-openssl": "*", 30 | "eclipxe/enum": "^0.2.0", 31 | "eclipxe/micro-catalog": "^0.1.3", 32 | "guzzlehttp/guzzle": "^7.0", 33 | "guzzlehttp/promises": "^2.0", 34 | "phpcfdi/credentials": "^1.1", 35 | "phpcfdi/image-captcha-resolver": "^0.2.4", 36 | "psr/http-message": "^1.1 || ^2.0", 37 | "symfony/css-selector": "^5.4 || ^6.0 || ^7.0", 38 | "symfony/dom-crawler": "^5.4 || ^6.0 || ^7.0" 39 | }, 40 | "require-dev": { 41 | "ext-iconv": "*", 42 | "fakerphp/faker": "^1.13", 43 | "phpcfdi/image-captcha-resolver-boxfactura-ai": "^0.1.0", 44 | "phpunit/phpunit": "^9.5", 45 | "symfony/dotenv": "^5.4 || ^6.0 || ^7.0" 46 | }, 47 | "autoload": { 48 | "psr-4": { 49 | "PhpCfdi\\CfdiSatScraper\\": "src" 50 | } 51 | }, 52 | "autoload-dev": { 53 | "psr-4": { 54 | "PhpCfdi\\CfdiSatScraper\\Tests\\": "tests" 55 | } 56 | }, 57 | "config": { 58 | "allow-plugins": { 59 | "php-http/discovery": false 60 | }, 61 | "optimize-autoloader": true, 62 | "preferred-install": { 63 | "*": "dist" 64 | } 65 | }, 66 | "scripts": { 67 | "post-install-cmd": "OnnxRuntime\\Vendor::check", 68 | "post-update-cmd": "OnnxRuntime\\Vendor::check", 69 | "dev:build": [ 70 | "@dev:fix-style", 71 | "@dev:tests" 72 | ], 73 | "dev:check-style": [ 74 | "@php tools/composer-normalize normalize --dry-run", 75 | "@php -r 'exit(intval(PHP_VERSION_ID >= 74000));' || $PHP_BINARY tools/php-cs-fixer fix --dry-run --verbose", 76 | "@php tools/phpcs --colors -sp" 77 | ], 78 | "dev:fix-style": [ 79 | "@php tools/composer-normalize normalize", 80 | "@php -r 'exit(intval(PHP_VERSION_ID >= 74000));' || $PHP_BINARY tools/php-cs-fixer fix --verbose", 81 | "@php tools/phpcbf --colors -sp" 82 | ], 83 | "dev:tests": [ 84 | "@dev:check-style", 85 | "@php vendor/bin/phpunit --testdox --verbose", 86 | "@php tools/phpstan analyze --no-progress --verbose" 87 | ] 88 | }, 89 | "scripts-descriptions": { 90 | "dev:build": "DEV: run dev:fix-style and dev:tests, run before pull request", 91 | "dev:check-style": "DEV: search for code style errors using composer-normalize, php-cs-fixer and phpcs", 92 | "dev:fix-style": "DEV: fix code style errors using composer-normalize, php-cs-fixer and phpcbf", 93 | "dev:tests": "DEV: run executes phpunit tests" 94 | }, 95 | "recommended": { 96 | "phpcfdi/image-captcha-resolver-boxfactura-ai": "Permite resolver los captchas localmente con inteligencia artificial" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # phpcfdi/cfdi-sat-scraper CHANGELOG 2 | 3 | ## Acerca de los números de versiones 4 | 5 | Usamos [Versionado Semántico 2.0.0](SEMVER.md) por lo que puedes usar esta librería sin temor a romper tu aplicación. 6 | 7 | ## Cambios aún no liberados en una versión 8 | 9 | Ninguno. 10 | 11 | ## Versión 4.0.0 2025-05-30 12 | 13 | Esta versión libera un cambio mayor provocado por el cambio de columnas que implementó el SAT. 14 | 15 | En la información de *Metadata* se ajusta la información a la nueva estructura de datos del SAT: 16 | 17 | - Ya no existe `fechaProcesoCancelacion`. 18 | - Se agrega `fechaSolicitudCancelacion`. 19 | - Se agrega `fechaDeCancelacion`. 20 | 21 | Gracias totales a `@cruzcraul` por notar este cambio y la implementación. 22 | 23 | Adicionalmente, se hicieron los siguientes cambios por tratarse de una versión mayor: 24 | 25 | - Se renombra el parámetro del constructor de `Scraper` de `$maximumRecordsHandler` a `$metadataMessageHandler`. 26 | - Se elimina el método `SatHttpGateway::postLoginData()`, sustituido por `SatHttpGateway::postCiecLoginData()`. 27 | - Se elimina la interfaz `MaximumRecordsHandler`, sustituida por `MetadataMessageHandler`. 28 | 29 | Adicionalmente, se hicieron estos cambios al entorno de desarrollo: 30 | 31 | - Se actualizan las herramientas de desarrollo. 32 | 33 | ## Versión 3.3.3 2024-10-03 34 | 35 | - Se agrega la documentación principal para usar el resolvedor de captchas 36 | [`phpcfdi/image-captcha-resolver-boxfactura-ai`](https://github.com/phpcfdi/image-captcha-resolver-boxfactura-ai). 37 | - Se agrega la documentación de `docs/EjemploConsumo.md` para usar el resolvedor de captchas `BoxFacturaAIResolver`, 38 | con todos los pasos para hacer correr el ejemplo. 39 | - En el archivo `composer.json` se recomienda `phpcfdi/image-captcha-resolver-boxfactura-ai`. 40 | 41 | Los siguientes cambios aplican para el entorno de desarrollo: 42 | 43 | - Se modifica el archivo `composer.json` para: 44 | - Requiere `phpcfdi/image-captcha-resolver-boxfactura-ai`. 45 | - Utiliza `phpcfdi/image-captcha-resolver-boxfactura-ai` para PHP 8.1 en adelante. 46 | - Desinstala `phpcfdi/image-captcha-resolver-boxfactura-ai` para menores de PHP 8.1. 47 | - Se actualiza la documentación de `develop/TestIntegracion.md` donde se remueve `eclipxe/captcha-local-resolver` 48 | y se menciona `phpcfdi/image-captcha-resolver-boxfactura-ai`. 49 | - Se cambian las pruebas de integración para usar `phpcfdi/image-captcha-resolver-boxfactura-ai`. 50 | - Se actualizan las herramientas de desarrollo. 51 | 52 | Esta actualización únicamente se ha podido hacer gracias al trabajo de investigación y entrenamiento de un modelo Onnx 53 | de inteligencia artificial de nuestros amigos de [BOX Factura](https://www.boxfactura.com/). 54 | Su trabajo en el repositorio [`BoxFactura/sat-captcha-ai-model`](https://github.com/BoxFactura/sat-captcha-ai-model) 55 | permitió crear el resolvedor `BoxFacturaAIResolver`, pero, sobre todo, simplificar la resolución de captchas, 56 | tanto en forma local como en producción. 57 | **Muchas gracias**. 58 | 59 | ## Versión 3.3.2 2024-09-09 60 | 61 | - PHPStan encontró una comparación superflua que fue eliminada para corregir el proceso de integración continua. 62 | - Se agregan comentarios a clases *Null* para mejorar la mantenibilidad. 63 | - Se actualiza el año del archivo de licencia a 2024. 64 | - Se corrige la variable `php-versions` por `php-version` en el flujo de trabajo `tests`. 65 | - Se actualizan las herramientas de desarrollo. 66 | 67 | ## Versión 3.3.1 2024-05-22 68 | 69 | - PHPStan encontró un problema en una especificación de tipo en un método de prueba, 70 | se ha corregido solo para que el proceso de integración continua no falle. 71 | - Se actualizan las dependencias de los componentes de Symfony para soportar la versión 7. 72 | - Se actualizan los flujos de trabajo de GitHub para usar las acciones versión 4. 73 | - Se usa `php-version` en singular, en lugar de `php-versions`. 74 | - Se actualizan las herramientas de desarrollo. 75 | 76 | ## Versión 3.3.0 2023-12-03 77 | 78 | Se agregó la interfaz `MetadataMessageHandler` que permite recibir notificaciones de la descarga de *Metadata*. 79 | Dentro de las notificaciones se incluye la que ocurre cuando se encontraron 500 registros en un solo segundo. 80 | 81 | Se deprecó la interfaz `MaximumRecordsHandler`, es sustituida por `MetadataMessageHandler`. 82 | 83 | Se deprecó el método `SatScraper::getMaximumRecordsHandler()` a favor de `SatScraper::getMetadataMessageHandler()`. 84 | 85 | Para no introducir un cambio que rompa la compatibilidad, el constructor de `SatScraper` sigue soportando la 86 | creación del objeto con el argumento `MaximumRecordsHandler $maximumRecordsHandler`. 87 | En su lugar, debería enviar un objeto que implemente la interfaz `MetadataMessageHandler`. 88 | 89 | Se introduce el objeto `NullMetadataMessageHandler` que implementa la interfaz `MetadataMessageHandler`, 90 | pero no realiza ninguna acción en sus métodos. 91 | 92 | Otros cambios al entorno de desarrollo: 93 | 94 | - Se actualizan las dependencias de desarrollo. 95 | - Se agrega PHP 8.3 a la matrix de pruebas. 96 | - Los trabajos se ejecutan con PHP 8.3. 97 | - Para `php-cs-fixer` se sustituye `function_typehint_space` con `type_declaration_spaces`. 98 | 99 | ## Versión 3.2.5 2023-07-03 100 | 101 | Algunos métodos intentaban atrapar una excepción `RuntimeException` proveniente de `Crawler`, sin embargo, 102 | la excepción no era correcta, se atrapa ahora `Throwable`. Gracias a PHPStan por detectar el problema. 103 | 104 | Se actualizan las dependencias de desarrollo. 105 | 106 | ## Versión 3.2.4 2023-06-22 107 | 108 | Se corrige el mensaje relacionado con el envío de datos incorrectos al iniciar sesión usando CIEC. 109 | 110 | Se corrige la dependencia de `CaptchaImage` por `CaptchaImageInterface` en `CiecLoginException`. 111 | 112 | Se extrae la lógica para hacer la petición de acceso vía CIEC a un método separado. 113 | En una prueba de concepto esto ayuda a crear la sesión usando un valor conocido de *Captcha*. 114 | 115 | Se agregan los siguientes cambios en el entorno de desarrollo: 116 | 117 | - Se corrige la liga del proyecto en el archivo `CONTRIBUTING.md`. 118 | - Se actualizan las herramientas de desarrollo. 119 | - Se agrega la herramienta `composer-normalize`. 120 | - En el flujo de trabajo de cobertura de código se ejecuta usando PHP 8.2. 121 | - Se elimina `PHP_CS_FIXER_IGNORE_ENV` del flujo de trabajo principal en el trabajo `php-cs-fixer`. 122 | - Se agrega la opción para ejecutar flujos de trabajo a solicitud. 123 | 124 | ### Cambios no liberados: 2023-02-13 125 | 126 | - Se corrige la configuración de `sonar-project.properties` para excluir correctamente los archivos para pruebas. 127 | - Se excluye correctamente el archivo `sonar-project.properties` del paquete de Git. 128 | 129 | ## Versión 3.2.3 2023-05-25 130 | 131 | - Se actualiza la dependencia de `guzzlehttp/promises` a versión mínima 2.0. 132 | - Se actualiza la dependencia de `psr/http-message` a versiones mínimas 1.1 o 2.0. 133 | - Se actualiza la dependencia de `phpcfdi/image-captcha-resolver` a versión mínima 0.2.3. 134 | 135 | Los siguientes cambios aplican al entorno de desarrollo: 136 | 137 | - La ejecución de `php-cs-fixer` dentro de `composer` se condiciona a mínimo PHP 8.0. 138 | - Se refactoriza la clase `RepositoryItem` para que las responsabilidades de la creación de una instancia 139 | a partir de un arreglo se realizen en la clase `RepositoryItemFactory`. 140 | - Se corrigen las pruebas para usar `psr/http-message:^2.0`. 141 | - Se corrige el issue falso positivo encontrado por PHPStan al convertir un objeto a cadena de caracteres. 142 | - Actualización de herramientas de desarrollo. 143 | 144 | También se concluyen los siguientes cambios previos no liberados. 145 | 146 | ### Cambios no liberados: 2023-02-13 147 | 148 | - Actualización de herramientas de desarrollo. 149 | - Se agrega la configuración en `composer.json` para no permitir el uso de *plugins* de `php-http/discovery`. 150 | - En las pruebas, se refactoriza `SatHttpGatewayTest::testMethodPostLoginDataIsDeprecated` para probar que 151 | el método `postLoginData` está deprecado, dado que PHPUnit 9.6 descontinuó el método `expectDeprecation`. 152 | 153 | ### Cambios no liberados: 2023-01-31 154 | 155 | - Actualización de herramientas de desarrollo. 156 | - En las pruebas, se elimina una anotación para PHPStan para ignorar un error al realizar `unset` sobre una 157 | variable indefinida en un objeto de tipo `Metadata`. 158 | 159 | ## Versión 3.2.2 160 | 161 | ### Regresar *Motivo de cancelación* y *Folio de sustitución* 162 | 163 | Se regresa la lectura de *Motivo de cancelación* (`motivoCancelacion`) y *Folio de sustitución* (`folioSustitucion`). 164 | Aparentemente, en la fecha 2023-01-12 el SAT ha regresado estas columnas. 165 | 166 | ## Versión 3.2.1 167 | 168 | ### Quitar *Motivo de cancelación* y *Folio de sustitución* 169 | 170 | Se elimina la lectura de *Motivo de cancelación* (`motivoCancelacion`) y *Folio de sustitución* (`folioSustitucion`). 171 | Aparentemente, en la fecha 2023-01-04 el SAT ha eliminado estas columnas. 172 | 173 | ### Otros cambios menores 174 | 175 | - Actualización de licencia a 2023. ¡Feliz año!. 176 | - Actualización de flujos de trabajo sustituyendo la directiva `::set-output` con `$GITHUB_OUTPUT`. 177 | - Corrección de la insignia del flujo de construcción `build`. 178 | 179 | ### Cambios previos 180 | 181 | #### 2022-11-09: Corrección de construcción de integración continua 182 | 183 | - Se actualizaron las herramientas de desarrollo. 184 | - Se agrega PHP 8.2 a la matriz de pruebas en el proceso de integración continua. 185 | - Se corrige la firma (`phpdoc`) del método `HttpLogger::bodyToVars`. 186 | - Se corrige el método `Repository::randomize` pues perdía las llaves del arreglo. 187 | - Se corrige el archivo de configuración de `php-cs-fixer` porque la regla `no_trailing_comma_in_singleline_array` está deprecada. 188 | 189 | #### 2022-10-22: Corrección de construcción de integración continua 190 | 191 | - Se actualizaron las herramientas de desarrollo. 192 | - Se aplicó la corrección de `php-cs-fixer`. 193 | - Se corrigió el nombre de usuario de `@git-micotito` en este mismo archivo. 194 | 195 | ## Versión 3.2.0 196 | 197 | ### Agregar *Motivo de cancelación* y *Folio de sustitución* 198 | 199 | Se agrega la lectura de *Motivo de cancelación* (`motivoCancelacion`) y *Folio de sustitución* (`folioSustitucion`) a `Metadata`. Así como la extracción de estos datos en `MetadataExtractor`. 200 | Gracias `@TheSpectroMx`. 201 | 202 | ## Versión 3.1.2 203 | 204 | ### Filtrado de recursos incorrecto 205 | 206 | Problema: Si el objeto `Metadata` contenía la entrada del recurso, pero estaba vacía, 207 | entonces la función `hasResource` devolvía verdadero. Esto hacía que fallara el filtrado. 208 | Se corrigió el problema comparando contra el valor vacío y no contra la existencia de la llave. 209 | Gracias `@git-micotito` por la detección del problema. 210 | 211 | ### Actualización de `eclipxe/micro-catalog` 212 | 213 | La nueva versión de `eclipxe/micro-catalog` necesita la especificación del tipo de datos 214 | para `MicroCatalog` en la clase `ComplementsOption`. 215 | 216 | ### Actualización de herramientas de desarrollo 217 | 218 | - Se actualizan las herramientas. 219 | - Se elimina la regla `method_argument_space` para dejar la definición por defecto de PSR-12. 220 | 221 | ## Versión 3.1.1 222 | 223 | ### Cambios en el código 224 | 225 | Se admite la compatibilidad con Symfony 6. Esto evita que se tengan que degradar componentes a la versión 5. 226 | 227 | Se depreca `SatHttpGatewayException::postLoginData` para crear el método específico 228 | `SatHttpGatewayException::postCiecLoginData`. Esto no altera la funcionalidad actual. 229 | 230 | Se agrega la dependencia faltante `mbstring`. 231 | 232 | ### Cambios en el entorno de desarrollo 233 | 234 | Se mejoran los test para probar valores idénticos en lugar de valores iguales. 235 | 236 | Se actualizan las herramientas de desarrollo. 237 | 238 | Se actualiza el archivo de configuración de `php-cs-fixer`. 239 | 240 | ## Versión 3.1.0 241 | 242 | ### Agregar *RFC a cuenta de terceros* 243 | 244 | Se agrega el filtro `RfcOnBehalfOption`. 245 | Se agrega la lectura de esta información en `Metadata` como `rfcACuentaTerceros`. 246 | Se agrega la documentación para filtrar y leer el campo de *RFC a cuenta de terceros*. 247 | 248 | ### Acceso por propiedades a `Metadata`. 249 | 250 | Se documentan las propiedades en `Metadata` para acceder a ellas usando, por ejemplo `$metadata->uuid`. 251 | 252 | ### Documentación 2022-03-01 253 | 254 | Se agrega la documentación para configurar el cliente de cURL con `DEFAULT@SECLEVEL=1`. 255 | 256 | Las pruebas se corren con `DEFAULT@SECLEVEL=1`. 257 | 258 | Se agrega el código que ejemplifica cómo validar que la FIEL no es un CSD y que es válido al momento de la consulta. 259 | 260 | ### Entorno de desarrollo 2022-03-01 261 | 262 | Al ejecutar el flujo de integración continua, se usan los path en el archivo `phpcs.xml.dist`. 263 | 264 | ## Versión 3.0.0 265 | 266 | Vea la [Guía de actualización de `2.x` a `3.x`](UPGRADE-2-3.md). 267 | 268 | Este es el listado de cambios más relevantes: 269 | 270 | - A partir de esta versión se puede realizar la autenticación del cliente utilizando FIEL. 271 | - El método `SatScraper::registerOnPortalMainPage` fue renombrado a `SatScraper::accessPortalMainPage`. 272 | - Se cambió la extracción y resolución de captchas a la librería 273 | [`phpcfdi/image-captcha-resolver`](https://github.com/phpcfdi/image-captcha-resolver). 274 | - Se cambió el manejador de máximo de registros de una función *callable* `callable(DateTimeImmutable): void` 275 | a una interfaz `MaximumRecordsHandler`. 276 | - Se eliminan las extensiones que estaban requeridas, pero no están más en uso: `libxml`, `simplexml` y `filter`. 277 | - Se actualiza toda la documentación del proyecto. 278 | 279 | Cambios relevantes en desarrollo: 280 | 281 | - Se cambia de `development/install-development-tools` a `phive`. 282 | - Se mejoraron los bloques `phpdoc`. 283 | - El proyecto se ha integrado con SonarCloud y se están utilizando sus métricas: 284 | . 285 | - Se deja de usar la integración con Scrutinizer CI. Gracias Srutinizer. 286 | 287 | ## Versión 2.1.1 288 | 289 | Se corrige un bug al consumir el servicio de Anti-Captcha donde estaba asumiendo que el código de error 290 | era un string vacío cuando en realidad es un número entero. 291 | 292 | - 2021-07-05: Tests: En las pruebas de `AntiCaptchaTinyClient` las respuestas preparadas no tenían correctamente 293 | formados los `HEADERS`. 294 | 295 | - 2021-07-05: CI: Se permite que falle la subida del archivo de cobertura de código a Scrutinizer-CI. 296 | 297 | ## Versión 2.1.0 298 | 299 | Se agrega la implementación para resolver el *captcha* en la clase `AntiCaptchaResolver`, 300 | que a su vez usa la clase `AntiCaptchaTinyClient` como un cliente de conectividad mínimo. 301 | 302 | Se modifica el entorno de desarrollo y bloques de documentación de PHP para asegurar la construcción del proyecto. 303 | Estos cambios no son importantes si estás usando la librería y son con respecto a desarrollo interno. 304 | 305 | Los flujos de pruebas de integración contínua ahora se migraron a GitHub Actions, 306 | Travis-CI ha sido de gran ayuda en el desarrollo de este proyecto. 307 | 308 | ## Versión 2.0.0 309 | 310 | ### Descarga de diferentes tipos de recursos 311 | 312 | Hasta la versión `1.x` el scraper solo descargaba los archivos de CFDI de tipo XML. 313 | A partir de la versión `2.x` es posible descargar 4 tipos diferentes definidos en el enumerador `ResourceType`: 314 | 315 | - El tipo `ResourceType::xml()` es para el archivo XML del CFDI. 316 | - El tipo `ResourceType::pdf()` es para la representación impresa en formato PDF del CFDI. 317 | - El tipo `ResourceType::cancelRequest()` es para la solicitud de cancelación del CFDI en formato PDF. 318 | - El tipo `ResourceType::cancelVoucher()` es para el acuse de cancelación del CFDI en formato PDF. 319 | 320 | El método `SatScraper::xmlDownloader` ha cambiado a `SatScraper::resourceDownloader`. 321 | 322 | Las clases llamadas `XmlDownload...` ahora se llaman `ResourceDownload...`. 323 | Esto incluye clases de la API, contratos, excepciones, clases internas, etc. 324 | 325 | Se actualizó la documentación y ejemplos para la nueva API. 326 | 327 | ### Cambios desde 2020-10-14 328 | 329 | Este cambio no afectó la versión liberada y no requiere de un nuevo release. 330 | 331 | - La construcción en Travis-CI se rompió porque PHPStan version 0.12.55 ya entiende las estructuras de control 332 | de PHPUnit, por lo que sabe que el código subsecuente es código muerto. Se corrigieron las pruebas con problemas. 333 | - Se actualizó la herramienta `develop/install-development-tools` 334 | 335 | ## Versión 1.0.1 336 | 337 | - Se actualizan dependencias: 338 | - `symfony/dom-crawler` de `^4.2|^5.0` a `5.1`. 339 | - `symfony/css-selector` de `^4.2|^5.0` a `5.1`. 340 | - `guzzlehttp/guzzle` de `^6.3` a `7.0`. 341 | - Se corrigen las descripciones de las clases `DownloadType`, `ComplementsOption`, `RfcOption`, `StatesVoucherOption` 342 | y `UuidOption`. 343 | - Se agregó una sección en el README *Verificar datos de autenticación sin hacer una consulta* (issue #35). 344 | - Se cambia en desarrollo la inicialización de `Dotenv` porque se deprecó la forma anterior en `symfony/dotenv: ^5.1`. 345 | - Se cambia en desarrollo la dependencia de `symfony/dotenv` de `^4.2|^5.0` a `^5.1`. 346 | 347 | ## Versión 1.0.0 348 | 349 | - Se establece la versión mínima de PHP a 7.3. 350 | - Se revisan las expresiones regulares y `json_encode`/`json_decode` con el paso a 7.3. 351 | - Se cambia la versión de PHPUnit a 9.1. 352 | - Se corrige `linguist-detectable=false` para los archivos en `tests/_files` que estaba mal puesto. 353 | 354 | ## UNRELEASED 2020-04-12 355 | 356 | - El filtro por complemento `ComplementsOption` ya no es un `Enum`, ahora es un `MicroCatalog`. 357 | De esta forma se puede tener mucha más información relacionada con el complemento y por ejemplo 358 | poder ofrecer una lista de opciones de catálogos. 359 | - La modificación de `ComplementsOption` es compatible con la forma de crear los objetos y de comprobar si es 360 | de un tipo en especial (por ejemplo: `ComplementsOption::todos()` y `ComplementsOption::isTodos()`). 361 | 362 | ## UNRELEASED 2020-02-23 363 | 364 | En este release se cambió totalmente la librería, tanto en el exterior como en el funcionamiento interno. 365 | 366 | Los cambios más importantes para los usuarios de la librería son: 367 | 368 | - La consulta por filtros ya no usa `Query`, que dejó de existir, ahora se llama `QueryByFilters`. 369 | - Los métodos para obtener el *metadata* han cambiado a `listByUuids`, `listByPeriod` y `listByDateTime`. 370 | - El método para crear el descargador de XML ahora se llama `xmlDownloader` y recibe optionalmente todos los parámetros. 371 | - Si se quiere personalizar el descargador se debe hacer implementando la interfaz `XmlDownloadHandlerInterface`. 372 | - Los datos para poder autenticarse con el SAT se almacenan en un objeto `SatSessionData`. 373 | - Se crea toda una estructura para excepciones: 374 | - Todas implementan la interfaz `SatException`. 375 | - Las excepciones usan las SPL de PHP: `RuntimeException`, `InvalidArgumentException` y `LogicException`. 376 | - Las excepciones lógicas indican que debes tener un error en la forma en que estás usando la aplicación. 377 | - Las excepciones de tiempo de ejecución es porque algo inesperado ha ocurrido, pero no necesariamente es 378 | por un error en la implementación. 379 | - Los problemas relacionados con el proceso de autenticación son `LoginException`. 380 | - Los problemas relacionados con las transacciones HTTP con el SAT son `SatHttpGatewayException`, 381 | con dos especializaciones: `SatHttpGatewayClientException` y `SatHttpGatewayResponseException`. 382 | - Los problemas relacionados con la ejecución de la descarga de XML se son `XmlDownloadError`, 383 | con dos especializaciones: `XmlDownloadRequestExceptionError` y `XmlDownloadResponseError`. 384 | 385 | Los cambios importantes al interior de la librería son: 386 | 387 | - La estructura para obtener el listado de metadata ahora pasa por: `SatScraper` crea y ejecuta un `MetadataDownloader` 388 | que crea y ejecuta un `QueryResolver` que crea y ejecuta un `MetadataExtractor`. 389 | - La generación de los datos que se envían por POST para seleccionar el tipo de consulta y ejecutarla se cambian 390 | a objetos `Input`. Estos objetos son especializaciones que a partir de la consulta generan los inputs adecuados. 391 | 392 | ## UNRELEASED 2020-02-14 393 | 394 | Estos son algunos de los cambios más importantes relacionados con la compatibilidad si está usando una versión previa. 395 | 396 | - Se crea el `SatHttpGateway` que encierra las comunicaciones con el SAT, es este el que utiliza 397 | el cliente de Guzzle (`GuzzleInterface`). 398 | - Se cambió el constructor del `SatScraper`, ahora el tercer parámetro es el resolvedor de captchas 399 | y el cuarto parámetro es un `SatHttpGateway` y es opcional (por si la cookie es solo de memoria). 400 | - Se cambia el objeto `DownloadXml`, antes funcionaba con un callable, ahora funciona con una interfaz 401 | de tipo `DownloadXmlHandlerInterface` para forzar las firmas de los métodos. También devuelve al 402 | hacer `download` o `saveTo` el listado de UUID descargados. 403 | - Se cambió el inicio de sesión en el SAT después de revisar el funcionamiento actual, ahora es más limpio 404 | y con menos llamadas. 405 | - Los filtros solamente llenan los input que deberían llenar. 406 | - La clase `DownloadTypesOption` ya no es un filtro, pero sigue siendo un `Enum`. 407 | - Se removieron las constantes `URLS::SAT_HOST_CFDI_AUTH`, `URLS::SAT_HOST_PORTAL_CFDI` y `URLS::SAT_URL_PORTAL_CFDI_CONSULTA` 408 | - Se movieron las clases internas al espacio de nombres interno. 409 | 410 | ## UNRELEASED 2020-02-10 411 | 412 | - Se cambia la interfaz `PhpCfdi\CfdiSatScraper\Contracts\CaptchaResolverInterface`, la nueva forma de uso 413 | sería: `$answer = $resolver->decode($image);` 414 | - Cambia `function decode(): ?string` a `function decode(string $base64Image): string`. 415 | - Se elimina `function setImage(string $base64Image): self` 416 | 417 | ## UNRELEASED 2020-02-09 418 | 419 | - Se corrigió un bug que no permitía descargar por UUID 420 | - Se cambió el objeto `\PhpCfdi\CfdiSatScraper\Filters\Options\RfcReceptorOption` a 421 | `PhpCfdi\CfdiSatScraper\Filters\Options\RfcOption`. 422 | - Se agregaron test de integración con información basada en un *único punto de verdad*. 423 | Consulta la guía en `develop/docs/TestIntegracion.md`. 424 | - Se agregó integración contínua con Travis-CI & Scrutinizer. 425 | - Se establece análisis de código a `phpstan level 5`. 426 | 427 | ## UNRELEASED 2020-01-28 428 | 429 | - Se corrigió el problema de descargas del SAT, la librería estaba en un estado no usable. 430 | - Se inició con el proceso de llevar la librería a una versión mayor `1.0.0`. 431 | -------------------------------------------------------------------------------- /docs/EjemploConsumo.md: -------------------------------------------------------------------------------- 1 | # Ejemplo de consumo 2 | 3 | Este ejemplo está documentado con las siguientes consideraciones: 4 | 5 | - El RFC está en el entorno en la variable `SAT_AUTH_RFC` 6 | - La clave CIEC está en el entorno en la variable `SAT_AUTH_CIEC` 7 | - Desde donde se está llamando al código existen las carpetas `build/cookies/` y `build/cfdis/` 8 | - Se está usando el objeto `BoxFacturaAIResolver`, así que se espera que el captcha se resuelva automáticamente. 9 | - El modelo de resolución de captcha está en la carpeta `storage/boxfactura-model`. 10 | 11 | Y se espera que: 12 | 13 | - Se pueda reutilizar la `cookie` si no ha expirado y así no tener que volver a resolver un captcha. 14 | - Se carge una lista de CFDI recibidos y vigentes entre 2019-12-01 y 2019-12-31. 15 | - Ocurra la descarga de los XML correspondientes a dichos registros. 16 | 17 | La rutina de descarga intentará hasta que haya descargado todos los archivos. 18 | 19 | ## Instalación de dependencias 20 | 21 | ### Instalación de los paquetes 22 | 23 | Paquetes base: 24 | 25 | ```shell 26 | composer require phpcfdi/cfdi-sat-scraper phpcfdi/image-captcha-resolver-boxfactura-ai 27 | ``` 28 | 29 | Instalación de la librería `libonnxruntime.so` para poder interpretar modelos Onnx: 30 | 31 | ```shell 32 | composer run-script post-update-cmd -d vendor/ankane/onnxruntime/ 33 | ``` 34 | 35 | Instalación del modelo Onnx para resolver el captcha: 36 | 37 | ```shell 38 | bash vendor/phpcfdi/image-captcha-resolver-boxfactura-ai/bin/download-model storage/boxfactura-model 39 | ``` 40 | 41 | ## Ejemplo de ejecución 42 | 43 | Archivo `demo-ciec.php`: 44 | 45 | ```php 46 | [CURLOPT_SSL_CIPHER_LIST => 'DEFAULT@SECLEVEL=1'], 70 | ]); 71 | $gateway = new SatHttpGateway($client, new FileCookieJar($cookieJarPath, true)); 72 | 73 | $configsFile = __DIR__ . '/storage/boxfactura-model/configs.yaml'; 74 | $captchaResolver = BoxFacturaAIResolver::createFromConfigs($configsFile); 75 | 76 | $ciecSessionManager = CiecSessionManager::create($rfc, $claveCiec, $captchaResolver); 77 | 78 | $satScraper = new SatScraper($ciecSessionManager, $gateway); 79 | 80 | $resourceDownloader = $satScraper->resourceDownloader(ResourceType::xml()) 81 | ->setConcurrency(20); 82 | 83 | $query = new QueryByFilters(new DateTimeImmutable('2019-12-01'), new DateTimeImmutable('2019-12-31')); 84 | $query->setDownloadType(DownloadType::recibidos()) // default: emitidos 85 | ->setStateVoucher(StatesVoucherOption::vigentes()); // default: todos 86 | 87 | $list = $satScraper->listByPeriod($query); 88 | printf("\nSe encontraron %d registros", $list->count()); 89 | 90 | $list = $list->filterWithResourceLink(ResourceType::xml()); 91 | printf("\nPero solamente %d registros contienen archivos XML", $list->count()); 92 | 93 | while ($list->count() > 0) { 94 | // perform download 95 | printf("\nIntentando descargar %d archivos: ", $list->count()); 96 | $downloadedUuids = $resourceDownloader->setMetadataList($list) 97 | ->saveTo($downloadsPath, true); 98 | printf('%d descargados.', count($downloadedUuids)); 99 | 100 | // check that at least one uuid were downloaded 101 | if ([] === $downloadedUuids) { 102 | printf("\nNo se pudieron descargar %d registros", $list->count()); 103 | break; // exit loop since no records were downloaded 104 | } 105 | 106 | // reduce list 107 | $list = $list->filterWithOutUuids($downloadedUuids); 108 | } 109 | ``` 110 | 111 | Creación de los directorios necesarios en el script: 112 | 113 | ```shell 114 | mkdir -p build/cookies build/cfdis 115 | ``` 116 | 117 | Ejecución de `demo-ciec.php`: 118 | 119 | ```shell 120 | env SAT_AUTH_RFC="COSC8001137NA" SAT_AUTH_CIEC="******" php demo-ciec.php 121 | ``` 122 | 123 | Que responde: 124 | 125 | ```text 126 | Se encontraron 385 registros 127 | Pero solamente 385 registros contienen archivos XML 128 | Intentando descargar 385 archivos: 385 descargados. 129 | ``` 130 | -------------------------------------------------------------------------------- /docs/SEMVER.md: -------------------------------------------------------------------------------- 1 | # SEMVER 2 | 3 | Respetamos el estándar [Versionado Semántico 2.0.0](https://semver.org/lang/es/). 4 | 5 | En resumen, [SemVer](https://semver.org/) es un sistema de versiones de tres componentes `X.Y.Z` 6 | que nombraremos así: ` Breaking . Feature . Fix `, donde: 7 | 8 | - `Breaking`: Rompe la compatibilidad de código con versiones anteriores. 9 | - `Feature`: Agrega una nueva característica que es compatible con lo anterior. 10 | - `Fix`: Incluye algún cambio (generalmente correcciones) que no agregan nueva funcionalidad. 11 | 12 | ## Composer 13 | 14 | La herramienta [Composer](https://getcomposer.org/) es un gestor de dependencias en proyectos para PHP. 15 | Este gestor usa las [reglas](https://getcomposer.org/doc/articles/versions.md) 16 | de versionado semántico para instalar y actualizar paquetes. 17 | 18 | Te recomendamos instalar dependencias de librerías (no frameworks) con *Caret Version Range*. 19 | Por ejemplo: `"vendor/package": "^2.5"`. 20 | 21 | Esto significa que: 22 | 23 | - no debe actualizar a versiones `3.x.x` 24 | - no debe utilizar ninguna versión menor a `2.5.0` 25 | 26 | ## Versiones 0.x.y no rompe compatibilidad 27 | 28 | Las versiones que inician con cero, por ejemplo `0.y.z`, no se ajustan a las reglas de versionado. 29 | Se considera que estas versiones son previas a la madurez del proyecto y, por lo tanto, 30 | introducen cambios sin previo aviso. 31 | 32 | ## `@internal` no rompe compatibilidad 33 | 34 | Si la librería contiene elementos marcados como `@internal` significa que no deben ser utilizados 35 | por tu código. Son partes de código internos de la librería. 36 | Por lo tanto, no se consideran breaking changes. 37 | 38 | Cuando un elemento es `@internal`, dicho elemento: 39 | 40 | - no debe ser una entrada (parámetro) 41 | - no debe ser una salida (retorno) 42 | - no debe exponer funcionalidades en los objetos públicos (rasgos) 43 | -------------------------------------------------------------------------------- /docs/TODO.md: -------------------------------------------------------------------------------- 1 | # phpcfdi/cfdi-sat-scraper tareas pendientes 2 | 3 | ## Siguiente versión mayor 4 | 5 | Ninguno. 6 | 7 | ## Pendientes 8 | 9 | - Core: 10 | - Incrementar las pruebas unitarias a un mínimo de 80%. 11 | - Verificar si el SAT sigue teniendo problemas de conectividad y se puede usar `DEFAULT@SECLEVEL=2`. 12 | - Documentación: 13 | - Todos los puntos de entrada deben tener phpdoc. 14 | - Entorno de desarrollo: 15 | - Crear un entorno disponible en el workflow de CI para correr pruebas de integración 16 | con un FIEL, CIEC, resolvedor de captchas y repositorio. 17 | 18 | ## Wishlist 19 | 20 | - Code coverage 100%. 21 | 22 | ## Realizadas 23 | 24 | - 2025-05-30: Versión 4. 25 | - Eliminar el método `SatHttpGatewayException::postLoginData`. 26 | 27 | - 2021-10-01: Versión 3. 28 | - Se agrega el registro en el portal del SAT usando FIEL. 29 | - Se usa la librería *Image Captcha Resolver* en lugar de los conectores en esta misma librería. 30 | - CodeStyle: Subir a PSR-12. 31 | - Definir el estilo de código de la coma final en un listado de parámetros. 32 | 33 | - 2020-07-18: 34 | - Se actualizó `guzzlehttp/guzzle` a `^7.0`. 35 | - Se actualizó `symfony/dom-crawler` y `symfony/css-selector` a `^5.1`. 36 | 37 | - 2020-04-18: 38 | - Se actualizaron las versiones a `php:>=7.3` y `phpunit:^9.1`. 39 | - Al fin se libera la versión `1.0` del proyecto. 40 | - Gracias a [`rector`](https://github.com/rectorphp/rector/) en forma local se analizaron 41 | los cambios de versiones de `php` y `phpunit`. 42 | 43 | - 2020-02-26 44 | - Test de integración basados en configuración de entorno. 45 | - Renombrar los métodos relacionados con el punto de entrada, se llaman `download*()` cuando lo que hacen es 46 | obtener un `MetadataList`, y el método que crea un objeto de descarga se llama `downloader()`. 47 | 48 | - 2020-02-11: 49 | - Entorno de desarrollo: PhpStan: Nivel máximo sin `checkMissingIterableValueType`. 50 | 51 | - 2020-02-11: 52 | - Entorno de desarrollo: PhpStan: Nivel máximo, aunque se está omitiendo la verificación 53 | `checkMissingIterableValueType`, corregirla será bastante complejo por las dependencias. 54 | 55 | - 2020-01-29: 56 | - PHP Minimal version to PHP 7.2 (cambios en código, no solamente en composer.json) 57 | - Incluir badges en README. 58 | - Travis-CI: construir usando 7.2, 7.3 y 7.4. 59 | - Scrutinizer-CI: análisis de código y code coverage. 60 | - Herramientas de desarrollo usando phive o descargando directamente de github, no en composer.json. 61 | 62 | - 2020-01-28: Revisión de archivo README, documentación, explicación y ejemplos. 63 | 64 | -------------------------------------------------------------------------------- /docs/UPGRADE-2-3.md: -------------------------------------------------------------------------------- 1 | # Guía de actualización de `2.x` a `3.x` 2 | 3 | ## Creación del objeto `SatScraper` 4 | 5 | Anteriormente, el objeto `SatScraper` se construía utilizando los datos de la Clave CIEC, 6 | sin embargo, a partir de la versión 3 se puede utilizar la Clave CIEC o la FIEL. 7 | 8 | Luego entonces, esta es la nueva forma de construirlo: 9 | 10 | ```diff 11 | - new SatScraper(new SatSessionData('rfc', 'ciec', $captchaResolver)); 12 | + new SatScraper(CiecSessionManager::create('rfc', 'ciec', $captchaResolver)); 13 | ``` 14 | 15 | El método `SatScraper::registerOnPortalMainPage` fue renombrado a `SatScraper::accessPortalMainPage`. 16 | Este método es útil si se está comprobando si actualmente existe una sesión válida 17 | sin necesidad de volver a construirla. 18 | 19 | La clase `SatSessionData` ya no existe, ahora su equivalente es `CiecSessionData`. 20 | 21 | Ahora la clase `LoginException` es abstracta, y hay especializaciones en 22 | `CiecLoginException` y `FielLoginException`, con ambas se pueden acceder a los 23 | objetos de datos de sesión. 24 | 25 | ## Resolución de captchas 26 | 27 | Se cambió la extracción de captchas a la librería [`phpcfdi/image-captcha-resolver`](https://github.com/phpcfdi/image-captcha-resolver). 28 | 29 | Con este cambio, la interfaz de esta librería fue eliminada y ahora se usa la de *Image Captcha Resolver*. 30 | 31 | ```diff 32 | - use PhpCfdi\CfdiSatScraper\Contracts\CaptchaResolverInterface; 33 | + use PhpCfdi\ImageCaptchaResolver\CaptchaResolverInterface; 34 | ``` 35 | 36 | El servicio de *DeCaptcher* ya no está soportado debido a malas experiencias de varios usuarios de la librería, 37 | si lo deseas, podríamos integrarlo en la librería de *Image Captcha Resolver*, por favor considera poder 38 | patrocinar una cuenta para poder ejecutar pruebas de integración. 39 | 40 | Anteriormente, en `LoginException` se ponía el captcha en `image`. En la nueva clase `CiecLoginException` 41 | se incluye el método `getCaptchaImage` con el último objeto `CaptchaImage` que no se pudo resolver. 42 | 43 | ## Manejador del momento cuando se han alcanzado 500 registros 44 | 45 | Anteriormente, se usaba una función `callable` con la firma `callable(DateTimeImmutable): void`. 46 | 47 | Ahora se requiere una implementación del *contrato* `MaximumRecordsHandler`. 48 | Si al crear el objeto `SatScraper` no se establece un manejador o se establece como `null` entonces se usará 49 | una instancia de `NullMaximumRecordsHandler` que, como su nombre lo indica, no realiza ninguna acción. 50 | 51 | Si no estaba utilizando esta característica no es necesario hacer nada. 52 | 53 | Este es un ejemplo de cómo puede modificar su código para pasar de `callable` a `MaximumRecordsHandler`: 54 | 55 | ```php 56 | format('c'), PHP_EOL; 66 | }; 67 | 68 | // Ahora 69 | class MyHandler implements MaximumRecordsHandler 70 | { 71 | public function handle(DateTimeImmutable $moment) : void 72 | { 73 | echo 'Se encontraron más de 500 CFDI en el segundo: ', $date->format('c'), PHP_EOL; 74 | } 75 | } 76 | $onFiveHundred = new MyHandler(); 77 | 78 | /** 79 | * @var SessionManager $sessionManager 80 | * @var SatHttpGateway $httpGateway 81 | */ 82 | $satScraper = new SatScraper($sessionManager, $httpGateway, $onFiveHundred); 83 | ``` 84 | 85 | ## La clase `SatScraper` cumple con una interfaz 86 | 87 | La clase `SatScraper` pronto será marcada como final, y en esta versión se ha introducido la interfaz `SatScraperInterface`. 88 | 89 | Esta interfaz es útil para poder crear *mocks* y *stubs* para realizar pruebas unitarias en donde 90 | se esté implementando esta librería. 91 | 92 | ## Cambios técnicos 93 | 94 | Estos cambios son importantes solo si estás desarrollando o extendiendo esta librería. 95 | 96 | - Se ha creado la interfaz `SessionManager`, implementada en `CiecSessionManager` 97 | y `FielSessionManager` para controlar la sesión creada con el SAT. 98 | - Los métodos comunes a las implementaciones de `SessionManager` se han establecido 99 | en la clase abstracta `AbstractSessionManager`. 100 | - Se han agregado nuevos métodos a `SatHttpGateway`. 101 | - Las constantes de URL han sido renombradas para mayor simplicidad. 102 | - Las clases `Enum` ahora son finales. 103 | - La clase `CaptchaBase64Extractor` ahora es interna y depende de `CaptchaImage`. 104 | 105 | Se ha cambiado el archivo de entorno de pruebas `tests/.env-example` agregando nuevas variables. 106 | Para correr los test de integración se recomienda configurar tanto la Clave CIEC como la FIEL. 107 | 108 | 109 | ## Backwards incompatibility changes 110 | 111 | ```text 112 | [BC] CHANGED: Class PhpCfdi\CfdiSatScraper\Filters\Options\StatesVoucherOption became final 113 | [BC] CHANGED: Class PhpCfdi\CfdiSatScraper\Filters\DownloadType became final 114 | [BC] REMOVED: Constant PhpCfdi\CfdiSatScraper\URLS::SAT_URL_LOGIN was removed 115 | [BC] REMOVED: Constant PhpCfdi\CfdiSatScraper\URLS::SAT_URL_PORTAL_CFDI was removed 116 | [BC] REMOVED: Constant PhpCfdi\CfdiSatScraper\URLS::SAT_URL_PORTAL_CFDI_CONSULTA_RECEPTOR was removed 117 | [BC] REMOVED: Constant PhpCfdi\CfdiSatScraper\URLS::SAT_URL_PORTAL_CFDI_CONSULTA_EMISOR was removed 118 | [BC] CHANGED: PhpCfdi\CfdiSatScraper\Internal\ResourceDownloadStoreInFolder was marked "@internal" 119 | [BC] CHANGED: PhpCfdi\CfdiSatScraper\Internal\DownloadTypePropertyTrait was marked "@internal" 120 | [BC] CHANGED: The return type of PhpCfdi\CfdiSatScraper\Internal\DownloadTypePropertyTrait#setDownloadType() changed from no type to self 121 | [BC] CHANGED: The return type of PhpCfdi\CfdiSatScraper\Internal\DownloadTypePropertyTrait#setDownloadType() changed from no type to self 122 | [BC] CHANGED: The return type of PhpCfdi\CfdiSatScraper\Internal\DownloadTypePropertyTrait#setDownloadType() changed from no type to self 123 | [BC] CHANGED: The return type of PhpCfdi\CfdiSatScraper\MetadataList#getIterator() changed from no type to ArrayIterator 124 | [BC] REMOVED: Class PhpCfdi\CfdiSatScraper\Contracts\CaptchaResolverInterface has been deleted 125 | [BC] CHANGED: Class PhpCfdi\CfdiSatScraper\Exceptions\LoginException became abstract 126 | [BC] REMOVED: Method PhpCfdi\CfdiSatScraper\Exceptions\LoginException::notRegisteredAfterLogin() was removed 127 | [BC] REMOVED: Method PhpCfdi\CfdiSatScraper\Exceptions\LoginException::noCaptchaImageFound() was removed 128 | [BC] REMOVED: Method PhpCfdi\CfdiSatScraper\Exceptions\LoginException::captchaWithoutAnswer() was removed 129 | [BC] REMOVED: Method PhpCfdi\CfdiSatScraper\Exceptions\LoginException::incorrectLoginData() was removed 130 | [BC] REMOVED: Method PhpCfdi\CfdiSatScraper\Exceptions\LoginException::connectionException() was removed 131 | [BC] REMOVED: Method PhpCfdi\CfdiSatScraper\Exceptions\LoginException#getSessionData() was removed 132 | [BC] REMOVED: Method PhpCfdi\CfdiSatScraper\Exceptions\LoginException#getPostedData() was removed 133 | [BC] CHANGED: The parameter $sessionData of PhpCfdi\CfdiSatScraper\Exceptions\LoginException#__construct() changed from PhpCfdi\CfdiSatScraper\SatSessionData to a non-contravariant string 134 | [BC] CHANGED: The parameter $contents of PhpCfdi\CfdiSatScraper\Exceptions\LoginException#__construct() changed from string to a non-contravariant Throwable|null 135 | [BC] CHANGED: The parameter $sessionData of PhpCfdi\CfdiSatScraper\Exceptions\LoginException#__construct() changed from PhpCfdi\CfdiSatScraper\SatSessionData to string 136 | [BC] CHANGED: The parameter $contents of PhpCfdi\CfdiSatScraper\Exceptions\LoginException#__construct() changed from string to Throwable|null 137 | [BC] CHANGED: Parameter 1 of PhpCfdi\CfdiSatScraper\Exceptions\LoginException#__construct() changed name from sessionData to contents 138 | [BC] CHANGED: Parameter 2 of PhpCfdi\CfdiSatScraper\Exceptions\LoginException#__construct() changed name from contents to previous 139 | [BC] REMOVED: Property PhpCfdi\CfdiSatScraper\SatScraper#$onFiveHundred was removed 140 | [BC] REMOVED: Method PhpCfdi\CfdiSatScraper\SatScraper#getSatSessionData() was removed 141 | [BC] REMOVED: Method PhpCfdi\CfdiSatScraper\SatScraper#getOnFiveHundred() was removed 142 | [BC] CHANGED: The parameter $sessionData of PhpCfdi\CfdiSatScraper\SatScraper#__construct() changed from PhpCfdi\CfdiSatScraper\SatSessionData to a non-contravariant PhpCfdi\CfdiSatScraper\Sessions\SessionManager 143 | [BC] CHANGED: The parameter $onFiveHundred of PhpCfdi\CfdiSatScraper\SatScraper#__construct() changed from callable|null to a non-contravariant PhpCfdi\CfdiSatScraper\Contracts\MaximumRecordsHandler|null 144 | [BC] CHANGED: The parameter $sessionData of PhpCfdi\CfdiSatScraper\SatScraper#__construct() changed from PhpCfdi\CfdiSatScraper\SatSessionData to PhpCfdi\CfdiSatScraper\Sessions\SessionManager 145 | [BC] CHANGED: The parameter $onFiveHundred of PhpCfdi\CfdiSatScraper\SatScraper#__construct() changed from callable|null to PhpCfdi\CfdiSatScraper\Contracts\MaximumRecordsHandler|null 146 | [BC] CHANGED: Parameter 0 of PhpCfdi\CfdiSatScraper\SatScraper#__construct() changed name from sessionData to sessionManager 147 | [BC] CHANGED: Parameter 2 of PhpCfdi\CfdiSatScraper\SatScraper#__construct() changed name from onFiveHundred to maximumRecordsHandler 148 | [BC] REMOVED: Class PhpCfdi\CfdiSatScraper\SatSessionData has been deleted 149 | [BC] REMOVED: Class PhpCfdi\CfdiSatScraper\Captcha\Resolvers\ConsoleCaptchaResolver has been deleted 150 | [BC] REMOVED: Class PhpCfdi\CfdiSatScraper\Captcha\Resolvers\AntiCaptchaResolver has been deleted 151 | [BC] REMOVED: Class PhpCfdi\CfdiSatScraper\Captcha\Resolvers\AntiCaptchaTinyClient\AntiCaptchaTinyClient has been deleted 152 | [BC] REMOVED: Class PhpCfdi\CfdiSatScraper\Captcha\Resolvers\DeCaptcherCaptchaResolver has been deleted 153 | [BC] REMOVED: Class PhpCfdi\CfdiSatScraper\Captcha\CaptchaBase64Extractor has been deleted 154 | ``` 155 | -------------------------------------------------------------------------------- /docs/UPGRADE-3-4.md: -------------------------------------------------------------------------------- 1 | # Guía de actualización de `3.x` a `4.x` 2 | 3 | ## Nuevos valores de `Metadata` 4 | 5 | El día 2025-05-30 el SAT implementó un cambio en su estructura de datos, lo que llevó a que ahora no se leyera 6 | correctamente la *Fecha de Proceso de Cancelación* (`fechaProcesoCancelacion`). 7 | 8 | El SAT en su lugar agregó dos campos: 9 | 10 | - *Fecha de Solicitud de Cancelación* (`fechaSolicitudCancelacion`). 11 | - *Fecha de Cancelación* (`fechaDeCancelacion`). 12 | 13 | ## Cambio en el constructor de `Scraper` 14 | 15 | El constructor de `Scraper` anteriormente recibía el parámetro `$maximumRecordsHandler` 16 | que ahora se ha renombrado a `$metadataMessageHandler`. 17 | 18 | ## Se elimina `MaximumRecordsHandler` 19 | 20 | La interfaz `MaximumRecordsHandler` se había deprecado desde la versión 3.3.0. 21 | En su lugar se debe usar `MetadataMessageHandler`. 22 | 23 | ## Se elimina `SatHttpGateway::postLoginData()` 24 | 25 | El método `SatHttpGateway::postLoginData()` se había deprecado desde la versión 3.1.1. 26 | En su lugar se debe usar `SatHttpGateway::postCiecLoginData()`. 27 | -------------------------------------------------------------------------------- /src/Contracts/FilterOption.php: -------------------------------------------------------------------------------- 1 | format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')), 20 | ); 21 | } 22 | 23 | public static function complementsOptionInvalidKey(string $key): self 24 | { 25 | return new self("The key '$key' is not registered as a valid option for ComplementsOption"); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exceptions/LogicException.php: -------------------------------------------------------------------------------- 1 | contents = $contents; 22 | } 23 | 24 | public function getContents(): string 25 | { 26 | return $this->contents; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Exceptions/ResourceDownloadError.php: -------------------------------------------------------------------------------- 1 | uuid = $uuid; 44 | $this->reason = $reason; 45 | } 46 | 47 | /** 48 | * @param string $uuid 49 | * @param mixed $reason 50 | * @return self 51 | */ 52 | public static function onRejected(string $uuid, $reason): self 53 | { 54 | $message = sprintf('Download of UUID %s was rejected, reason: %s', $uuid, static::reasonToString($reason)); 55 | return new self($message, $uuid, $reason); 56 | } 57 | 58 | /** 59 | * @param mixed $reason 60 | * @return string 61 | */ 62 | public static function reasonToString($reason): string 63 | { 64 | if ($reason instanceof Throwable) { 65 | return get_class($reason) . ': ' . $reason->getMessage(); 66 | } 67 | if (is_scalar($reason)) { 68 | return strval($reason); 69 | } 70 | if (is_object($reason) && is_callable([$reason, '__toString'])) { 71 | /** 72 | * Fix PHPStan false positive detecting cast from object to string 73 | * @phpstan-var Stringable $reason 74 | * @noinspection PhpMultipleClassDeclarationsInspection 75 | */ 76 | return strval($reason); 77 | } 78 | return print_r($reason, true); 79 | } 80 | 81 | /** 82 | * The UUID related to the error 83 | * 84 | * @return string 85 | */ 86 | public function getUuid(): string 87 | { 88 | return $this->uuid; 89 | } 90 | 91 | /** 92 | * The given reason of the exception 93 | * 94 | * @return mixed 95 | */ 96 | public function getReason() 97 | { 98 | return $this->reason; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Exceptions/ResourceDownloadRequestExceptionError.php: -------------------------------------------------------------------------------- 1 | getStatusCode()), 26 | $uuid, 27 | $response, 28 | ); 29 | } 30 | 31 | public static function emptyContent(ResponseInterface $response, string $uuid): self 32 | { 33 | return new self(sprintf('Download of CFDI %s return an empty body', $uuid), $uuid, $response); 34 | } 35 | 36 | public static function contentIsNotCfdi(ResponseInterface $response, string $uuid): self 37 | { 38 | return new self(sprintf('Download of CFDI %s return something that is not a CFDI', $uuid), $uuid, $response); 39 | } 40 | 41 | public static function contentIsNotPdf(ResponseInterface $response, string $uuid, string $mimeType): self 42 | { 43 | return new self(sprintf('Download of CFDI %s return something that is not a PDF (mime %s)', $uuid, $mimeType), $uuid, $response); 44 | } 45 | 46 | public static function onSuccessException(ResponseInterface $response, string $uuid, Throwable $throwable): self 47 | { 48 | if ($throwable instanceof self) { 49 | return $throwable; 50 | } 51 | return new self(sprintf('Download of CFDI %s was unable to handle fulfill', $uuid), $uuid, $response, $throwable); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Exceptions/RuntimeException.php: -------------------------------------------------------------------------------- 1 | $requestHeaders 26 | * @param array $requestData 27 | * @param GuzzleException $previous 28 | */ 29 | protected function __construct( 30 | string $message, 31 | string $httpMethod, 32 | string $url, 33 | array $requestHeaders, 34 | array $requestData, 35 | GuzzleException $previous 36 | ) { 37 | parent::__construct($message, $httpMethod, $url, $requestHeaders, $requestData, $previous); 38 | $this->clientException = $previous; 39 | } 40 | 41 | /** 42 | * Method factory 43 | * 44 | * @param string $when 45 | * @param string $method 46 | * @param string $url 47 | * @param array $requestHeaders 48 | * @param array $requestData 49 | * @param GuzzleException $exception 50 | * @return self 51 | */ 52 | public static function clientException(string $when, string $method, string $url, array $requestHeaders, array $requestData, GuzzleException $exception): self 53 | { 54 | return new self("HTTP client error when $when", $method, $url, $requestHeaders, $requestData, $exception); 55 | } 56 | 57 | public function getClientException(): GuzzleException 58 | { 59 | return $this->clientException; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Exceptions/SatHttpGatewayException.php: -------------------------------------------------------------------------------- 1 | */ 23 | private $requestHeaders; 24 | 25 | /** @var array */ 26 | private $requestData; 27 | 28 | /** @var string */ 29 | private $httpMethod; 30 | 31 | /** 32 | * SatHttpGatewayException constructor. 33 | * 34 | * @param string $message 35 | * @param string $httpMethod 36 | * @param string $url 37 | * @param array $requestHeaders 38 | * @param array $requestData 39 | * @param Throwable|null $previous 40 | */ 41 | protected function __construct( 42 | string $message, 43 | string $httpMethod, 44 | string $url, 45 | array $requestHeaders, 46 | array $requestData = [], 47 | Throwable $previous = null 48 | ) { 49 | parent::__construct($message, 0, $previous); 50 | $this->httpMethod = $httpMethod; 51 | $this->url = $url; 52 | $this->requestHeaders = $requestHeaders; 53 | $this->requestData = $requestData; 54 | } 55 | 56 | public function getHttpMethod(): string 57 | { 58 | return $this->httpMethod; 59 | } 60 | 61 | public function getUrl(): string 62 | { 63 | return $this->url; 64 | } 65 | 66 | /** @return array */ 67 | public function getRequestHeaders(): array 68 | { 69 | return $this->requestHeaders; 70 | } 71 | 72 | /** @return array */ 73 | public function getRequestData(): array 74 | { 75 | return $this->requestData; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Exceptions/SatHttpGatewayResponseException.php: -------------------------------------------------------------------------------- 1 | $requestHeaders 27 | * @param array $requestData 28 | */ 29 | protected function __construct(ResponseInterface $response, string $message, string $httpMethod, string $url, array $requestHeaders, array $requestData) 30 | { 31 | parent::__construct($message, $httpMethod, $url, $requestHeaders, $requestData); 32 | $this->response = $response; 33 | } 34 | 35 | /** 36 | * Method factory 37 | * 38 | * @param string $when 39 | * @param ResponseInterface $response 40 | * @param string $httpMethod 41 | * @param string $url 42 | * @param array $requestHeaders 43 | * @param array $requestData 44 | * @return self 45 | */ 46 | public static function unexpectedEmptyResponse(string $when, ResponseInterface $response, string $httpMethod, string $url, array $requestHeaders, array $requestData = []): self 47 | { 48 | return new self($response, "Unexpected empty content when $when", $httpMethod, $url, $requestHeaders, $requestData); 49 | } 50 | 51 | public function getResponse(): ResponseInterface 52 | { 53 | return $this->response; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Filters/DownloadType.php: -------------------------------------------------------------------------------- 1 | URLS::PORTAL_CFDI_CONSULTA_RECEPTOR, 24 | 'emitidos' => URLS::PORTAL_CFDI_CONSULTA_EMISOR, 25 | ]; 26 | 27 | public function url(): string 28 | { 29 | $url = self::URLS[$this->value()] ?? ''; 30 | if ('' === $url) { 31 | throw LogicException::generic(sprintf('Enum %s does not have the url for "%s"', self::class, $this->value())); 32 | } 33 | return $url; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Filters/Options/ComplementsOption.php: -------------------------------------------------------------------------------- 1 | 106 | */ 107 | class ComplementsOption extends MicroCatalog implements FilterOption 108 | { 109 | protected const VALUES = [ 110 | 'todos' => [ 111 | 'input' => '-1', 112 | 'description' => 'Cualquier complemento', 113 | ], 114 | 'sinComplemento' => [ 115 | 'input' => '8', 116 | 'description' => 'Sin complemento', 117 | ], 118 | 'acreditamientoIeps' => [ 119 | 'input' => '4294967296', 120 | 'description' => 'Acreditamiento de IEPS', 121 | ], 122 | 'aerolineas' => [ 123 | 'input' => '8388608', 124 | 'description' => 'Aerolíneas', 125 | ], 126 | 'cartaPorte10' => [ 127 | 'input' => '70368744177664', 128 | 'description' => 'Carta Porte 1.0', 129 | ], 130 | 'cartaPorte20' => [ 131 | 'input' => '140737488355328', 132 | 'description' => 'Carta Porte 2.0', 133 | ], 134 | 'certificadoDestruccion' => [ 135 | 'input' => '1073741824', 136 | 'description' => 'Certificado de destrucción', 137 | ], 138 | 'comercioExterior' => [ 139 | 'input' => '17179869184', 140 | 'description' => 'Comercio exterior 1.0', 141 | ], 142 | 'comercioExterior11' => [ 143 | 'input' => '274877906944', 144 | 'description' => 'Comercio exterior 1.1', 145 | ], 146 | 'compraVentaDivisas' => [ 147 | 'input' => '4', 148 | 'description' => 'Compra venta de divisas', 149 | ], 150 | 'consumoCombustibles' => [ 151 | 'input' => '16777216', 152 | 'description' => 'Consumo de combustibles 1.0', 153 | ], 154 | 'consumoCombustibles11' => [ 155 | 'input' => '8796093022208', 156 | 'description' => 'Consumo de combustibles 1.1', 157 | ], 158 | 'donatarias' => [ 159 | 'input' => '64', 160 | 'description' => 'Donatarias', 161 | ], 162 | 'estadoCuentaBancario' => [ 163 | 'input' => '256', 164 | 'description' => 'Estado de cuenta bancario', 165 | ], 166 | 'estadoCuentaCombustibles12' => [ 167 | 'input' => '4398046511104', 168 | 'description' => 'Estado de cuenta combustibles 1.2', 169 | ], 170 | 'estadoCuentaCombustiblesMonederoElectronico' => [ 171 | 'input' => '8589934592', 172 | 'description' => 'Estado de cuenta combustibles monedero electrónico', 173 | ], 174 | 'gastosHidrocarburos' => [ 175 | 'input' => '17592186044416', 176 | 'description' => 'Gastos contrato de hidrocarburos', 177 | ], 178 | 'ine11' => [ 179 | 'input' => '68719476736', 180 | 'description' => 'INE 1.1', 181 | ], 182 | 'ingresosHidrocarburos' => [ 183 | 'input' => '35184372088832', 184 | 'description' => 'Ingresos contrato de hidrocarburos', 185 | ], 186 | 'institucionesEducativasPrivadas' => [ 187 | 'input' => '1024', 188 | 'description' => 'Instituciones educativas privadas', 189 | ], 190 | 'leyendasFiscales' => [ 191 | 'input' => '4096', 192 | 'description' => 'Leyendas fiscales', 193 | ], 194 | 'misCuentas' => [ 195 | 'input' => '524288', 196 | 'description' => 'Mis cuentas', 197 | ], 198 | 'notariosPublicos' => [ 199 | 'input' => '67108864', 200 | 'description' => 'Notarios públicos', 201 | ], 202 | 'obraArtesAntiguedades' => [ 203 | 'input' => '536870912', 204 | 'description' => 'Obras de artes plásticas y antigüedades', 205 | ], 206 | 'otrosDerechosImpuestos' => [ 207 | 'input' => '2048', 208 | 'description' => 'Otros derechos e impuestos', 209 | ], 210 | 'pagoEspecie' => [ 211 | 'input' => '4194304', 212 | 'description' => 'Pago en especie', 213 | ], 214 | 'personaFisicaIntegranteCoordinado' => [ 215 | 'input' => '8192', 216 | 'description' => 'Personas físicas integrantes de coordinados', 217 | ], 218 | 'recepcionPagos' => [ 219 | 'input' => '549755813888', 220 | 'description' => 'Recepción de pagos', 221 | ], 222 | 'recepcionPagos20' => [ 223 | 'input' => '1125899906842624', 224 | 'description' => 'Recepción de pagos 2.0', 225 | ], 226 | 'reciboDonativo' => [ 227 | 'input' => '128', 228 | 'description' => 'Recibo de donativo', 229 | ], 230 | 'reciboPagoSalarios' => [ 231 | 'input' => '1048576', 232 | 'description' => 'Nómina 1.1', 233 | ], 234 | 'reciboPagoSalarios12' => [ 235 | 'input' => '137438953472', 236 | 'description' => 'Nómina 1.2', 237 | ], 238 | 'sectorVentasDetalle' => [ 239 | 'input' => '32', 240 | 'description' => 'Facturas del sector de ventas al detalle', 241 | ], 242 | 'serviciosConstruccion' => [ 243 | 'input' => '268435456', 244 | 'description' => 'Servicios parciales de construcción', 245 | ], 246 | 'speiTerceroTercero' => [ 247 | 'input' => '16384', 248 | 'description' => 'SPEI Tercero a Tercero', 249 | ], 250 | 'sustitucionRenovacionVehicular' => [ 251 | 'input' => '2147483648', 252 | 'description' => 'Renovación y sustitución vehicular', 253 | ], 254 | 'terceros1' => [ 255 | 'input' => '32768', 256 | 'description' => 'Concepto por cuenta de terceros 1.1', 257 | ], 258 | 'terceros2' => [ 259 | 'input' => '65536', 260 | 'description' => 'Concepto por cuenta de terceros 2.0', 261 | ], 262 | 'timbreFiscalDigital' => [ 263 | 'input' => '2199023255552', 264 | 'description' => 'Timbre Fiscal Digital', 265 | ], 266 | 'turistaPasajeroExtranjero' => [ 267 | 'input' => '16', 268 | 'description' => 'Turista pasajero extranjero', 269 | ], 270 | 'valesDespensa' => [ 271 | 'input' => '33554432', 272 | 'description' => 'Vales de despensa', 273 | ], 274 | 'vehiculoUsado' => [ 275 | 'input' => '134217728', 276 | 'description' => 'Vehiculos usados', 277 | ], 278 | 'ventaVehiculos' => [ 279 | 'input' => '2097152', 280 | 'description' => 'Venta de vehiculos nuevos', 281 | ], 282 | ]; 283 | 284 | /** 285 | * @param string $name 286 | * @param mixed $arguments 287 | * @return self 288 | */ 289 | public static function __callStatic(string $name, $arguments) 290 | { 291 | return new self($name); 292 | } 293 | 294 | public static function getEntriesArray(): array 295 | { 296 | return self::VALUES; 297 | } 298 | 299 | public function getEntryValueOnUndefined(): array 300 | { 301 | throw InvalidArgumentException::complementsOptionInvalidKey((string) $this->getEntryIndex()); 302 | } 303 | 304 | public function nameIndex(): string 305 | { 306 | return 'ctl00$MainContent$ddlComplementos'; 307 | } 308 | 309 | public function value(): string 310 | { 311 | return $this->getInput(); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/Filters/Options/RfcOnBehalfOption.php: -------------------------------------------------------------------------------- 1 | value = mb_strtoupper($rfc); 20 | } 21 | 22 | public function nameIndex(): string 23 | { 24 | return 'ctl00$MainContent$TxtRfcTercero'; 25 | } 26 | 27 | public function value(): string 28 | { 29 | return $this->value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Filters/Options/RfcOption.php: -------------------------------------------------------------------------------- 1 | value = mb_strtoupper($rfc); 20 | } 21 | 22 | public function nameIndex(): string 23 | { 24 | return 'ctl00$MainContent$TxtRfcReceptor'; 25 | } 26 | 27 | public function value(): string 28 | { 29 | return $this->value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Filters/Options/StatesVoucherOption.php: -------------------------------------------------------------------------------- 1 | '-1', 28 | 'cancelados' => '0', 29 | 'vigentes' => '1', 30 | ]; 31 | } 32 | 33 | public function nameIndex(): string 34 | { 35 | return 'ctl00$MainContent$DdlEstadoComprobante'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Filters/Options/UuidOption.php: -------------------------------------------------------------------------------- 1 | value = strtolower($uuid); 20 | } 21 | 22 | public function nameIndex(): string 23 | { 24 | return 'ctl00$MainContent$TxtUUID'; 25 | } 26 | 27 | public function value(): string 28 | { 29 | return $this->value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Inputs/InputsByFilters.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | abstract class InputsByFilters extends InputsGeneric implements InputsInterface 14 | { 15 | /** @return array */ 16 | abstract public function getDateFilters(): array; 17 | 18 | public function __construct(QueryByFilters $query) 19 | { 20 | parent::__construct($query); 21 | } 22 | 23 | public function getCentralFilter(): string 24 | { 25 | return 'RdoFechas'; 26 | } 27 | 28 | public function getQueryAsInputs(): array 29 | { 30 | return array_merge( 31 | parent::getQueryAsInputs(), 32 | $this->getDateFilters(), 33 | ); 34 | } 35 | 36 | public function getFilterOptions(): array 37 | { 38 | /** @var QueryByFilters $query */ 39 | $query = $this->getQuery(); 40 | return [ 41 | $query->getComplement(), 42 | $query->getStateVoucher(), 43 | $query->getRfc(), 44 | $query->getRfcOnBehalf(), 45 | ]; 46 | } 47 | 48 | /** 49 | * Helper function that emulates idate($ts, $format) but using a DateTimeImmutable and padding leading zeros 50 | * 51 | * @param DateTimeImmutable $date 52 | * @param string $format some value of date, use only those values that return an integer expression 53 | * @param int $fixedPositions expected minimal positions, will pad leading zeros if length is lower than fixed 54 | * @return string 55 | */ 56 | protected function sidate(DateTimeImmutable $date, string $format, int $fixedPositions = 1): string 57 | { 58 | $fixedPositions = max(1, $fixedPositions); 59 | return sprintf("%0{$fixedPositions}d", (int) $date->format($format)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Inputs/InputsByFiltersIssued.php: -------------------------------------------------------------------------------- 1 | */ 12 | public function getDateFilters(): array 13 | { 14 | /** @var QueryByFilters $query PhpStorm does not know correct type by template */ 15 | $query = $this->getQuery(); 16 | $startDate = $query->getStartDate(); 17 | $endDate = $query->getEndDate(); 18 | return [ 19 | 'ctl00$MainContent$hfInicial' => $startDate->format('Y'), 20 | 'ctl00$MainContent$CldFechaInicial2$Calendario_text' => $startDate->format('d/m/Y'), 21 | 'ctl00$MainContent$CldFechaInicial2$DdlHora' => $this->sidate($startDate, 'H', 1), 22 | 'ctl00$MainContent$CldFechaInicial2$DdlMinuto' => $this->sidate($startDate, 'i', 1), 23 | 'ctl00$MainContent$CldFechaInicial2$DdlSegundo' => $this->sidate($startDate, 's', 1), 24 | 'ctl00$MainContent$CldFechaFinal2$Calendario_text' => $endDate->format('d/m/Y'), 25 | 'ctl00$MainContent$hfFinal' => $endDate->format('Y'), 26 | 'ctl00$MainContent$CldFechaFinal2$DdlHora' => $this->sidate($endDate, 'H', 1), 27 | 'ctl00$MainContent$CldFechaFinal2$DdlMinuto' => $this->sidate($endDate, 'i', 1), 28 | 'ctl00$MainContent$CldFechaFinal2$DdlSegundo' => $this->sidate($endDate, 's', 1), 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Inputs/InputsByFiltersReceived.php: -------------------------------------------------------------------------------- 1 | */ 12 | public function getDateFilters(): array 13 | { 14 | /** @var QueryByFilters $query PhpStorm does not know correct type by template */ 15 | $query = $this->getQuery(); 16 | $startDate = $query->getStartDate(); 17 | $endDate = $query->getEndDate(); 18 | return [ 19 | 'ctl00$MainContent$CldFecha$DdlAnio' => $startDate->format('Y'), 20 | 'ctl00$MainContent$CldFecha$DdlMes' => $this->sidate($startDate, 'm', 1), 21 | 'ctl00$MainContent$CldFecha$DdlDia' => $this->sidate($startDate, 'd', 2), 22 | 'ctl00$MainContent$CldFecha$DdlHora' => $this->sidate($startDate, 'H', 1), 23 | 'ctl00$MainContent$CldFecha$DdlMinuto' => $this->sidate($startDate, 'i', 1), 24 | 'ctl00$MainContent$CldFecha$DdlSegundo' => $this->sidate($startDate, 's', 1), 25 | 'ctl00$MainContent$CldFecha$DdlHoraFin' => $this->sidate($endDate, 'H', 1), 26 | 'ctl00$MainContent$CldFecha$DdlMinutoFin' => $this->sidate($endDate, 'i', 1), 27 | 'ctl00$MainContent$CldFecha$DdlSegundoFin' => $this->sidate($endDate, 's', 1), 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Inputs/InputsByUuid.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class InputsByUuid extends InputsGeneric implements InputsInterface 13 | { 14 | public function __construct(QueryByUuid $query) 15 | { 16 | parent::__construct($query); 17 | } 18 | 19 | public function getCentralFilter(): string 20 | { 21 | return 'RdoFolioFiscal'; 22 | } 23 | 24 | public function getFilterOptions(): array 25 | { 26 | return [$this->getQuery()->getUuid()]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Inputs/InputsGeneric.php: -------------------------------------------------------------------------------- 1 | query = $query; 21 | } 22 | 23 | /** @return TQuery */ 24 | public function getQuery(): QueryInterface 25 | { 26 | return $this->query; 27 | } 28 | 29 | public function getUrl(): string 30 | { 31 | return $this->getQuery()->getDownloadType()->url(); 32 | } 33 | 34 | public function getQueryAsInputs(): array 35 | { 36 | $inputs = ['ctl00$MainContent$BtnBusqueda' => 'Buscar CFDI']; 37 | 38 | $filters = $this->getFilterOptions(); 39 | foreach ($filters as $filter) { 40 | $inputs[$filter->nameIndex()] = $filter->value(); 41 | } 42 | 43 | return $inputs; 44 | } 45 | 46 | public function getAjaxInputs(): array 47 | { 48 | $centralFilter = $this->getCentralFilter(); 49 | return [ 50 | '__ASYNCPOST' => 'true', 51 | '__EVENTARGUMENT' => '', 52 | '__EVENTTARGET' => 'ctl00$MainContent$' . $centralFilter, 53 | '__LASTFOCUS' => '', 54 | 'ctl00$MainContent$FiltroCentral' => $centralFilter, 55 | 'ctl00$ScriptManager1' => 'ctl00$MainContent$UpnlBusqueda|ctl00$MainContent$' . $centralFilter, 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Inputs/InputsInterface.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public function getQueryAsInputs(): array; 39 | 40 | /** 41 | * Return the minimum set of inputs to make an ajax request 42 | * 43 | * @return array 44 | */ 45 | public function getAjaxInputs(): array; 46 | 47 | /** 48 | * Return the URL which all http transactions will be sent 49 | * 50 | * @return string 51 | */ 52 | public function getUrl(): string; 53 | } 54 | -------------------------------------------------------------------------------- /src/Internal/CaptchaBase64Extractor.php: -------------------------------------------------------------------------------- 1 | img'; 19 | 20 | public function retrieveCaptchaImage(string $htmlSource, string $selector = self::DEFAULT_SELECTOR): CaptchaImage 21 | { 22 | $images = (new Crawler($htmlSource))->filter($selector); 23 | 24 | if (0 === $images->count()) { 25 | throw RuntimeException::unableToFindCaptchaImage($selector); 26 | } 27 | 28 | $imageSource = (string) $images->attr('src'); 29 | 30 | return CaptchaImage::newFromInlineHtml($imageSource); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Internal/DownloadTypePropertyTrait.php: -------------------------------------------------------------------------------- 1 | downloadType; 27 | } 28 | 29 | /** 30 | * @param DownloadType $downloadType 31 | * @return $this 32 | */ 33 | public function setDownloadType(DownloadType $downloadType): self 34 | { 35 | $this->downloadType = $downloadType; 36 | 37 | return $this; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Internal/Headers.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | public static function get(string $referer = ''): array 22 | { 23 | return array_filter([ 24 | 'Accept' => ' text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 25 | 'Accept-Encoding' => 'gzip, deflate', 26 | 'Accept-Language' => 'en-US,en;q=0.5', 27 | 'Accept-Charset' => 'utf-8, iso-8859-15;q=0.5', 28 | 'Connection' => 'keep-alive', 29 | 'Referer' => $referer, 30 | 'User-Agent' => 'Mozilla/5.0 (Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0', 31 | ]); 32 | } 33 | 34 | /** 35 | * Return the headers to use on general form submit 36 | * 37 | * @param string $host 38 | * @param string $referer 39 | * 40 | * @return array 41 | */ 42 | public static function post(string $host, string $referer): array 43 | { 44 | return array_merge(self::get($referer), array_filter([ 45 | 'Pragma' => 'no-cache', 46 | 'Host' => $host, 47 | 'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8', 48 | ])); 49 | } 50 | 51 | /** 52 | * Return the headers to use on ajax requests 53 | * 54 | * @param string $host 55 | * @param string $referer 56 | * 57 | * @return array 58 | */ 59 | public static function postAjax(string $host, string $referer): array 60 | { 61 | return array_merge(self::post($host, $referer), [ 62 | 'X-MicrosoftAjax' => 'Delta=true', 63 | 'X-Requested-With' => 'XMLHttpRequest', 64 | ]); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Internal/HtmlForm.php: -------------------------------------------------------------------------------- 1 | setHtmlSource($htmlSource); 37 | $this->setParentElement($parentElement); 38 | $this->setElementNameExcludePatterns(...$elementNameExcludePatters); 39 | } 40 | 41 | public function setHtmlSource(string $htmlSource): void 42 | { 43 | $this->crawler = new Crawler($htmlSource); 44 | } 45 | 46 | public function setParentElement(string $parentElement): void 47 | { 48 | $this->parentElement = $parentElement; 49 | } 50 | 51 | public function setElementNameExcludePatterns(string ...$elementNameExcludePatters): void 52 | { 53 | $this->elementNameExcludePatters = $elementNameExcludePatters; 54 | } 55 | 56 | /** 57 | * Return all inputs (without submit, reset and button) and selects, 58 | * following the element name exclusion patterns 59 | * 60 | * @return array 61 | */ 62 | public function getFormValues(): array 63 | { 64 | return array_merge($this->readInputValues(), $this->readSelectValues()); 65 | } 66 | 67 | /** 68 | * Retrieve an array with key as input element name and value as value 69 | * It excludes the inputs which name match with an exclusion pattern 70 | * This excludes all inputs with types submit, reset and button 71 | * In the case of input type radio it only includes it when is checked 72 | * 73 | * @return array 74 | */ 75 | public function readInputValues(): array 76 | { 77 | return $this->readFormElementsValues('input', ['submit', 'reset', 'button']); 78 | } 79 | 80 | /** 81 | * Retrieve an array with key as select element name and value as first option selected 82 | * 83 | * @return array 84 | */ 85 | public function readSelectValues(): array 86 | { 87 | $data = []; 88 | /** @var DOMElement[] $elements */ 89 | $elements = $this->filterCrawlerElements("$this->parentElement select"); 90 | foreach ($elements as $element) { 91 | $name = $element->getAttribute('name'); 92 | if ($this->elementNameIsExcluded($name)) { 93 | continue; 94 | } 95 | 96 | $value = ''; 97 | /** @var DOMElement $option */ 98 | foreach ($element->getElementsByTagName('option') as $option) { 99 | if ($option->getAttribute('selected')) { 100 | $value = $option->getAttribute('value'); 101 | break; 102 | } 103 | } 104 | 105 | $data[$name] = $value; 106 | } 107 | 108 | return $data; 109 | } 110 | 111 | /** 112 | * This method is compatible with elements that have a name and value 113 | * It excludes the selects which name match with an exclusion pattern 114 | * If type is defined is excluded if was set as an excluded type 115 | * If type is radio is included only if checked attribute is true-ish 116 | * 117 | * @param string $element 118 | * @param string[] $excludeTypes 119 | * 120 | * @return array 121 | */ 122 | public function readFormElementsValues(string $element, array $excludeTypes = []): array 123 | { 124 | $excludeTypes = array_map('strtolower', $excludeTypes); 125 | $data = []; 126 | 127 | /** @var DOMElement[] $elements */ 128 | $elements = $this->filterCrawlerElements("$this->parentElement $element"); 129 | foreach ($elements as $element) { 130 | $name = $element->getAttribute('name'); 131 | if ($this->elementNameIsExcluded($name)) { 132 | continue; 133 | } 134 | 135 | $type = strtolower($element->getAttribute('type')); 136 | if (in_array($type, $excludeTypes, true)) { 137 | continue; 138 | } 139 | if (('radio' === $type || 'checkbox' === $type) && ! $element->getAttribute('checked')) { 140 | continue; 141 | } 142 | 143 | $data[$name] = $element->getAttribute('value'); 144 | } 145 | 146 | return $data; 147 | } 148 | 149 | public function elementNameIsExcluded(string $name): bool 150 | { 151 | foreach ($this->elementNameExcludePatters as $excludePattern) { 152 | if (1 === preg_match($excludePattern, $name)) { 153 | return true; 154 | } 155 | } 156 | return false; 157 | } 158 | 159 | /** 160 | * This method is made to ignore RuntimeException if the CssSelector Component is not available. 161 | * 162 | * @param string $filter 163 | * @return Crawler|DOMElement[] 164 | */ 165 | private function filterCrawlerElements(string $filter) 166 | { 167 | try { 168 | $elements = $this->crawler->filter($filter); 169 | } catch (Throwable $exception) { 170 | $elements = []; 171 | } 172 | /** @var Crawler|DOMElement[] $elements */ 173 | return $elements; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Internal/MetaRefreshInspector.php: -------------------------------------------------------------------------------- 1 | filter('head meta[http-equiv="refresh"]'); 22 | if (1 !== $refresh->count()) { 23 | return ''; 24 | } 25 | 26 | $content = (string) $refresh->attr('content'); 27 | if (! (bool) preg_match('/^\d+;\s*url=(?.*)$/i', $content, $matches)) { 28 | return ''; 29 | } 30 | 31 | $url = trim($matches['url']); 32 | if ('' === $url) { 33 | return ''; 34 | } 35 | 36 | $uriResolver = new UriResolver(); 37 | if ('' !== $baseUri) { 38 | $url = $uriResolver->resolve($url, $baseUri); 39 | } 40 | 41 | return $url; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Internal/MetadataDownloader.php: -------------------------------------------------------------------------------- 1 | queryResolver = $queryResolver; 43 | $this->messageHandler = $messageHandler; 44 | } 45 | 46 | public function getQueryResolver(): QueryResolver 47 | { 48 | return $this->queryResolver; 49 | } 50 | 51 | public function getMessageHandler(): MetadataMessageHandler 52 | { 53 | return $this->messageHandler; 54 | } 55 | 56 | /** 57 | * @param string[] $uuids 58 | * @param DownloadType $downloadType 59 | * @return MetadataList 60 | * @throws SatHttpGatewayException 61 | */ 62 | public function downloadByUuids(array $uuids, DownloadType $downloadType): MetadataList 63 | { 64 | $uuids = array_keys(array_change_key_case(array_flip($uuids), CASE_LOWER)); 65 | $result = new MetadataList([]); 66 | foreach ($uuids as $uuid) { 67 | $uuidResult = $this->downloadByUuid(new UuidOption($uuid), $downloadType); 68 | $result = $result->merge($uuidResult); 69 | } 70 | 71 | return $result; 72 | } 73 | 74 | /** 75 | * @param UuidOption $uuid 76 | * @param DownloadType $downloadType 77 | * @return MetadataList 78 | * @throws SatHttpGatewayException 79 | */ 80 | public function downloadByUuid(UuidOption $uuid, DownloadType $downloadType): MetadataList 81 | { 82 | $query = new QueryByUuid($uuid, $downloadType); 83 | return $this->resolveQuery($query); 84 | } 85 | 86 | /** 87 | * @param QueryByFilters $query 88 | * @return MetadataList 89 | * @throws SatHttpGatewayException 90 | */ 91 | public function downloadByDate(QueryByFilters $query): MetadataList 92 | { 93 | /** @var DateTimeImmutable $startDate set this type definition as setTime can return FALSE */ 94 | $startDate = $query->getStartDate()->setTime(0, 0, 0); 95 | /** @var DateTimeImmutable $endDate set this type definition as setTime can return FALSE */ 96 | $endDate = $query->getEndDate()->setTime(23, 59, 59); 97 | 98 | $query = clone $query; 99 | $query->setPeriod($startDate, $endDate); 100 | return $this->downloadByDateTime($query); 101 | } 102 | 103 | /** 104 | * @param QueryByFilters $query 105 | * @return MetadataList 106 | * @throws SatHttpGatewayException 107 | */ 108 | public function downloadByDateTime(QueryByFilters $query): MetadataList 109 | { 110 | $result = new MetadataList([]); 111 | foreach ($this->splitQueryByFiltersByDays($query) as $current) { 112 | $list = $this->downloadQuery($current); 113 | $this->messageHandler->date($current->getStartDate(), $current->getEndDate(), $list->count()); 114 | $result = $result->merge($list); 115 | } 116 | return $result; 117 | } 118 | 119 | /** 120 | * @param QueryByFilters $query 121 | * @return MetadataList 122 | * @throws SatHttpGatewayException 123 | */ 124 | public function downloadQuery(QueryByFilters $query): MetadataList 125 | { 126 | $finalList = new MetadataList([]); 127 | $day = $query->getStartDate()->modify('midnight'); 128 | $lowerBound = intval($query->getStartDate()->format('U')) - intval($day->format('U')); 129 | $upperBound = intval($query->getEndDate()->format('U')) - intval($day->format('U')); 130 | $secondInitial = $lowerBound; 131 | $secondEnd = $upperBound; 132 | 133 | while (true) { 134 | $currentQuery = $this->newQueryWithSeconds($query, $secondInitial, $secondEnd); 135 | $list = $this->resolveQuery($currentQuery); 136 | $result = $list->count(); 137 | 138 | if ($result >= 500 && $secondEnd === $secondInitial) { 139 | $this->messageHandler->maximum($currentQuery->getStartDate()); 140 | } 141 | 142 | if ($result >= 500 && $secondEnd > $secondInitial) { 143 | $this->messageHandler->divide($currentQuery->getStartDate(), $currentQuery->getEndDate()); 144 | $secondEnd = (int) floor($secondInitial + (($secondEnd - $secondInitial) / 2)); 145 | continue; 146 | } 147 | 148 | $this->messageHandler->resolved($currentQuery->getStartDate(), $currentQuery->getEndDate(), $list->count()); 149 | $finalList = $finalList->merge($list); 150 | if ($secondEnd >= $upperBound) { 151 | break; 152 | } 153 | 154 | $secondInitial = $secondEnd + 1; 155 | $secondEnd = $upperBound; 156 | } 157 | 158 | return $finalList; 159 | } 160 | 161 | public function newQueryWithSeconds(QueryByFilters $query, int $startSec, int $endSec): QueryByFilters 162 | { 163 | return (clone $query)->setPeriod( 164 | $this->buildDateWithDayAndSeconds($query->getStartDate(), $startSec), 165 | $this->buildDateWithDayAndSeconds($query->getEndDate(), $endSec), 166 | ); 167 | } 168 | 169 | /** 170 | * @param QueryInterface $query 171 | * @return MetadataList 172 | * @throws SatHttpGatewayException 173 | * @see QueryResolver 174 | */ 175 | public function resolveQuery(QueryInterface $query): MetadataList 176 | { 177 | $inputs = $this->createInputsFromQuery($query); 178 | return $this->getQueryResolver()->resolve($inputs); 179 | } 180 | 181 | public function buildDateWithDayAndSeconds(DateTimeImmutable $day, int $seconds): DateTimeImmutable 182 | { 183 | return $day->modify(sprintf('midnight + %d seconds', $seconds)); 184 | } 185 | 186 | public function createInputsFromQuery(QueryInterface $query): InputsInterface 187 | { 188 | if ($query instanceof QueryByFilters) { 189 | if ($query->getDownloadType()->isEmitidos()) { 190 | return new InputsByFiltersIssued($query); 191 | } 192 | return new InputsByFiltersReceived($query); 193 | } 194 | if ($query instanceof QueryByUuid) { 195 | return new InputsByUuid($query); 196 | } 197 | throw LogicException::generic(sprintf('Unable to create input filters from query type %s', get_class($query))); 198 | } 199 | 200 | /** 201 | * Generates a clone of this query split by day 202 | * 203 | * @param QueryByFilters $query 204 | * @return Generator 205 | */ 206 | public function splitQueryByFiltersByDays(QueryByFilters $query): Generator 207 | { 208 | $endDate = $query->getEndDate(); 209 | for ($date = $query->getStartDate(); $date <= $endDate; $date = $date->modify('midnight +1 day')) { 210 | $partial = clone $query; 211 | /** @var DateTimeImmutable $dateOnLastSecond set this type definition as setTime can return FALSE */ 212 | $dateOnLastSecond = $date->setTime(23, 59, 59); 213 | $partial->setPeriod($date, min($dateOnLastSecond, $endDate)); 214 | yield $partial; 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/Internal/MetadataExtractor.php: -------------------------------------------------------------------------------- 1 | |null $fieldsCaptions 25 | * @return MetadataList 26 | */ 27 | public function extract(string $html, ?array $fieldsCaptions = null): MetadataList 28 | { 29 | if (null === $fieldsCaptions) { 30 | $fieldsCaptions = $this->defaultFieldsCaptions(); 31 | } 32 | 33 | try { 34 | $rows = (new Crawler($html))->filter('table#ctl00_MainContent_tblResult > tr'); 35 | } catch (RuntimeException $exception) { 36 | return new MetadataList([]); 37 | } 38 | if ($rows->count() < 2) { 39 | return new MetadataList([]); 40 | } 41 | 42 | // first row is the only expected to have the th elements 43 | $fieldsPositions = $this->locateFieldsPositions($rows->first(), $fieldsCaptions); 44 | 45 | // slice first row (headers), build data array as a collection of metadata 46 | $data = $rows->slice(1)->each( 47 | function (Crawler $row) use ($fieldsPositions): ?Metadata { 48 | $metadata = $this->obtainMetadataValues($row, $fieldsPositions); 49 | if ('' === ($metadata['uuid'] ?? '')) { 50 | return null; 51 | } 52 | $metadata[ResourceType::xml()->value()] = $this->obtainUrlXml($row); 53 | $metadata[ResourceType::pdf()->value()] = $this->obtainUrlPdf($row); 54 | $metadata[ResourceType::cancelRequest()->value()] = $this->obtainUrlCancelRequest($row); 55 | $metadata[ResourceType::cancelVoucher()->value()] = $this->obtainUrlCancelVoucher($row); 56 | return new Metadata($metadata['uuid'], $metadata); 57 | }, 58 | ); 59 | 60 | // build metadata using uuid as key 61 | return new MetadataList($data); 62 | } 63 | 64 | /** 65 | * @return array 66 | * @see Metadata 67 | */ 68 | public function defaultFieldsCaptions(): array 69 | { 70 | return [ 71 | 'uuid' => 'Folio Fiscal', 72 | 'rfcEmisor' => 'RFC Emisor', 73 | 'nombreEmisor' => 'Nombre o Razón Social del Emisor', 74 | 'rfcReceptor' => 'RFC Receptor', 75 | 'nombreReceptor' => 'Nombre o Razón Social del Receptor', 76 | 'fechaEmision' => 'Fecha de Emisión', 77 | 'fechaCertificacion' => 'Fecha de Certificación', 78 | 'pacCertifico' => 'PAC que Certificó', 79 | 'total' => 'Total', 80 | 'efectoComprobante' => 'Efecto del Comprobante', 81 | 'estatusCancelacion' => 'Estatus de cancelación', 82 | 'estadoComprobante' => 'Estado del Comprobante', 83 | 'estatusProcesoCancelacion' => 'Estatus de Proceso de Cancelación', 84 | 'fechaSolicitudCancelacion' => 'Fecha de Solicitud de la Cancelación', 85 | 'fechaDeCancelacion' => 'Fecha de Cancelación', 86 | 'rfcACuentaTerceros' => 'RFC a cuenta de terceros', 87 | 'motivoCancelacion' => 'Motivo', 88 | 'folioSustitucion' => 'Folio de Sustitución', 89 | ]; 90 | } 91 | 92 | /** 93 | * @param Crawler $headersRow 94 | * @param array $fieldsCaptions 95 | * @return array 96 | */ 97 | public function locateFieldsPositions(Crawler $headersRow, array $fieldsCaptions): array 98 | { 99 | try { 100 | /** @var array $headerCells */ 101 | $headerCells = $headersRow->children()->each( 102 | function (Crawler $cell) { 103 | return trim($cell->text()); 104 | }, 105 | ); 106 | } catch (RuntimeException $exception) { 107 | return []; 108 | } 109 | 110 | $headerPositions = []; 111 | foreach ($fieldsCaptions as $field => $label) { 112 | /** @var int|false $search */ 113 | $search = array_search($label, $headerCells); 114 | if (false !== $search) { 115 | $headerPositions[$field] = $search; 116 | } 117 | } 118 | 119 | return $headerPositions; 120 | } 121 | 122 | /** 123 | * @param Crawler $row 124 | * @param array $fieldsPositions 125 | * @return array 126 | */ 127 | public function obtainMetadataValues(Crawler $row, array $fieldsPositions): array 128 | { 129 | try { 130 | $cells = $row->children(); 131 | } catch (RuntimeException $exception) { 132 | return []; 133 | } 134 | 135 | $values = []; 136 | foreach ($fieldsPositions as $field => $position) { 137 | $values[$field] = trim($cells->getNode($position)->textContent ?? ''); 138 | } 139 | return $values; 140 | } 141 | 142 | public function obtainUrlXml(Crawler $row): string 143 | { 144 | $onClickAttribute = $this->obtainOnClickFromElement($row, 'span#BtnDescarga'); 145 | return str_replace( 146 | ["return AccionCfdi('", "','Recuperacion');"], 147 | [URLS::PORTAL_CFDI, ''], 148 | $onClickAttribute, 149 | ); 150 | } 151 | 152 | public function obtainUrlPdf(Crawler $row): string 153 | { 154 | $onClickAttribute = $this->obtainOnClickFromElement($row, 'span#BtnRI'); 155 | return str_replace( 156 | ["recuperaRepresentacionImpresa('", "');"], 157 | [URLS::PORTAL_CFDI . 'RepresentacionImpresa.aspx?Datos=', ''], 158 | $onClickAttribute, 159 | ); 160 | } 161 | 162 | public function obtainUrlCancelRequest(Crawler $row): string 163 | { 164 | $onClickAttribute = $this->obtainOnClickFromElement($row, 'span#BtnRecuperaAcuse'); 165 | return str_replace( 166 | ["AccionCfdi('", "','Acuse');"], 167 | [URLS::PORTAL_CFDI, ''], 168 | $onClickAttribute, 169 | ); 170 | } 171 | 172 | public function obtainUrlCancelVoucher(Crawler $row): string 173 | { 174 | $onClickAttribute = $this->obtainOnClickFromElement($row, 'span#BtnRecuperaAcuseFinal'); 175 | // change javascript call and replace it with complete url 176 | return str_replace( 177 | ["javascript:window.location.href='", "';"], 178 | [URLS::PORTAL_CFDI, ''], 179 | $onClickAttribute, 180 | ); 181 | } 182 | 183 | private function obtainOnClickFromElement(Crawler $crawler, string $elementFilter): string 184 | { 185 | try { 186 | $filteredElements = $crawler->filter($elementFilter); 187 | } catch (Throwable $exception) { 188 | return ''; 189 | } 190 | 191 | if (0 === $filteredElements->count()) { // button not found 192 | return ''; 193 | } 194 | 195 | return $filteredElements->first()->attr('onclick') ?? ''; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Internal/ParserFormatSAT.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | public function getFormValues(string $source): array 29 | { 30 | $values = explode('|', $source); 31 | $items = []; 32 | 33 | foreach (self::FILTER_KEYS as $key) { 34 | if (false !== $index = array_search($key, $values, true)) { 35 | $items[$key] = $values[$index + 1] ?? ''; 36 | } 37 | } 38 | 39 | return $items; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Internal/QueryResolver.php: -------------------------------------------------------------------------------- 1 | satHttpGateway = $satHttpGateway; 27 | } 28 | 29 | /** 30 | * @param InputsInterface $inputs 31 | * @return MetadataList 32 | * @throws SatHttpGatewayException 33 | */ 34 | public function resolve(InputsInterface $inputs): MetadataList 35 | { 36 | $url = $inputs->getUrl(); 37 | $ajaxFilters = $inputs->getAjaxInputs(); 38 | 39 | // access to download type page, it returns the hole set of inputs 40 | $baseInputs = $this->inputFieldsFromInitialPage($url); 41 | 42 | // select query type (uuid or filters), it returns only a subset of inputs 43 | $post = array_merge($baseInputs, $ajaxFilters); 44 | $lastViewStates = $this->inputFieldsFromSelectDownloadType($url, $post); 45 | 46 | // execute search 47 | $post = array_merge($baseInputs, $ajaxFilters, $lastViewStates, $inputs->getQueryAsInputs()); 48 | $htmlWithMetadata = $this->htmlFromExecuteQuery($url, $post); 49 | 50 | // extract metadata from search results 51 | return (new MetadataExtractor())->extract($htmlWithMetadata); 52 | } 53 | 54 | /** 55 | * @param string $url 56 | * @return array 57 | * @throws SatHttpGatewayException 58 | */ 59 | protected function inputFieldsFromInitialPage(string $url): array 60 | { 61 | $completePage = $this->getSatHttpGateway()->getPortalPage($url); 62 | $completePage = str_replace('charset=utf-16', 'charset=utf-8', $completePage); // quick and dirty hack 63 | $htmlFormInputExtractor = new HtmlForm($completePage, 'form', ['/^ctl00\$MainContent\$Btn.+/', '/^seleccionador$/']); 64 | return $htmlFormInputExtractor->getFormValues(); 65 | } 66 | 67 | /** 68 | * @param string $url 69 | * @param array $post 70 | * @return array 71 | * @throws SatHttpGatewayException 72 | */ 73 | protected function inputFieldsFromSelectDownloadType(string $url, array $post): array 74 | { 75 | $html = $this->getSatHttpGateway()->postAjaxSearch($url, $post); // this html is used to update __VARIABLES 76 | return (new ParserFormatSAT())->getFormValues($html); 77 | } 78 | 79 | /** 80 | * @param string $url 81 | * @param array $post 82 | * @return string 83 | * @throws SatHttpGatewayException 84 | */ 85 | protected function htmlFromExecuteQuery(string $url, array $post): string 86 | { 87 | return $this->getSatHttpGateway()->postAjaxSearch($url, $post); 88 | } 89 | 90 | public function getSatHttpGateway(): SatHttpGateway 91 | { 92 | return $this->satHttpGateway; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Internal/ResourceDownloadStoreInFolder.php: -------------------------------------------------------------------------------- 1 | destinationFolder = $destinationFolder; 41 | $this->resourceFileNamer = $resourceFileNamer; 42 | } 43 | 44 | public function getDestinationFolder(): string 45 | { 46 | return $this->destinationFolder; 47 | } 48 | 49 | public function getResouceFileNamer(): ResourceFileNamerInterface 50 | { 51 | return $this->resourceFileNamer; 52 | } 53 | 54 | public function pathFor(string $uuid): string 55 | { 56 | return $this->getDestinationFolder() . DIRECTORY_SEPARATOR . $this->resourceFileNamer->nameFor($uuid); 57 | } 58 | 59 | /** 60 | * This method is invoked from ResourceDownloader::saveTo() to validate that the 61 | * destination folder exists or create it. 62 | * 63 | * @param bool $createDestinationFolder 64 | * @param int $createMode 65 | * 66 | * @throws RuntimeException if didn't ask to create folder and path does not exist 67 | * @throws RuntimeException if ask to create folder path exists and is not a folder 68 | * @throws RuntimeException if unable to create folder 69 | */ 70 | public function checkDestinationFolder(bool $createDestinationFolder, int $createMode = 0755): void 71 | { 72 | $destinationFolder = $this->getDestinationFolder(); 73 | 74 | if (is_dir($destinationFolder)) { 75 | return; 76 | } 77 | 78 | if (! $createDestinationFolder) { 79 | throw RuntimeException::pathDoesNotExists($destinationFolder); 80 | } 81 | 82 | if (file_exists($destinationFolder)) { 83 | throw RuntimeException::pathIsNotFolder($destinationFolder); 84 | } 85 | 86 | $this->mkdirRecursive($destinationFolder, $createMode); 87 | } 88 | 89 | /** 90 | * @inheritDoc 91 | * @throws RuntimeException if putting the content into file fails 92 | */ 93 | public function onSuccess(string $uuid, string $content, ResponseInterface $response): void 94 | { 95 | $destinationFile = $this->pathFor($uuid); 96 | $this->filePutContents($destinationFile, $content); 97 | } 98 | 99 | /** 100 | * @inheritDoc 101 | */ 102 | public function onError(ResourceDownloadError $error): void 103 | { 104 | // errors are just ignored 105 | } 106 | 107 | /** 108 | * @param string $destinationFolder 109 | * @param int $createMode 110 | * @throws RuntimeException if unable to create folder 111 | */ 112 | public function mkdirRecursive(string $destinationFolder, int $createMode): void 113 | { 114 | try { 115 | $mkdir = mkdir($destinationFolder, $createMode, true); 116 | } catch (Throwable $exception) { 117 | throw RuntimeException::unableToCreateFolder($destinationFolder, $exception); 118 | } 119 | if (false === $mkdir) { // in case error reporting is disabled 120 | throw RuntimeException::unableToCreateFolder($destinationFolder); 121 | } 122 | } 123 | 124 | /** 125 | * @param string $destinationFile 126 | * @param string $content 127 | * @throws RuntimeException if unable to put contents on file 128 | */ 129 | public function filePutContents(string $destinationFile, string $content): void 130 | { 131 | try { 132 | $putContents = file_put_contents($destinationFile, $content); 133 | } catch (Throwable $exception) { 134 | throw RuntimeException::unableToFilePutContents($destinationFile, $exception); 135 | } 136 | if (false === $putContents) { // in case error reporting is disabled 137 | throw RuntimeException::unableToFilePutContents($destinationFile); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Internal/ResourceDownloaderPromiseHandler.php: -------------------------------------------------------------------------------- 1 | resourceType = $resourceType; 40 | $this->handler = $handler; 41 | } 42 | 43 | /** 44 | * This method handles each promise fulfilled event 45 | * 46 | * @param ResponseInterface $response 47 | * @param string $uuid 48 | * @return null 49 | */ 50 | public function promiseFulfilled(ResponseInterface $response, string $uuid) 51 | { 52 | try { 53 | $content = $this->validateResponse($response, $uuid); 54 | $this->handler->onSuccess($uuid, $content, $response); 55 | } catch (ResourceDownloadResponseError $exception) { 56 | return $this->handlerError($exception); 57 | } catch (Throwable $exception) { 58 | return $this->handlerError(ResourceDownloadResponseError::onSuccessException($response, $uuid, $exception)); 59 | } 60 | 61 | $this->fulfilledUuids[] = $uuid; 62 | return null; 63 | } 64 | 65 | /** 66 | * Validate that the Response object was OK and contains something that looks like CFDI. 67 | * Return the content read from the response body. 68 | * 69 | * @param ResponseInterface $response 70 | * @param string $uuid 71 | * @return string 72 | * 73 | * @throws ResourceDownloadResponseError 74 | */ 75 | public function validateResponse(ResponseInterface $response, string $uuid): string 76 | { 77 | if (200 !== $response->getStatusCode()) { 78 | throw ResourceDownloadResponseError::invalidStatusCode($response, $uuid); 79 | } 80 | 81 | $content = strval($response->getBody()); 82 | 83 | if ('' === $content) { 84 | throw ResourceDownloadResponseError::emptyContent($response, $uuid); 85 | } 86 | 87 | if ($this->resourceType->fileTypeIsXml()) { 88 | if (false === stripos($content, 'UUID="')) { 89 | throw ResourceDownloadResponseError::contentIsNotCfdi($response, $uuid); 90 | } 91 | } elseif ($this->resourceType->fileTypeIsPdf()) { 92 | $mimeType = strtolower((new finfo())->buffer($content, FILEINFO_MIME_TYPE) ?: ''); 93 | if ('application/pdf' !== $mimeType) { 94 | throw ResourceDownloadResponseError::contentIsNotPdf($response, $uuid, $mimeType); 95 | } 96 | } 97 | 98 | return $content; 99 | } 100 | 101 | /** 102 | * This method handles each promise rejected event 103 | * 104 | * @param mixed $reason 105 | * @param string $uuid 106 | * @return null 107 | */ 108 | public function promiseRejected($reason, string $uuid) 109 | { 110 | if ($reason instanceof RequestException) { 111 | return $this->handlerError(ResourceDownloadRequestExceptionError::onRequestException($reason, $uuid)); 112 | } 113 | 114 | return $this->handlerError(ResourceDownloadError::onRejected($uuid, $reason)); 115 | } 116 | 117 | /** 118 | * Send the error to handler error method 119 | * 120 | * @param ResourceDownloadError $error 121 | * @return null 122 | */ 123 | public function handlerError(ResourceDownloadError $error) 124 | { 125 | $this->handler->onError($error); 126 | return null; 127 | } 128 | 129 | /** 130 | * Return the list of successfully processed UUIDS 131 | * 132 | * @return string[] 133 | */ 134 | public function downloadedUuids(): array 135 | { 136 | return $this->fulfilledUuids; 137 | } 138 | 139 | public function getResourceType(): ResourceType 140 | { 141 | return $this->resourceType; 142 | } 143 | 144 | public function getHandler(): ResourceDownloadHandlerInterface 145 | { 146 | return $this->handler; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Internal/ResourceFileNamerByType.php: -------------------------------------------------------------------------------- 1 | resourceType = $resourceType; 25 | } 26 | 27 | public function getResourceType(): ResourceType 28 | { 29 | return $this->resourceType; 30 | } 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function nameFor(string $uuid): string 36 | { 37 | $resourceType = $this->getResourceType(); 38 | if ($resourceType->isXml()) { 39 | return $uuid . '.xml'; 40 | } 41 | if ($resourceType->isPdf()) { 42 | return $uuid . '.pdf'; 43 | } 44 | if ($resourceType->isCancelRequest()) { 45 | return $uuid . '-cancel-request.pdf'; 46 | } 47 | if ($resourceType->isCancelVoucher()) { 48 | return $uuid . '-cancel-voucher.pdf'; 49 | } 50 | throw new OutOfRangeException("Don't know how to generate name for resource {$resourceType->value()}"); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Metadata.php: -------------------------------------------------------------------------------- 1 | 42 | */ 43 | class Metadata implements JsonSerializable, IteratorAggregate 44 | { 45 | /** @var array */ 46 | private $data; 47 | 48 | /** 49 | * Metadata constructor. 50 | * $uuid will be converted to lower case. 51 | * If $data contains a key with 'uuid' will be ignored. 52 | * 53 | * @param string $uuid 54 | * @param array $data 55 | * @throws InvalidArgumentException when UUID is empty 56 | */ 57 | public function __construct(string $uuid, array $data = []) 58 | { 59 | if ('' === $uuid) { 60 | throw InvalidArgumentException::emptyInput('UUID'); 61 | } 62 | $this->data = ['uuid' => strtolower($uuid)] + $data; 63 | } 64 | 65 | public function __get(string $name): string 66 | { 67 | return $this->get($name); 68 | } 69 | 70 | public function __isset(string $name): bool 71 | { 72 | return $this->has($name); 73 | } 74 | 75 | /** @param mixed $value */ 76 | public function __set(string $name, $value): void 77 | { 78 | throw new LogicException(sprintf('The %s class is immutable', self::class)); 79 | } 80 | 81 | public function __unset(string $name): void 82 | { 83 | throw new LogicException(sprintf('The %s class is immutable', self::class)); 84 | } 85 | 86 | public function uuid(): string 87 | { 88 | return $this->data['uuid']; 89 | } 90 | 91 | public function get(string $key): string 92 | { 93 | return strval($this->data[$key] ?? ''); 94 | } 95 | 96 | /** @return array */ 97 | public function getData(): array 98 | { 99 | return $this->data; 100 | } 101 | 102 | public function has(string $key): bool 103 | { 104 | return isset($this->data[$key]); 105 | } 106 | 107 | public function getResource(ResourceType $resourceType): string 108 | { 109 | return $this->get($resourceType->value()); 110 | } 111 | 112 | public function hasResource(ResourceType $resourceType): bool 113 | { 114 | return '' !== $this->get($resourceType->value()); 115 | } 116 | 117 | /** @return Traversable */ 118 | public function getIterator(): Traversable 119 | { 120 | return new ArrayIterator($this->data); 121 | } 122 | 123 | /** @return array */ 124 | public function jsonSerialize(): array 125 | { 126 | return $this->data; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/MetadataList.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class MetadataList implements Countable, IteratorAggregate, JsonSerializable 17 | { 18 | /** @var array */ 19 | private $list; 20 | 21 | /** @param Metadata[]|mixed[] $list */ 22 | public function __construct(array $list) 23 | { 24 | $this->list = []; 25 | foreach ($list as $metadata) { 26 | if (! $metadata instanceof Metadata) { 27 | continue; 28 | } 29 | $this->list[$metadata->uuid()] = $metadata; 30 | } 31 | } 32 | 33 | public function merge(self $list): self 34 | { 35 | $new = new self([]); 36 | $new->list = array_merge($this->list, $list->list); 37 | return $new; 38 | } 39 | 40 | /** 41 | * Return a new list with only the Metadata that is on the uuids 42 | * 43 | * @param string[] $uuids 44 | * @return self 45 | */ 46 | public function filterWithUuids(array $uuids): self 47 | { 48 | $uuids = array_change_key_case(array_flip($uuids), CASE_LOWER); 49 | return new self(array_intersect_key($this->list, $uuids)); 50 | } 51 | 52 | /** 53 | * Return a new list excluding the Metadata that is on the uuids 54 | * 55 | * @param string[] $uuids 56 | * @return self 57 | */ 58 | public function filterWithOutUuids(array $uuids): self 59 | { 60 | $uuids = array_change_key_case(array_flip($uuids), CASE_LOWER); 61 | return new self(array_diff_key($this->list, $uuids)); 62 | } 63 | 64 | /** 65 | * Return a new list with only the Metadata which has an url to download according to specified resource type 66 | * 67 | * @param ResourceType $resourceType 68 | * @return self 69 | */ 70 | public function filterWithResourceLink(ResourceType $resourceType): self 71 | { 72 | return new self(array_filter($this->list, function (Metadata $metadata) use ($resourceType): bool { 73 | return $metadata->hasResource($resourceType); 74 | })); 75 | } 76 | 77 | public function has(string $uuid): bool 78 | { 79 | return isset($this->list[strtolower($uuid)]); 80 | } 81 | 82 | /** 83 | * Retrieve a Metadata by UUID, if the metadata object does not exists returns NULL 84 | * 85 | * @param string $uuid 86 | * @return Metadata|null 87 | */ 88 | public function find(string $uuid): ?Metadata 89 | { 90 | return $this->list[strtolower($uuid)] ?? null; 91 | } 92 | 93 | /** 94 | * Obtain a Metadata by UUID, the metadata object must exist in the collection 95 | * 96 | * @param string $uuid 97 | * @return Metadata 98 | * @throws LogicException when UUID is not found 99 | */ 100 | public function get(string $uuid): Metadata 101 | { 102 | $values = $this->find($uuid); 103 | if (null === $values) { 104 | throw LogicException::generic("UUID $uuid not found"); 105 | } 106 | return $values; 107 | } 108 | 109 | /** @return ArrayIterator */ 110 | public function getIterator(): ArrayIterator 111 | { 112 | return new ArrayIterator($this->list); 113 | } 114 | 115 | public function count(): int 116 | { 117 | return count($this->list); 118 | } 119 | 120 | /** @return array */ 121 | public function jsonSerialize(): array 122 | { 123 | return $this->list; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/NullMetadataMessageHandler.php: -------------------------------------------------------------------------------- 1 | setPeriod($startDate, $endDate); 50 | $this->setDownloadType($this->getDefaultDownloadType($downloadType)); 51 | $this->setComplement(ComplementsOption::todos()); 52 | $this->setStateVoucher(StatesVoucherOption::todos()); 53 | $this->setRfc(new RfcOption('')); 54 | $this->setRfcOnBehalf(new RfcOnBehalfOption('')); 55 | } 56 | 57 | /** 58 | * Set the query period using start date and current end date 59 | * 60 | * @param DateTimeImmutable $startDate 61 | * @param DateTimeImmutable $endDate 62 | * @return $this 63 | * @throws InvalidArgumentException if start date is greater than end date 64 | */ 65 | public function setPeriod(DateTimeImmutable $startDate, DateTimeImmutable $endDate): self 66 | { 67 | if ($startDate > $endDate) { 68 | throw InvalidArgumentException::periodStartDateGreaterThanEndDate($startDate, $endDate); 69 | } 70 | $this->startDate = $startDate; 71 | $this->endDate = $endDate; 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * @return DateTimeImmutable 78 | */ 79 | public function getStartDate(): DateTimeImmutable 80 | { 81 | return $this->startDate; 82 | } 83 | 84 | /** 85 | * Set the query period using specified start date and current end date 86 | * 87 | * @param DateTimeImmutable $startDate 88 | * @return $this 89 | * @throws InvalidArgumentException if start date is greater than end date 90 | */ 91 | public function setStartDate(DateTimeImmutable $startDate): self 92 | { 93 | return $this->setPeriod($startDate, $this->getEndDate()); 94 | } 95 | 96 | /** 97 | * @return DateTimeImmutable 98 | */ 99 | public function getEndDate(): DateTimeImmutable 100 | { 101 | return $this->endDate; 102 | } 103 | 104 | /** 105 | * Set the query period using current start date and specified end date 106 | * 107 | * @param DateTimeImmutable $endDate 108 | * @return $this 109 | * @throws InvalidArgumentException if start date is greater than end date 110 | */ 111 | public function setEndDate(DateTimeImmutable $endDate): self 112 | { 113 | return $this->setPeriod($this->getStartDate(), $endDate); 114 | } 115 | 116 | /** 117 | * @return RfcOption 118 | */ 119 | public function getRfc(): RfcOption 120 | { 121 | return $this->rfc; 122 | } 123 | 124 | /** 125 | * @param RfcOption $rfc 126 | * @return $this 127 | */ 128 | public function setRfc(RfcOption $rfc): self 129 | { 130 | $this->rfc = $rfc; 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * @return RfcOnBehalfOption 137 | */ 138 | public function getRfcOnBehalf(): RfcOnBehalfOption 139 | { 140 | return $this->rfcOnBehalf; 141 | } 142 | 143 | /** 144 | * @param RfcOnBehalfOption $rfcOnBehalf 145 | * @return $this 146 | */ 147 | public function setRfcOnBehalf(RfcOnBehalfOption $rfcOnBehalf): self 148 | { 149 | $this->rfcOnBehalf = $rfcOnBehalf; 150 | 151 | return $this; 152 | } 153 | 154 | /** 155 | * @return ComplementsOption 156 | */ 157 | public function getComplement(): ComplementsOption 158 | { 159 | return $this->complement; 160 | } 161 | 162 | /** 163 | * @param ComplementsOption $complement 164 | * @return $this 165 | */ 166 | public function setComplement(ComplementsOption $complement): self 167 | { 168 | $this->complement = $complement; 169 | 170 | return $this; 171 | } 172 | 173 | /** 174 | * @return StatesVoucherOption 175 | */ 176 | public function getStateVoucher(): StatesVoucherOption 177 | { 178 | return $this->stateVoucher; 179 | } 180 | 181 | /** 182 | * @param StatesVoucherOption $stateVoucher 183 | * @return $this 184 | */ 185 | public function setStateVoucher(StatesVoucherOption $stateVoucher): self 186 | { 187 | $this->stateVoucher = $stateVoucher; 188 | 189 | return $this; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/QueryByUuid.php: -------------------------------------------------------------------------------- 1 | setDownloadType($this->getDefaultDownloadType($downloadType)); 26 | $this->setUuid($uuid); 27 | } 28 | 29 | /** 30 | * @param UuidOption $uuid 31 | * @return $this 32 | */ 33 | final public function setUuid(UuidOption $uuid): self 34 | { 35 | if ('' === $uuid->value()) { 36 | throw InvalidArgumentException::emptyInput('UUID'); 37 | } 38 | $this->uuid = $uuid; 39 | 40 | return $this; 41 | } 42 | 43 | final public function getUuid(): UuidOption 44 | { 45 | return $this->uuid; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ResourceDownloader.php: -------------------------------------------------------------------------------- 1 | satHttpGateway = $satHttpGateway; 60 | $this->resourceType = $resourceType; 61 | $this->list = $list; 62 | $this->setConcurrency($concurrency); 63 | $this->setResourceFileNamer($resourceFileNamer ?? new ResourceFileNamerByType($resourceType)); 64 | } 65 | 66 | public function getResourceType(): ResourceType 67 | { 68 | return $this->resourceType; 69 | } 70 | 71 | public function hasMetadataList(): bool 72 | { 73 | return $this->list instanceof MetadataList; 74 | } 75 | 76 | public function getMetadataList(): MetadataList 77 | { 78 | if (null === $this->list) { 79 | throw LogicException::generic('The metadata list has not been set'); 80 | } 81 | return $this->list; 82 | } 83 | 84 | /** 85 | * Change the metadata list that will be used to perform downloads 86 | * 87 | * @param MetadataList $list 88 | * @return $this 89 | */ 90 | public function setMetadataList(MetadataList $list): self 91 | { 92 | $this->list = $list; 93 | return $this; 94 | } 95 | 96 | public function getConcurrency(): int 97 | { 98 | return $this->concurrency; 99 | } 100 | 101 | /** 102 | * Set concurrency, if lower than 1 will use 1 103 | * 104 | * @param int $concurrency 105 | * @return $this 106 | */ 107 | public function setConcurrency(int $concurrency): self 108 | { 109 | $this->concurrency = max(1, $concurrency); 110 | return $this; 111 | } 112 | 113 | public function getResourceFileNamer(): ResourceFileNamerInterface 114 | { 115 | return $this->resourceFileNamer; 116 | } 117 | 118 | /** 119 | * Set up the resource file namer 120 | * 121 | * @param ResourceFileNamerInterface $resourceFileNamer 122 | * @return $this 123 | */ 124 | public function setResourceFileNamer(ResourceFileNamerInterface $resourceFileNamer): self 125 | { 126 | $this->resourceFileNamer = $resourceFileNamer; 127 | return $this; 128 | } 129 | 130 | /** 131 | * Generate the promises to download all the elements on the metadata list that contains 132 | * a link to download the CFDI. 133 | * 134 | * Then the download operation was successful it will call ResourceDownloadHandlerInterface::onSuccess. 135 | * If some exception was raced when downloading, validating the response or calling onSuccess 136 | * then it will call ResourceDownloadHandlerInterface::onError. 137 | * 138 | * The download will return an array that contains all the successful processed uuids. 139 | * 140 | * @param ResourceDownloadHandlerInterface $handler 141 | * @return string[] 142 | * 143 | * @see ResourceDownloaderPromiseHandler::promiseFulfilled() 144 | * @see ResourceDownloaderPromiseHandler::promiseRejected() 145 | */ 146 | public function download(ResourceDownloadHandlerInterface $handler): array 147 | { 148 | // wrap the provided handler into the main handler, to throw the expected exceptions 149 | $promisesHandler = $this->makePromiseHandler($handler); 150 | // create the promises iterator 151 | $promises = $this->makePromises(); 152 | // create the invoker 153 | $invoker = new EachPromise($promises, [ 154 | 'concurrency' => $this->getConcurrency(), 155 | 'fulfilled' => [$promisesHandler, 'promiseFulfilled'], 156 | 'rejected' => [$promisesHandler, 'promiseRejected'], 157 | ]); 158 | // create the promise from the invoker and wait for it to finish 159 | $invoker->promise()->wait(); 160 | 161 | return $promisesHandler->downloadedUuids(); 162 | } 163 | 164 | /** 165 | * Factory method to make the default ResourceDownloaderPromiseHandler, 166 | * by extracting the creation it can be replaced with any ResourceDownloaderPromiseHandlerInterface. 167 | * 168 | * @param ResourceDownloadHandlerInterface $handler 169 | * @return ResourceDownloaderPromiseHandlerInterface 170 | */ 171 | protected function makePromiseHandler(ResourceDownloadHandlerInterface $handler): ResourceDownloaderPromiseHandlerInterface 172 | { 173 | return new ResourceDownloaderPromiseHandler($this->getResourceType(), $handler); 174 | } 175 | 176 | /** 177 | * Factory method to make a Promise iterator with each item in the Metadata in the MedataList 178 | * that has a URL to download the XML. 179 | * By extracting the creation it can be replaced with any iterable. 180 | * 181 | * @return Traversable 182 | */ 183 | protected function makePromises(): Traversable 184 | { 185 | foreach ($this->getMetadataList() as $metadata) { 186 | $link = $metadata->getResource($this->getResourceType()); 187 | if ('' === $link) { 188 | continue; 189 | } 190 | yield $metadata->uuid() => $this->satHttpGateway->getAsync($link); 191 | } 192 | } 193 | 194 | /** 195 | * Generic method to download all the elements on the metadata list that contains a link to download. 196 | * Before download, it checks that the destination directory exists, if it doesn't exist and call with 197 | * true in $createDir then the directory will be created recursively using mode $mode. 198 | * 199 | * When one of the downloads fails it will throw an exception. 200 | * 201 | * Return the list of fulfilled UUID 202 | * 203 | * @param string $destinationFolder 204 | * @param bool $createFolder 205 | * @param int $createMode 206 | * @return string[] 207 | * 208 | * @throws InvalidArgumentException if destination folder argument is empty 209 | * @throws RuntimeException if didn't ask to create folder and path does not exist 210 | * @throws RuntimeException if ask to create folder path exists and is not a folder 211 | * @throws RuntimeException if unable to create folder 212 | * 213 | * @see ResourceDownloadStoreInFolder 214 | */ 215 | public function saveTo(string $destinationFolder, bool $createFolder = false, int $createMode = 0775): array 216 | { 217 | $storeHandler = new ResourceDownloadStoreInFolder($destinationFolder, $this->getResourceFileNamer()); 218 | $storeHandler->checkDestinationFolder($createFolder, $createMode); 219 | return $this->download($storeHandler); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/ResourceType.php: -------------------------------------------------------------------------------- 1 | 'urlXml', 29 | 'pdf' => 'urlPdf', 30 | 'cancelRequest' => 'urlCancelRequest', 31 | 'cancelVoucher' => 'urlCancelVoucher', 32 | ]; 33 | } 34 | 35 | public function fileTypeIsXml(): bool 36 | { 37 | return $this->isXml(); 38 | } 39 | 40 | public function fileTypeIsPdf(): bool 41 | { 42 | return ! $this->isXml(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/SatHttpGateway.php: -------------------------------------------------------------------------------- 1 | $cookieJar ?? new CookieJar()]); 37 | 38 | // if the cookieJar was set on the client but not in the configuration 39 | if (null === $cookieJar) { 40 | /** 41 | * @noinspection PhpDeprecationInspection 42 | * @var mixed $cookieJar 43 | */ 44 | $cookieJar = $client->getConfig(RequestOptions::COOKIES); 45 | if (! $cookieJar instanceof CookieJarInterface) { 46 | $cookieJar = new CookieJar(); 47 | } 48 | } 49 | 50 | $this->client = $client; 51 | $this->cookieJar = $cookieJar; 52 | } 53 | 54 | /** 55 | * @param string $url 56 | * @param string $referer 57 | * @return string 58 | * @throws SatHttpGatewayException 59 | */ 60 | public function getAuthLoginPage(string $url, string $referer = ''): string 61 | { 62 | return $this->get('get login page', $url, $referer); 63 | } 64 | 65 | /** 66 | * @return string 67 | * @throws SatHttpGatewayException 68 | */ 69 | public function getPortalMainPage(): string 70 | { 71 | return $this->get('get portal main page', URLS::PORTAL_CFDI); 72 | } 73 | 74 | /** 75 | * @param array $formData 76 | * @return string 77 | * @throws SatHttpGatewayException 78 | */ 79 | public function postPortalMainPage(array $formData): string 80 | { 81 | return $this->post('post to portal main page', URLS::PORTAL_CFDI, Headers::post('', ''), $formData); 82 | } 83 | 84 | /** 85 | * @param string $loginUrl 86 | * @param array $formParams 87 | * @return string 88 | * @throws SatHttpGatewayException 89 | */ 90 | public function postCiecLoginData(string $loginUrl, array $formParams): string 91 | { 92 | $headers = Headers::post($this->urlHost(URLS::AUTH_LOGIN), URLS::AUTH_LOGIN); 93 | return $this->post('post login data', $loginUrl, $headers, $formParams); 94 | } 95 | 96 | /** 97 | * @param string $loginUrl 98 | * @param array $formParams 99 | * @return string 100 | * @throws SatHttpGatewayException 101 | */ 102 | public function postFielLoginData(string $loginUrl, array $formParams): string 103 | { 104 | $headers = Headers::post($this->urlHost($loginUrl), $loginUrl); 105 | return $this->post('post fiel login data', $loginUrl, $headers, $formParams); 106 | } 107 | 108 | /** 109 | * @param string $url 110 | * @return string 111 | * @throws SatHttpGatewayException 112 | */ 113 | public function getPortalPage(string $url): string 114 | { 115 | return $this->get('get portal page', $url); 116 | } 117 | 118 | /** 119 | * @param string $url 120 | * @param array $formParams 121 | * @return string 122 | * @throws SatHttpGatewayException 123 | */ 124 | public function postAjaxSearch(string $url, array $formParams): string 125 | { 126 | $headers = Headers::postAjax($this->urlHost(URLS::PORTAL_CFDI), $url); 127 | return $this->post('query search page', $url, $headers, $formParams); 128 | } 129 | 130 | /** 131 | * Create a promise (asynchronous request) to perform an XML download. 132 | * 133 | * @param string $link 134 | * @return PromiseInterface 135 | */ 136 | public function getAsync(string $link): PromiseInterface 137 | { 138 | $options = [ 139 | RequestOptions::HEADERS => Headers::get(), 140 | RequestOptions::COOKIES => $this->cookieJar, 141 | ]; 142 | return $this->client->requestAsync('GET', $link, $options); 143 | } 144 | 145 | public function clearCookieJar(): void 146 | { 147 | $this->cookieJar->clear(); 148 | } 149 | 150 | public function isCookieJarEmpty(): bool 151 | { 152 | return [] === $this->cookieJar->toArray(); 153 | } 154 | 155 | /** 156 | * Helper to make a GET request 157 | * 158 | * @param string $reason 159 | * @param string $url 160 | * @param string $referer 161 | * @return string 162 | * @throws SatHttpGatewayClientException 163 | * @throws SatHttpGatewayResponseException 164 | */ 165 | private function get(string $reason, string $url, string $referer = ''): string 166 | { 167 | $options = [ 168 | RequestOptions::HEADERS => Headers::get($referer), 169 | ]; 170 | return $this->request('GET', $url, $options, $reason); 171 | } 172 | 173 | /** 174 | * Helper to make a POST request 175 | * 176 | * @param string $reason 177 | * @param string $url 178 | * @param array $headers 179 | * @param array $data 180 | * @return string 181 | * @throws SatHttpGatewayException 182 | */ 183 | private function post(string $reason, string $url, array $headers, array $data): string 184 | { 185 | $options = [ 186 | RequestOptions::HEADERS => $headers, 187 | RequestOptions::FORM_PARAMS => $data, 188 | ]; 189 | return $this->request('POST', $url, $options, $reason); 190 | } 191 | 192 | /** 193 | * @param string $method 194 | * @param string $uri 195 | * @param array $options 196 | * @param string $reason 197 | * @return string 198 | * @throws SatHttpGatewayClientException 199 | * @throws SatHttpGatewayResponseException 200 | */ 201 | private function request(string $method, string $uri, array $options, string $reason): string 202 | { 203 | $options = [ 204 | RequestOptions::COOKIES => $this->cookieJar, 205 | RequestOptions::ALLOW_REDIRECTS => ['trackredirects' => true], 206 | ] + $options; 207 | 208 | $this->effectiveUri = $uri; 209 | try { 210 | $response = $this->client->request($method, $uri, $options); 211 | } catch (GuzzleException $exception) { 212 | if ($exception instanceof RequestException && null !== $exception->getResponse()) { 213 | $this->setEffectiveUriFromResponse($exception->getResponse(), $uri); 214 | } else { 215 | $this->effectiveUri = $uri; 216 | } 217 | /** @var array $requestHeaders */ 218 | $requestHeaders = $options[RequestOptions::HEADERS]; 219 | /** @var array $requestData */ 220 | $requestData = $options[RequestOptions::FORM_PARAMS] ?? []; 221 | throw SatHttpGatewayClientException::clientException( 222 | $reason, 223 | $method, 224 | $uri, 225 | $requestHeaders, 226 | $requestData, 227 | $exception, 228 | ); 229 | } 230 | $this->setEffectiveUriFromResponse($response, $uri); 231 | 232 | $contents = strval($response->getBody()); 233 | if ('' === $contents) { 234 | /** @var array $requestHeaders */ 235 | $requestHeaders = $options[RequestOptions::HEADERS]; 236 | /** @var array $requestData */ 237 | $requestData = $options[RequestOptions::FORM_PARAMS] ?? []; 238 | throw SatHttpGatewayResponseException::unexpectedEmptyResponse( 239 | $reason, 240 | $response, 241 | $method, 242 | $uri, 243 | $requestHeaders, 244 | $requestData, 245 | ); 246 | } 247 | 248 | return $contents; 249 | } 250 | 251 | public function getLogout(): string 252 | { 253 | $metaRefresh = new MetaRefreshInspector(); 254 | 255 | $destination = URLS::PORTAL_CFDI_LOGOUT; 256 | $referer = URLS::PORTAL_CFDI; 257 | 258 | do { 259 | $html = $this->getLogoutWithoutException($destination, $referer); 260 | $referer = $this->getEffectiveUri(); // it can be redirected several 261 | $destination = $metaRefresh->obtainUrl($html, $referer); 262 | } while ('' !== $destination && $destination !== $referer); 263 | 264 | $this->clearCookieJar(); 265 | 266 | return $html; 267 | } 268 | 269 | private function getLogoutWithoutException(string $destination, string $referer): string 270 | { 271 | try { 272 | return $this->get('logout', $destination, $referer); 273 | } catch (SatHttpGatewayException $exception) { 274 | return ''; 275 | } 276 | } 277 | 278 | private function getEffectiveUri(): string 279 | { 280 | return $this->effectiveUri; 281 | } 282 | 283 | private function setEffectiveUriFromResponse(ResponseInterface $response, string $previousUri): void 284 | { 285 | $history = $response->getHeader('X-Guzzle-Redirect-History'); 286 | $effectiveUri = (string) end($history); 287 | $this->effectiveUri = $effectiveUri ?: $previousUri; 288 | } 289 | 290 | private function urlHost(string $url): string 291 | { 292 | return (string) parse_url($url, PHP_URL_HOST); 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/SatScraper.php: -------------------------------------------------------------------------------- 1 | sessionManager = $sessionManager; 38 | $this->satHttpGateway = $satHttpGateway ?? $this->createDefaultSatHttpGateway(); 39 | $this->metadataMessageHandler = $metadataMessageHandler ?? new NullMetadataMessageHandler(); 40 | } 41 | 42 | /** 43 | * Method factory to create a MetadataDownloader 44 | * 45 | * @internal 46 | */ 47 | protected function createMetadataDownloader(): MetadataDownloader 48 | { 49 | return new MetadataDownloader($this->createQueryResolver(), $this->metadataMessageHandler); 50 | } 51 | 52 | /** 53 | * Method factory to create a SatHttpGateway 54 | * 55 | * @internal 56 | */ 57 | protected function createDefaultSatHttpGateway(): SatHttpGateway 58 | { 59 | return new SatHttpGateway(); 60 | } 61 | 62 | /** 63 | * Method factory to create a QueryResolver 64 | * 65 | * @internal 66 | */ 67 | protected function createQueryResolver(): QueryResolver 68 | { 69 | return new QueryResolver($this->satHttpGateway); 70 | } 71 | 72 | public function resourceDownloader( 73 | ResourceType $resourceType = null, 74 | ?MetadataList $metadataList = null, 75 | int $concurrency = ResourceDownloader::DEFAULT_CONCURRENCY 76 | ): ResourceDownloader { 77 | $resourceType = $resourceType ?? ResourceType::xml(); 78 | return new ResourceDownloader($this->satHttpGateway, $resourceType, $metadataList, $concurrency); 79 | } 80 | 81 | public function confirmSessionIsAlive(): self 82 | { 83 | $sessionManager = $this->getSessionManager(); 84 | $sessionManager->setHttpGateway($this->getSatHttpGateway()); 85 | 86 | if (! $sessionManager->hasLogin()) { 87 | $sessionManager->login(); 88 | } 89 | $sessionManager->accessPortalMainPage(); 90 | 91 | return $this; 92 | } 93 | 94 | public function listByUuids(array $uuids, DownloadType $downloadType): MetadataList 95 | { 96 | $this->confirmSessionIsAlive(); 97 | return $this->createMetadataDownloader()->downloadByUuids($uuids, $downloadType); 98 | } 99 | 100 | public function listByPeriod(QueryByFilters $query): MetadataList 101 | { 102 | $this->confirmSessionIsAlive(); 103 | return $this->createMetadataDownloader()->downloadByDate($query); 104 | } 105 | 106 | public function listByDateTime(QueryByFilters $query): MetadataList 107 | { 108 | $this->confirmSessionIsAlive(); 109 | return $this->createMetadataDownloader()->downloadByDateTime($query); 110 | } 111 | 112 | public function getSessionManager(): SessionManager 113 | { 114 | return $this->sessionManager; 115 | } 116 | 117 | public function getSatHttpGateway(): SatHttpGateway 118 | { 119 | return $this->satHttpGateway; 120 | } 121 | 122 | public function getMetadataMessageHandler(): MetadataMessageHandler 123 | { 124 | return $this->metadataMessageHandler; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Sessions/AbstractSessionManager.php: -------------------------------------------------------------------------------- 1 | getHttpGateway()->getLogout(); 25 | } 26 | 27 | public function accessPortalMainPage(): void 28 | { 29 | $satHttpGateway = $this->getHttpGateway(); 30 | try { 31 | $htmlMainPage = $satHttpGateway->getPortalMainPage(); 32 | $inputs = (new HtmlForm($htmlMainPage, 'form'))->getFormValues(); 33 | if (count($inputs) > 0) { 34 | $htmlMainPage = $satHttpGateway->postPortalMainPage($inputs); 35 | } 36 | } catch (SatHttpGatewayException $exception) { 37 | throw $this->createExceptionConnection('registering on login page', $exception); 38 | } 39 | 40 | if (false === strpos($htmlMainPage, 'RFC Autenticado: ' . $this->getRfc())) { 41 | throw $this->createExceptionNotAuthenticated($htmlMainPage); 42 | } 43 | } 44 | 45 | public function getHttpGateway(): SatHttpGateway 46 | { 47 | if (null === $this->httpGateway) { 48 | throw LogicException::generic('Must set http gateway property before use'); 49 | } 50 | return $this->httpGateway; 51 | } 52 | 53 | public function setHttpGateway(SatHttpGateway $httpGateway): void 54 | { 55 | $this->httpGateway = $httpGateway; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Sessions/Ciec/CiecLoginException.php: -------------------------------------------------------------------------------- 1 | */ 18 | private $postedData; 19 | 20 | /** @var CaptchaImageInterface|null */ 21 | private $captchaImage; 22 | 23 | /** 24 | * LoginException constructor. 25 | * 26 | * @param string $message 27 | * @param CiecSessionData $sessionData 28 | * @param string $contents 29 | * @param array $postedData 30 | * @param Throwable|null $previous 31 | */ 32 | public function __construct(string $message, CiecSessionData $sessionData, string $contents, array $postedData = [], Throwable $previous = null) 33 | { 34 | parent::__construct($message, $contents, $previous); 35 | $this->sessionData = $sessionData; 36 | $this->postedData = $postedData; 37 | } 38 | 39 | public static function notRegisteredAfterLogin(CiecSessionData $data, string $contents): self 40 | { 41 | $message = "It was expected to have the session registered on portal home page with RFC {$data->getRfc()}"; 42 | return new self($message, $data, $contents); 43 | } 44 | 45 | public static function noCaptchaImageFound(CiecSessionData $data, string $contents, Throwable $previous = null): self 46 | { 47 | return new self('It was unable to find the captcha image', $data, $contents, [], $previous); 48 | } 49 | 50 | public static function captchaWithoutAnswer(CiecSessionData $data, CaptchaImageInterface $captchaImage, Throwable $previous = null): self 51 | { 52 | $exception = new self('Unable to decode captcha', $data, '', [], $previous); 53 | $exception->captchaImage = $captchaImage; 54 | return $exception; 55 | } 56 | 57 | /** 58 | * @param CiecSessionData $data 59 | * @param string $contents 60 | * @param array $postedData 61 | * @return self 62 | */ 63 | public static function incorrectLoginData(CiecSessionData $data, string $contents, array $postedData): self 64 | { 65 | return new self('Incorrect login data', $data, $contents, $postedData); 66 | } 67 | 68 | public static function connectionException(string $when, CiecSessionData $data, SatHttpGatewayException $exception): self 69 | { 70 | return new self("Connection error when $when", $data, '', [], $exception); 71 | } 72 | 73 | public function getSessionData(): CiecSessionData 74 | { 75 | return $this->sessionData; 76 | } 77 | 78 | /** @return array */ 79 | public function getPostedData(): array 80 | { 81 | return $this->postedData; 82 | } 83 | 84 | public function getCaptchaImage(): ?CaptchaImageInterface 85 | { 86 | return $this->captchaImage; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Sessions/Ciec/CiecSessionData.php: -------------------------------------------------------------------------------- 1 | rfc = $rfc; 60 | $this->ciec = $ciec; 61 | $this->captchaResolver = $captchaResolver; 62 | $this->maxTriesCaptcha = max(1, $maxTriesCaptcha); 63 | $this->maxTriesLogin = max(1, $maxTriesLogin); 64 | } 65 | 66 | public function getRfc(): string 67 | { 68 | return $this->rfc; 69 | } 70 | 71 | public function getCiec(): string 72 | { 73 | return $this->ciec; 74 | } 75 | 76 | public function getCaptchaResolver(): CaptchaResolverInterface 77 | { 78 | return $this->captchaResolver; 79 | } 80 | 81 | public function getMaxTriesCaptcha(): int 82 | { 83 | return $this->maxTriesCaptcha; 84 | } 85 | 86 | public function getMaxTriesLogin(): int 87 | { 88 | return $this->maxTriesLogin; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Sessions/Ciec/CiecSessionManager.php: -------------------------------------------------------------------------------- 1 | sessionData = $sessionData; 25 | } 26 | 27 | public static function create(string $rfc, string $ciec, CaptchaResolverInterface $resolver): self 28 | { 29 | $sessionData = new CiecSessionData($rfc, $ciec, $resolver); 30 | return new self($sessionData); 31 | } 32 | 33 | /** 34 | * @throws CiecLoginException 35 | */ 36 | public function requestCaptchaImage(): CaptchaImage 37 | { 38 | try { 39 | $html = $this->getHttpGateway()->getAuthLoginPage(URLS::AUTH_LOGIN); 40 | } catch (SatHttpGatewayException $exception) { 41 | throw CiecLoginException::connectionException('getting captcha image', $this->sessionData, $exception); 42 | } 43 | 44 | try { 45 | $captchaBase64Extractor = new CaptchaBase64Extractor(); 46 | $captchaImage = $captchaBase64Extractor->retrieveCaptchaImage($html); 47 | } catch (Throwable $exception) { 48 | throw CiecLoginException::noCaptchaImageFound($this->sessionData, $html, $exception); 49 | } 50 | 51 | return $captchaImage; 52 | } 53 | 54 | /** 55 | * @param int $attempt 56 | * @return string 57 | * @throws CiecLoginException 58 | */ 59 | public function getCaptchaValue(int $attempt): string 60 | { 61 | $captchaImage = $this->requestCaptchaImage(); 62 | try { 63 | $result = $this->sessionData->getCaptchaResolver()->resolve($captchaImage); 64 | return $result->getValue(); 65 | } catch (Throwable $exception) { 66 | if ($attempt < $this->sessionData->getMaxTriesCaptcha()) { 67 | return $this->getCaptchaValue($attempt + 1); 68 | } 69 | if (! $exception instanceof CiecLoginException) { 70 | $exception = CiecLoginException::captchaWithoutAnswer($this->sessionData, $captchaImage, $exception); 71 | } 72 | /** @var CiecLoginException $exception */ 73 | throw $exception; 74 | } 75 | } 76 | 77 | public function hasLogin(): bool 78 | { 79 | $httpGateway = $this->getHttpGateway(); 80 | 81 | // if cookie is empty, then it will not be able to detect a session anyway 82 | if ($httpGateway->isCookieJarEmpty()) { 83 | return false; 84 | } 85 | 86 | // check login on CFDIAU 87 | try { 88 | $html = $httpGateway->getAuthLoginPage(URLS::AUTH_LOGIN); 89 | } catch (SatHttpGatewayException $exception) { 90 | throw CiecLoginException::connectionException('getting login page', $this->sessionData, $exception); 91 | } 92 | // if the user has a valid session then CFDIAU will try to send to this location 93 | if (false === strpos($html, 'https://cfdiau.sat.gob.mx/nidp/app?sid=0')) { 94 | $this->logout(); 95 | return false; 96 | } 97 | 98 | // check main page 99 | try { 100 | $html = $httpGateway->getPortalMainPage(); 101 | } catch (SatHttpGatewayException $exception) { 102 | throw CiecLoginException::connectionException('getting portal main page', $this->sessionData, $exception); 103 | } 104 | // if portal main page session is no longer valid then will try to force you to log out 105 | if (false !== strpos($html, urlencode('https://portalcfdi.facturaelectronica.sat.gob.mx/logout.aspx?salir=y'))) { 106 | $this->logout(); 107 | return false; 108 | } 109 | 110 | return true; 111 | } 112 | 113 | public function login(): void 114 | { 115 | $this->loginInternal(1); 116 | } 117 | 118 | /** 119 | * @param int $attempt 120 | * @return string 121 | * @throws CiecLoginException 122 | */ 123 | private function loginInternal(int $attempt): string 124 | { 125 | $captchaValue = $this->getCaptchaValue(1); 126 | 127 | try { 128 | $response = $this->loginPostLoginData($captchaValue); 129 | } catch (CiecLoginException $exception) { 130 | if ($attempt < $this->sessionData->getMaxTriesLogin()) { 131 | return $this->loginInternal($attempt + 1); 132 | } 133 | throw $exception; 134 | } 135 | 136 | return $response; 137 | } 138 | 139 | /** 140 | * @throws CiecLoginException 141 | */ 142 | public function loginPostLoginData(string $captchaValue): string 143 | { 144 | $postData = [ 145 | 'Ecom_User_ID' => $this->sessionData->getRfc(), 146 | 'Ecom_Password' => $this->sessionData->getCiec(), 147 | 'option' => 'credential', 148 | 'submit' => 'Enviar', 149 | 'userCaptcha' => $captchaValue, 150 | ]; 151 | try { 152 | $response = $this->getHttpGateway()->postCiecLoginData(URLS::AUTH_LOGIN, $postData); 153 | } catch (SatHttpGatewayException $exception) { 154 | throw CiecLoginException::connectionException('sending login data', $this->sessionData, $exception); 155 | } 156 | 157 | if (false !== strpos($response, 'Ecom_User_ID')) { 158 | throw CiecLoginException::incorrectLoginData($this->sessionData, $response, $postData); 159 | } 160 | 161 | return $response; 162 | } 163 | 164 | public function getSessionData(): CiecSessionData 165 | { 166 | return $this->sessionData; 167 | } 168 | 169 | public function getRfc(): string 170 | { 171 | return $this->sessionData->getRfc(); 172 | } 173 | 174 | protected function createExceptionConnection(string $when, SatHttpGatewayException $exception): LoginException 175 | { 176 | return CiecLoginException::connectionException('registering on login page', $this->sessionData, $exception); 177 | } 178 | 179 | protected function createExceptionNotAuthenticated(string $html): LoginException 180 | { 181 | return CiecLoginException::notRegisteredAfterLogin($this->sessionData, $html); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/Sessions/Fiel/ChallengeResolver.php: -------------------------------------------------------------------------------- 1 | */ 18 | private $fields; 19 | 20 | /** @var FielSessionData */ 21 | private $sessionData; 22 | 23 | /** @param array $fields */ 24 | private function __construct(array $fields, FielSessionData $sessionData) 25 | { 26 | $this->sessionData = $sessionData; 27 | $this->fields = $fields; 28 | } 29 | 30 | public static function createFromHtml(string $html, FielSessionData $sessionData): self 31 | { 32 | $inputs = (new HtmlForm($html, '#certform'))->getFormValues(); 33 | if (isset($inputs[''])) { 34 | unset($inputs['']); 35 | } 36 | return new self($inputs, $sessionData); 37 | } 38 | 39 | /** 40 | * @return array 41 | */ 42 | public function obtainFormFields(): array 43 | { 44 | return array_merge($this->fields, [ 45 | 'token' => $this->obtainTokenFromTokenUuid(), 46 | 'guid' => $this->getTokenUuid(), 47 | 'fert' => $this->sessionData->getValidTo(), 48 | ]); 49 | } 50 | 51 | public function obtainTokenFromTokenUuid(): string 52 | { 53 | $fiel = $this->getSessionData(); 54 | $rfc = $fiel->getRfc(); 55 | $serial = $fiel->getSerialNumber(); 56 | $sourceString = "{$this->getTokenUuid()}|$rfc|$serial"; 57 | $signature = base64_encode(base64_encode($fiel->sign($sourceString, OPENSSL_ALGO_SHA1))); 58 | return base64_encode(base64_encode($sourceString) . '#' . $signature); 59 | } 60 | 61 | public function getTokenUuid(): string 62 | { 63 | return ($this->fields['guid'] ?? '') ?: ''; 64 | } 65 | 66 | public function getSessionData(): FielSessionData 67 | { 68 | return $this->sessionData; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Sessions/Fiel/FielLoginException.php: -------------------------------------------------------------------------------- 1 | sessionData = $sessionData; 19 | } 20 | 21 | public static function connectionException(string $when, FielSessionData $sessionData, Throwable $previous = null): self 22 | { 23 | return new self("Connection error when $when", '', $sessionData, $previous); 24 | } 25 | 26 | public static function notRegisteredAfterLogin(FielSessionData $data, string $contents): self 27 | { 28 | $message = "It was expected to have the session registered on portal home page with RFC {$data->getRfc()}"; 29 | return new self($message, $contents, $data); 30 | } 31 | 32 | public function getSessionData(): FielSessionData 33 | { 34 | return $this->sessionData; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Sessions/Fiel/FielSessionData.php: -------------------------------------------------------------------------------- 1 | fiel = $fiel; 17 | } 18 | 19 | public function getFiel(): Credential 20 | { 21 | return $this->fiel; 22 | } 23 | 24 | public function getRfc(): string 25 | { 26 | return $this->fiel->certificate()->rfc(); 27 | } 28 | 29 | /** 30 | * The valid to is formatted as yymmddhhiissZ 31 | * Example: 2023-06-13T21:05:15+00:00 is 230613210515Z 32 | * @return string 33 | */ 34 | public function getValidTo(): string 35 | { 36 | return $this->fiel->certificate()->validTo(); 37 | } 38 | 39 | public function getSerialNumber(): string 40 | { 41 | return $this->fiel->certificate()->serialNumber()->bytes(); 42 | } 43 | 44 | public function sign(string $data, int $algorithm): string 45 | { 46 | return $this->fiel->privateKey()->sign($data, $algorithm); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Sessions/Fiel/FielSessionManager.php: -------------------------------------------------------------------------------- 1 | sessionData = $fielSessionData; 23 | } 24 | 25 | public static function create(Credential $credential): self 26 | { 27 | return new self(new FielSessionData($credential)); 28 | } 29 | 30 | public function hasLogin(): bool 31 | { 32 | $httpGateway = $this->getHttpGateway(); 33 | 34 | // if cookie is empty, then it will not be able to detect a session anyway 35 | if ($httpGateway->isCookieJarEmpty()) { 36 | return false; 37 | } 38 | 39 | try { 40 | // check is logged in on portal 41 | $html = $httpGateway->getPortalMainPage(); 42 | if (false === strpos($html, 'RFC Autenticado: ' . $this->getRfc())) { 43 | return false; 44 | } 45 | } catch (SatHttpGatewayException $exception) { 46 | // if http error, consider without session 47 | return false; 48 | } 49 | 50 | return true; 51 | } 52 | 53 | public function login(): void 54 | { 55 | $httpGateway = $this->getHttpGateway(); 56 | 57 | try { 58 | // contact homepage, it will try to redirect to access by password 59 | $httpGateway->getPortalMainPage(); 60 | 61 | // previous page will try to redirect to access by password using post 62 | $httpGateway->postCiecLoginData(URLS::AUTH_LOGIN_CIEC, []); 63 | 64 | // change to fiel login page and get challenge 65 | $html = $httpGateway->getAuthLoginPage(URLS::AUTH_LOGIN_FIEL, URLS::AUTH_LOGIN_CIEC); 66 | 67 | // resolve and submit challenge, it returns an autosubmit form 68 | $inputs = $this->resolveChallengeUsingFiel($html); 69 | $html = $httpGateway->postFielLoginData(URLS::AUTH_LOGIN_FIEL, $inputs); 70 | 71 | // submit login credentials to portalcfdi 72 | $form = new HtmlForm($html, 'form'); 73 | $inputs = $form->getFormValues(); // wa, weesult, wctx 74 | $httpGateway->postPortalMainPage($inputs); 75 | } catch (SatHttpGatewayException $exception) { 76 | throw FielLoginException::connectionException('try to login using FIEL', $this->sessionData, $exception); 77 | } 78 | } 79 | 80 | public function getSessionData(): FielSessionData 81 | { 82 | return $this->sessionData; 83 | } 84 | 85 | public function getRfc(): string 86 | { 87 | return $this->sessionData->getRfc(); 88 | } 89 | 90 | /** 91 | * @param string $html 92 | * @return array 93 | */ 94 | private function resolveChallengeUsingFiel(string $html): array 95 | { 96 | $resolver = ChallengeResolver::createFromHtml($html, $this->getSessionData()); 97 | return $resolver->obtainFormFields(); 98 | } 99 | 100 | protected function createExceptionConnection(string $when, SatHttpGatewayException $exception): LoginException 101 | { 102 | return FielLoginException::connectionException($when, $this->sessionData, $exception); 103 | } 104 | 105 | protected function createExceptionNotAuthenticated(string $html): LoginException 106 | { 107 | return FielLoginException::notRegisteredAfterLogin($this->sessionData, $html); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Sessions/SessionManager.php: -------------------------------------------------------------------------------- 1 |