estratos / finkok-bundle
Bundle de Symfony 7.4 para timbrar y cancelar CFDI consumiendo los Web Services SOAP de Finkok, sin depender de ext-soap.
Package info
github.com/estratos/finkok-bundle
Type:symfony-bundle
pkg:composer/estratos/finkok-bundle
Requires
- php: ^8.2
- ext-dom: *
- ext-libxml: *
- psr/log: ^2.0 || ^3.0
- symfony/config: ^7.4
- symfony/dependency-injection: ^7.4
- symfony/http-client: ^7.4
- symfony/http-client-contracts: ^3.5
- symfony/http-kernel: ^7.4
Requires (Dev)
- phpunit/phpunit: ^11.5
- symfony/var-dumper: ^7.4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-06 04:34:45 UTC
README
Bundle de Symfony 7.4 para consumir los Web Services SOAP de Finkok:
timbrado (stamp.wsdl) y cancelación (cancel.wsdl) de CFDI.
- Sin
ext-soap: el transporte SOAP está construido sobre Symfony HttpClient (PSR-18), con los envelopes escritos a mano a partir del WSDL real. Funciona en instalaciones donde la extensión SOAP no está disponible, es testeable conMockHttpClienty permite inspeccionar byte a byte lo que se envía a Finkok. - Multi-emisor: varios RFC, ambientes y CSD en la misma aplicación mediante perfiles conmutables por nombre, seguro para workers de larga vida.
- DTOs tipados: nunca se manipulan arreglos ni
stdClassde la respuesta. - Errores tipificados: los ~24 códigos de incidencia de Finkok están en un enum con descripción y pista de solución.
- Validación previa: evita gastar timbrados con XML mal formado (301/705), sin sello (CFDI40102) o de más de 1 MB.
Tabla de contenido
- Requisitos
- Instalación
- Configuración
- Uso
- Métodos disponibles
- Manejo de errores
- Servicios y extensibilidad
- Notas de integración
- Seguridad
- Pruebas
- Estado y siguientes pasos
- Soporte y contribución
Requisitos
| Requisito | Versión |
|---|---|
| PHP | 8.2 o superior |
| Symfony | 7.4 |
| Extensiones PHP | dom, libxml |
| Extensión PHP | openssl (solo si usas CSD propios para cancelar) |
| No requiere | ext-soap |
Instalación
composer require estratos/finkok-bundle
Con Symfony Flex el bundle queda registrado automáticamente, sin que tengas que
hacer nada más. Si tu aplicación no usa Flex, añádelo a mano en
config/bundles.php:
return [ // … Estratos\FinkokBundle\FinkokBundle::class => ['all' => true], ];
No necesitas ext-soap: el transporte SOAP está construido sobre Symfony
HttpClient, que ya forma parte de las dependencias del paquete.
Configuración
El bundle configura solo infraestructura, nunca credenciales:
# config/packages/finkok.yaml finkok: http: timeout: 30 # segundos por petición log_payloads: false # true escribe el envelope completo en el log (¡datos fiscales!) retry: enabled: true # reintenta errores de transporte y códigos transitorios max_retries: 2 preflight: enabled: true # valida el CFDI en local antes de enviarlo require_signature: true # exige el atributo Sello (evita CFDI40102)
Las URLs por defecto son las oficiales de Finkok y pueden sobrescribirse:
finkok: endpoints: stamp: demo: 'https://demo-facturacion.finkok.com/servicios/soap/stamp' production: 'https://facturacion.finkok.com/servicios/soap/stamp' cancel: demo: 'https://demo-facturacion.finkok.com/servicios/soap/cancel' production: 'https://facturacion.finkok.com/servicios/soap/cancel'
Credenciales: las aporta tu aplicación
Los perfiles no viven en la configuración del bundle. Los crea la aplicación
que consume los servicios y se inyectan mediante un servicio que implemente
Estratos\FinkokBundle\Config\CredentialsProviderInterface. Así las credenciales
pueden venir de variables de entorno, de un secret manager, de la base de datos o
del inquilino activo, sin quedar embebidas en la configuración de un paquete.
Opción 1: declararlos en config/services.yaml (sin escribir PHP)
# config/services.yaml services: # El proveedor que consume el bundle. Estratos\FinkokBundle\Config\CredentialsProviderInterface: class: Estratos\FinkokBundle\Config\CredentialsProvider arguments: $profiles: matriz: '@app.finkok.credentials.matriz' sucursal: '@app.finkok.credentials.sucursal' $default: matriz # Cada RFC emisor, con su ambiente y —si va a cancelar— su CSD. app.finkok.credentials.matriz: class: Estratos\FinkokBundle\Config\Credentials arguments: $name: matriz $username: '%env(FINKOK_USERNAME)%' $password: '%env(FINKOK_PASSWORD)%' $taxpayerId: '%env(FINKOK_RFC)%' $environment: !php/enum Estratos\FinkokBundle\Config\Environment::Demo app.finkok.credentials.sucursal: class: Estratos\FinkokBundle\Config\Credentials arguments: $name: sucursal $username: '%env(FINKOK_USERNAME)%' $password: '%env(FINKOK_PASSWORD)%' $taxpayerId: 'MISC491214B86' $environment: !php/enum Estratos\FinkokBundle\Config\Environment::Production # CSD opcional: solo se usa en el método cancel $certificate: '%kernel.project_dir%/var/csd/sucursal.cer' $privateKey: '%kernel.project_dir%/var/csd/sucursal.key' $privateKeyPassphrase: '%env(CSD_PASSPHRASE)%'
# .env.local FINKOK_USERNAME="tu-usuario@empresa.com" FINKOK_PASSWORD="tu-contraseña" FINKOK_RFC="EKU9003173C9"
Opción 2: derivarlos en tiempo de ejecución
Cuando los perfiles dependen del inquilino, de un registro en base de datos o de un secret manager, implementa la interfaz:
namespace App\Finkok; use Estratos\FinkokBundle\Config\Credentials; use Estratos\FinkokBundle\Config\CredentialsInterface; use Estratos\FinkokBundle\Config\CredentialsProviderInterface; use Estratos\FinkokBundle\Config\Environment; final class TenantCredentialsProvider implements CredentialsProviderInterface { public function __construct(private readonly TenantContext $tenant) { } public function get(?string $name = null): CredentialsInterface { $name ??= (string) $this->tenant->activeRfc(); return new Credentials( name: $name, username: (string) $this->tenant->get('finkok_username'), password: (string) $this->tenant->get('finkok_password'), taxpayerId: $name, environment: $this->tenant->isProduction() ? Environment::Production : Environment::Demo, ); } public function has(string $name): bool { return null !== $this->tenant->find($name); } public function default(): CredentialsInterface { return $this->get(); } /** * @return list<string> */ public function names(): array { return $this->tenant->allRfcs(); } }
Si la aplicación no registra ningún proveedor, el contenedor compila igual y el error aparece —con instrucciones— en cuanto se intente timbrar o cancelar.
Uso
Los servicios se inyectan por su interfaz:
use Estratos\FinkokBundle\Contract\CancelServiceInterface; use Estratos\FinkokBundle\Contract\StampServiceInterface; final class FacturacionService { public function __construct( private readonly StampServiceInterface $finkokStamp, private readonly CancelServiceInterface $finkokCancel, ) { } }
Timbrado
use Estratos\FinkokBundle\Xml\CfdiDocument; $cfdi = CfdiDocument::fromFile('/ruta/factura.xml'); // o fromString($xml) $receipt = $this->finkokStamp->stamp($cfdi); if (!$receipt->isSuccess()) { // El mensaje incluye el código de Finkok y, en el log, la pista de solución. throw new \RuntimeException((string) $receipt->getErrorMessage()); } $uuid = $receipt->uuid; // 7D162D12-F6B6-4BDE-BC8A-BABC4331919A $xmlTimbrado = $receipt->getStampedXml(); // CFDI con el nodo tfd:TimbreFiscalDigital
stamp() devuelve siempre un StampReceipt; las incidencias de negocio no
lanzan excepción (la documentación de Finkok advierte que un comprobante puede
timbrarse correctamente y aun así traer incidencias). Si prefieres el
comportamiento estricto:
$receipt = $this->finkokStamp->stamp($cfdi)->assertSuccess(); // lanza ApiException si falla
Antes de enviar nada puedes revisar el envelope exacto, sin abrir conexión:
echo $this->finkokStamp->previewStampRequest($cfdi);
Recuperar un comprobante ya timbrado (incidencia 307)
Si el CFDI ya estaba timbrado, Finkok responde CodEstatus =
«Comprobante timbrado previamente» e intenta recuperar el XML. Puede tardar unos
segundos:
$receipt = $this->finkokStamp->stamp($cfdi); if ($receipt->isPreviouslyStamped() && !$receipt->hasStampedXml()) { sleep(3); $receipt = $this->finkokStamp->stamped($cfdi); // método Stamped } $xmlTimbrado = $receipt->getStampedXml();
Para verificar que el UUID del acuse coincide con el del XML recuperado:
$receipt->uuid === $receipt->uuidFromXml();
Cancelación
use Estratos\FinkokBundle\Model\CancellationReason; use Estratos\FinkokBundle\Model\CancellationUuid; $receipt = $this->finkokCancel->cancel([ new CancellationUuid($uuid, CancellationReason::ErrorsWithoutRelation), ]); // Motivo 01 (con relación) exige el UUID que sustituye al cancelado: // new CancellationUuid($uuid, CancellationReason::ErrorsWithRelation, $uuidSustituto) $receipt->assertSuccess(); foreach ($receipt->folios as $folio) { $folio->uuid; // UUID procesado $folio->isCancelled(); // ¿el SAT aceptó? (códigos 201/202) $folio->isInProcess(); // ¿quedó en proceso? (204/205) $folio->getStatusDescription(); }
Advertencia de Finkok: el código
201confirma que la petición se envió correctamente, no que el CFDI ya esté cancelado. Siempre hay que confirmarlo con el SAT (siguiente sección).
Para que la cancelación no se almacene en el buffer de Finkok (y evitar la incidencia «Already en BufferCancellation»):
$this->finkokCancel->cancel($uuids, storePending: false);
El parámetro cer/key se envía automáticamente solo si el perfil tiene CSD
configurado. Si no, Finkok usa el certificado cargado en el panel para ese RFC.
Confirmar la cancelación ante el SAT
$status = $this->finkokCancel->getSatStatus( uuid: $uuid, taxpayerId: 'EKU9003173C9', // RFC emisor receiverTaxpayerId: 'MISC491214B86', // RFC receptor total: '1160.00', // ¡con los decimales exactos! ); $status->isActive(); // Vigente $status->isCancelled(); // Cancelado $status->isCancellable(); // admite cancelación $status->details?->requiresReceiverAcceptance(); // necesita aceptación del receptor $status->details?->isListedAsEfos(); // listado EFOS 200/201
Si tienes el XML a la mano, no hace falta transcribir nada: getStatusOf() toma
UUID, RFC de emisor, RFC de receptor y total directamente del comprobante, lo que
evita el error «N 601 La expresión impresa proporcionada no es válida» (casi
siempre causado por un total mal formado):
$status = $this->finkokCancel->getStatusOf(CfdiDocument::fromString($xmlTimbrado));
Aceptar o rechazar una cancelación
use Estratos\FinkokBundle\Model\AcceptRejectAnswer; $result = $this->finkokCancel->acceptReject([ 'A1B2C3D4-1111-2222-3333-444455556666' => AcceptRejectAnswer::Accepted, 'B2C3D4E5-1111-2222-3333-444455556666' => AcceptRejectAnswer::Rejected, ], receiverTaxpayerId: 'MISC491214B86'); $result->countAccepted(); $result->countRejected(); $result->accepted[0]->uuid;
Acuses y cancelaciones pendientes
use Estratos\FinkokBundle\Model\ReceiptType; // Acuse de recepción (I) o de cancelación (C) $acuse = $this->finkokCancel->getReceipt($uuid, ReceiptType::Cancellation); $acuse->receipt; // XML del acuse del SAT // UUID con cancelación pendiente de un receptor $pending = $this->finkokCancel->getPending('MISC491214B86'); $pending->uuids; $pending->contains($uuid); // Estado de un timbrado que quedó en la cola de Finkok $queued = $this->finkokStamp->queryPending($uuid); $queued->isStampedNotSent(); // "S": timbrado, aún no enviado al SAT $queued->isFinished(); // "F": ya enviado al SAT $queued->attempts;
Varios emisores en la misma aplicación
use Estratos\FinkokBundle\Config\CredentialsProviderInterface; final class FacturacionService { public function __construct( private readonly StampServiceInterface $finkokStamp, private readonly CredentialsProviderInterface $finkokCredentials, ) { } public function timbrarPara(string $emisor, string $xml): string { $perfil = $this->finkokCredentials->get($emisor); // 'matriz' | 'sucursal' $receipt = $this->finkokStamp->stamp($xml, $perfil); return (string) $receipt->getStampedXml(); } }
El perfil se pasa explícitamente en cada llamada. Esto es deliberado: un «perfil activo» global en un worker de larga vida (Messenger, RoadRunner, Swoole) acaba timbrando con el RFC equivocado.
Métodos disponibles
Timbrado (StampServiceInterface)
| Método del bundle | Operación SOAP | Descripción |
|---|---|---|
stamp() |
stamp |
Timbra el CFDI y lo encola para el SAT. Recupera el XML si ya estaba timbrado (307). |
quickStamp() |
quick_stamp |
Timbrado rápido para volúmenes altos. Si ya estaba timbrado devuelve error. |
stamped() |
stamped |
Recupera un CFDI timbrado previamente. Sin timbre previo devuelve 603. |
signStamp() |
sign_stamp |
Timbra con los CSD cargados en el panel de Finkok (719/720 si faltan). |
queryPending() |
query_pending |
Estado de un comprobante que quedó en la cola de Finkok. |
previewStampRequest() |
— | Devuelve el envelope que enviaría stamp() sin abrir conexión. |
Cancelación (CancelServiceInterface)
| Método del bundle | Operación SOAP | Descripción |
|---|---|---|
cancel() |
cancel |
Cancela uno o varios UUID con su motivo y folio de sustitución. |
acceptReject() |
accept_reject |
Acepta o rechaza solicitudes de cancelación como receptor. |
getSatStatus() |
get_sat_status |
Estado del CFDI ante el SAT: Vigente/Cancelado, cancelable, EFOS. |
getStatusOf() |
get_sat_status |
Igual, tomando los datos del XML para evitar el error N 601. |
getPending() |
get_pending |
UUID con cancelación pendiente de un receptor. |
getReceipt() |
get_receipt |
Acuse de recepción (I) o de cancelación (C). |
queryPendingCancellation() |
query_pending_cancellation |
Estado de una cancelación en el buffer de Finkok. |
Manejo de errores
Los métodos devuelven DTOs que implementan FinkokResultInterface:
$receipt->isSuccess(); // ¿surtió efecto la operación? $receipt->getStatusCode(); // CodEstatus tal cual lo devolvió Finkok $receipt->getErrorCodes(); // ['705'] $receipt->getErrorMessage(); // mensaje legible, o null $receipt->hasErrorCode(ErrorCode::InvalidXmlStructure); $receipt->getIncidences(); // colección tipada de incidencias $receipt->isCredentialError(); // usuario/contraseña o ambiente equivocado $receipt->isTransient(); // conviene reintentar $receipt->requiresManualAction(); // hay que actuar en el panel de Finkok o ante el SAT $receipt->assertSuccess(); // lanza ApiException si no fue exitosa
Catálogo de códigos con descripción y pista de solución:
use Estratos\FinkokBundle\Model\ErrorCode; $code = ErrorCode::tryFrom('307'); $code?->description(); // "El CFDI contiene un timbre previo." $code?->hint(); // qué hacer exactamente para resolverlo $code?->isTransient(); $code?->requiresManualAction(); $code?->isAlreadyStamped();
Excepciones del bundle (todas implementan FinkokExceptionInterface):
| Excepción | Cuándo se lanza |
|---|---|
TransportException |
Fallo de red, timeout o HTTP 4xx/5xx sin SOAP Fault. isRetryable() indica si conviene reintentar. |
SoapFaultException |
SOAP Fault de protocolo: error de esquema, operación inexistente, URL equivocada. |
UnexpectedResponseException |
La respuesta no es un envelope SOAP (proxy, portal cautivo, URL equivocada). |
ApiException |
Incidencia de negocio, lanzada por assertSuccess(). Conserva getIncidences() y getResult(). |
ValidationException |
Validación local: XML vacío o mal formado, sin sello, mayor a 1 MB, motivo 01 sin folio de sustitución. |
ConfigurationException |
Perfil mal configurado (sin usuario/contraseña, certificado sin llave, sin taxpayer_id). |
ProfileNotFoundException |
Se pidió un perfil que no existe; el mensaje lista los disponibles. |
use Estratos\FinkokBundle\Exception\FinkokExceptionInterface; use Estratos\FinkokBundle\Exception\TransportException; use Estratos\FinkokBundle\Exception\ApiException; try { $receipt = $this->finkokStamp->stamp($cfdi)->assertSuccess(); } catch (TransportException $e) { if ($e->isRetryable()) { // reintentar más tarde (Messenger, cron, …) } } catch (ApiException $e) { foreach ($e->getIncidences() as $incidencia) { $this->logger->error($incidencia->describe()); } } catch (FinkokExceptionInterface $e) { // cualquier otro fallo del bundle }
En docs/errores.md están las tablas completas de códigos de
timbrado, cancelación y get_sat_status.
Servicios y extensibilidad
| Servicio | Interfaz para autowiring |
|---|---|
finkok.stamp_service |
Contract\StampServiceInterface |
finkok.cancel_service |
Contract\CancelServiceInterface |
finkok.credentials_provider |
Config\CredentialsProviderInterface (lo aporta tu aplicación) |
finkok.transport |
Soap\SoapTransportInterface |
finkok.csd_encoder |
Csd\CsdEncoderInterface |
Puntos de extensión:
SoapTransportInterface: sustituye el transporte (por ejemplo para añadir un cliente HTTP propio con mTLS o un interceptor de trazas).CsdEncoderInterface: cambia cómo se codifican el.cery el.keypara el métodocancel. El codificador por defecto (RawFileCsdEncoder) hacebase64del contenido del archivo una sola vez; si tu cuenta requiere el proceso antiguo de PEM + cifrado DES3 con la contraseña del panel, implementa esta interfaz y regístrala comofinkok.csd_encoder.CredentialsProviderInterface: es el punto por el que tu aplicación aporta los perfiles. Si no registras ninguno, el contenedor compila pero cualquier uso falla con un mensaje que explica cómo registrarlo.- Cliente HTTP: si la aplicación define el servicio
http_client, el bundle lo reutiliza (comparte pool de conexiones, proxy y CA configurados); si no, crea uno propio.
Notas de integración
Puntos que provocan la mayoría de los incidentes en producción:
- Límite de 1 MB por XML. Superarlo puede afectar el timbrado de comprobantes
posteriores. El validador previo lo detecta:
CfdiDocument::MAX_SIZE_BYTES. - Ventana de 72 horas. El SAT solo acepta timbrar dentro de las 72 horas posteriores a la fecha de emisión y rechaza fechas futuras (incidencia 401).
- Certificados nuevos. La lista LCO del SAT tarda hasta 72 horas en actualizarse (incidencias 305 y 402).
NoCertificadoy CSD. El atributo debe corresponder al certificado usado, y para personas morales el certificado debe ser CSD, no FIEL (306, 712).- Base64 una sola vez. El XML del CFDI se codifica una sola vez. En el método
cancel, el.cery el.keytambién (doble codificación → incidencia 704). totalenget_sat_status. Debe llevar los decimales exactos del comprobante, o el SAT responde «N 601». UsagetStatusOf($cfdi).- Máximo 5 intentos de cancelación por UUID. Al sexto, Finkok responde 799 y ya no se puede cancelar por ese medio.
- DEMO no es para cargas. El ambiente de demostración no soporta pruebas de estrés; su balanceo provoca incidencias transitorias como la 709.
store_pending. Con el buffer activo puede aparecer «Already en BufferCancellation»; pasastorePending: falsesi no lo necesitas.- Retimbrado en el mismo mes. La cancelación con motivo 01 requiere el UUID del comprobante que sustituye al cancelado; los motivos 02, 03 y 04 no lo admiten (el bundle lo valida antes de enviar).
Seguridad
- El método
cancelenvía el CSD a Finkok. El WSDL exigecerykeypara firmar la solicitud. Los parámetros sonminOccurs="0", así que el bundle los omite si el perfil no tiene CSD configurado: en ese caso Finkok usa el certificado cargado en el panel para ese RFC. Es la opción recomendada. - Si necesitas no compartir la llave privada en absoluto, la alternativa es el
servicio
cancel_signature, que recibe la solicitud de cancelación ya firmada por tu aplicación. No está implementado en esta versión (ver Estado y siguientes pasos). log_payloadsestá desactivado por omisión: el envelope contiene datos fiscales y, al cancelar, los CSD en base64. Los logs siempre enmascaran la contraseña y resumen los binarios (SoapRequest::debugArguments()).Credentials::__debugInfo()oculta la contraseña al volcar el objeto, así quedump()y el profiler de Symfony no la exponen.
Pruebas
# Suite offline (sin red): 186 pruebas vendor/bin/phpunit # Prueba de contrato contra los servidores DEMO y PRODUCCIÓN reales de Finkok. # No necesita credenciales: verifica que Finkok entiende el envelope y responde # la incidencia 300 en lugar de un error de deserialización. vendor/bin/phpunit --group live
La suite cubre la construcción de los envelopes (namespaces incluidos), el parseo
de respuestas reales de Finkok, la hidratación de los DTO, la validación previa,
los perfiles multi-emisor, el cableado del contenedor y los flujos de error
(300, 301, 307, 603, 705, 798, 799, Invalid Username or Password, SOAP Fault,
HTTP 502 y respuesta no-SOAP).
Estado y siguientes pasos
Implementado y verificado contra los servidores de Finkok:
- Timbrado:
stamp,quick_stamp,stamped,query_pending,sign_stamp. - Cancelación:
cancel,accept_reject,get_sat_status,get_pending,get_receipt,query_pending_cancellation.
Fuera del alcance de esta entrega, candidatos naturales para siguientes versiones:
cancel_signature,accept_reject_signatureyget_related_signature: las variantes que reciben el XML de cancelación ya firmado, sin compartir el CSD.- Variantes
out_*(out_cancel,out_accept_reject,get_out_*) para cancelar CFDI timbrados por otro PAC. - Métodos asíncronos de timbrado (
stamp_async,get_result_async,get_pdf). - Web Service de utilidades (reportes de timbres, hora del servidor) y de registro
de clientes (
add,edit,assign,switch,get). - Data collector para el profiler de Symfony con el envelope de cada llamada.
Soporte y contribución
- Reportar un error o pedir una mejora: https://github.com/estratos/finkok-bundle/issues
- Código fuente: https://github.com/estratos/finkok-bundle
- Paquete en Packagist: https://packagist.org/packages/estratos/finkok-bundle
- Códigos de error de Finkok: docs/errores.md
Antes de enviar un cambio:
composer install vendor/bin/phpunit # 186 pruebas offline, sin red vendor/bin/phpunit --group live # 4 pruebas de contrato contra Finkok
Los cambios se registran en CHANGELOG.md siguiendo Keep a Changelog, y el proyecto usa Versionado Semántico.
Licencia
MIT. Ver LICENSE.