
CombusTicket: Automatiza tu facturación de gasolina
Automatiza la facturación de combustible en México con Clean Architecture, OCR local, Playwright CDP stealth y colas BullMQ en un VPS propio.
Cargas gasolina, te entregan un ticket térmico arrugado y te advierten la misma frase de siempre: “Facture en nuestro portal antes del cierre de mes”. Llegas a casa con prisa, olvidas el papel en el auto, y para cuando intentas ingresar al sitio web de la estación, el sistema marca error o el mes fiscal ya cerró. Al final del año, la suma acumulada de combustible no deducido representa una fuga de dinero considerable ante el Servicio de Administración Tributaria (SAT).
El dolor de la facturación de combustible en México
En México, deducir la gasolina es un requisito indispensable para profesionistas independientes y empresas bajo el esquema de CFDI 4.0. Sin embargo, el sector está completamente fragmentado: no existen APIs públicas ni estándares unificados. Cada grupo gasolinero opera con portales web propietarios (FacturasGas, ControlGas, Lodemo, GOGAS, Hidrosina), muchos de ellos desarrollados hace más de una década.
Estos portales presentan fricciones constantes:
- Formularios densos que obligan a escribir a mano RFC, razón social, régimen fiscal, código postal, uso de CFDI, número de estación, folio y dígito verificador.
- Caídas continuas de servidor, especialmente los últimos tres días de cada mes.
- Validaciones agresivas y sistemas anti-bot que bloquean automatizaciones básicas.
Cansado de perder dinero en deducciones legítimas por falta de tiempo, decidí construir una solución definitiva: CombusTicket. Un sistema de código abierto que vive en mi propio VPS y convierte todo el proceso en un simple disparo de cámara desde el teléfono.
Cómo funciona CombusTicket en el día a día
El flujo operativo diario toma menos de diez segundos de atención humana:
- Termina la carga de combustible y recibo el ticket impreso.
- Abro la Progressive Web App (PWA) de CombusTicket instalada en la pantalla de inicio del teléfono.
- Tomo una fotografía directa del ticket y pulso Enviar.
- Enciendo el auto y continúo conduciendo.
Entre 30 segundos y 4 minutos después, un proceso en segundo plano dentro del servidor ejecuta el OCR, resuelve el portal correspondiente, ingresa a la plataforma mediante un navegador furtivo, completa los formularios fiscales y timbra la factura ante el SAT. En cuanto el comprobante queda generado, recibo una notificación WebPush en el teléfono con el enlace directo al PDF y XML. Si el portal de la gasolinera se encuentra temporalmente caído, el sistema reintenta la tarea de manera controlada sin perder los datos.
Arquitectura del sistema: Clean Architecture en acción
Para evitar que el proyecto se convirtiera en una colección de scripts frágiles propensos a romperse con cualquier actualización, diseñé la arquitectura siguiendo principios de Clean Architecture (Arquitectura Hexagonal).
[ Cliente PWA / REST API / Servidor MCP ]
│
▼
[ BullMQ + Redis Queue ]
│
▼
[ GasInvoiceService (Core) ]
┌─────────────┴─────────────┐
▼ ▼
[ ReceiptParser (OCR) ] [ BillingPortalRegistry ]
│
▼
[ <<IBillingPortalAdapter>> ]
├── FacturasGasAdapter
├── ControlGasAdapter
├── LodemoGasAdapter
└── GenericGasAdapter
│
▼
[ Obscura (CDP Stealth) ]
El núcleo de negocio desconoce por completo si el navegador es Playwright, si el almacenamiento es un disco local o un bucket S3, o si la petición provino de la interfaz web o de un agente de inteligencia artificial conectado vía Model Context Protocol (MCP).
Paso 1: Extensibilidad con el patrón Strategy
Cada portal gasolinero tiene su propia estructura de URLs, selectores de formulario y flujos de navegación. Para cumplir con el principio de Open/Closed (OCP) de SOLID, definí una interfaz común IBillingPortalAdapter:
import { Page } from 'playwright-core';
import {
ParsedReceiptData,
BillingProfile,
AutomationOptions,
InvoiceResult,
PortalDescriptor
} from '../types.js';
export interface IBillingPortalAdapter {
readonly descriptor: PortalDescriptor;
/**
* Evalúa si este adaptador sabe procesar el ticket actual
* basándose en la URL del portal, marca o número de estación.
*/
canHandle(receipt: ParsedReceiptData): boolean;
/**
* Ejecuta los pasos de automatización específicos del portal.
*/
execute(
page: Page,
receipt: ParsedReceiptData,
profile: BillingProfile,
options?: AutomationOptions
): Promise<InvoiceResult>;
}
Un registro central (BillingPortalRegistry) administra los adaptadores activos e inspecciona el ticket parseado para despachar el trabajo al adaptador correspondiente en tiempo de ejecución:
export class BillingPortalRegistry {
private adapters: Map<string, IBillingPortalAdapter> = new Map();
public register(adapter: IBillingPortalAdapter): void {
this.adapters.set(adapter.descriptor.id, adapter);
}
public resolve(receipt: ParsedReceiptData): IBillingPortalAdapter {
for (const adapter of this.adapters.values()) {
if (adapter.canHandle(receipt)) {
return adapter;
}
}
throw new Error(
`No se encontró adaptador para la estación "${receipt.gasStation}" ni la URL "${receipt.billingUrl}".`
);
}
}
Incorporar una nueva franquicia gasolinera no requiere modificar el orquestador principal: basta con crear una clase que implemente la interfaz y registrarla en el catálogo.
Paso 2: Desacoplamiento asíncrono con BullMQ y Mutex
La interacción con portales de terceros es inherentemente lenta: las páginas cargan scripts pesados, ejecutan validaciones remotas y tardan en timbrar. No podemos mantener una conexión HTTP abierta esperando 3 minutos en una solicitud móvil.
La solución consiste en delegar el procesamiento a una cola de mensajes en Redis gestionada por BullMQ:
// infrastructure/queue/invoiceWorker.ts
import { Worker, Job } from 'bullmq';
import { INVOICE_QUEUE_NAME, InvoiceJobData } from './invoiceQueue.js';
import { GasInvoiceService } from '../../services/gasInvoiceService.js';
import { PushNotificationService } from '../notifications/pushNotificationService.js';
export function startInvoiceWorker(): Worker<InvoiceJobData, InvoiceResult> {
return new Worker<InvoiceJobData, InvoiceResult>(
INVOICE_QUEUE_NAME,
async (job: Job<InvoiceJobData, InvoiceResult>) => {
const { receiptData, billingProfile, dryRun } = job.data;
// Ejecución aislada dentro del pipeline
const result = await GasInvoiceService.processInvoice(
receiptData,
billingProfile,
{ dryRun }
);
// Despacho de notificación WebPush al dispositivo del usuario
await PushNotificationService.sendToRfc(billingProfile.rfc, {
title: result.success ? 'Factura generada con éxito' : 'Atención requerida en factura',
body: result.success
? `Ticket ${receiptData.trackingNumber || ''} timbrado correctamente.`
: `Error: ${result.message}`,
data: { jobId: job.id, status: result.success ? 'completed' : 'failed' }
});
return result;
},
{ concurrency: 1 } // Control estricto de recursos en el VPS
);
}
Al limitar la concurrencia a nivel de worker y utilizar un cerrojo (mutex) FIFO para los puertos del navegador, evitamos sobrecargar la memoria RAM del VPS y eliminamos colisiones entre sesiones concurrentes de Chromium.
Paso 3: OCR local tolerante a arrugas e impresiones térmicas
Los tickets de gasolina sufren de baja calidad de impresión, dobleces en el papel y variaciones de luz al ser fotografiados en el habitáculo del automóvil. Para evitar costos recurrentes de APIs comerciales de visión computacional, CombusTicket utiliza un motor OCR local basado en Tesseract.js optimizado con Sharp:
- Escala de grises y aumento de contraste: eliminan sombras del papel térmico.
- Umbralización adaptativa (thresholding): convierte la imagen a blanco y negro puro para resaltar números de folio tenues.
- Corrección de inclinación (deskew): orienta el bloque de texto horizontalmente antes de la inferencia.
Una vez obtenido el texto plano, un analizador de expresiones regulares heurísticas (ReceiptParser) extrae los identificadores críticos:
// Ejemplo simplificado de extracción de folio y web ID
const folioMatch = text.match(/(?:FOLIO|TICKET|TRANSACCION)[:\s#]*([A-Z0-9-]{4,15})/i);
const webIdMatch = text.match(/(?:WEB\s*ID|CODIGO\s*FACT|ID)[:\s#]*([A-Z0-9]{8,20})/i);
const totalMatch = text.match(/(?:TOTAL|IMPORTE|TOTAL\s*MXN)[:\s$]*([\d,]+\.\d{2})/i);
Si el OCR no alcanza un nivel de confianza suficiente en algún campo numérico, el dashboard le permite al usuario corregir el valor antes de encolar la solicitud definitiva.
Paso 4: Navegación furtiva con Obscura y CDP
Muchos portales gasolineros implementan WAFs que detectan navegadores headless convencionales de Playwright o Puppeteer mediante atributos típicos como navigator.webdriver = true o firmas de canvas y WebGL inconsistentes.
Para solucionar este bloqueo, desarrollé Obscura: un daemon de Chromium instrumentado directamente mediante Chrome DevTools Protocol (CDP). Obscura inicializa la sesión eliminando flags de automatización, emulando características de pantalla reales y preservando la capacidad de capturar video forense y capturas de pantalla completas del proceso de facturación.
# Salida del log del servicio al procesar un ticket
[Obscura] Connecting via low-level CDP to session on port 9222...
[FacturasGasAdapter] Navigating to billing portal: https://facturasgas.com/
[FacturasGasAdapter] Filling station code: 12489
[FacturasGasAdapter] Entering Folio: 849201, WebID: A9X7K2
[FacturasGasAdapter] Injecting SAT Profile: RFC: XAXX010101000, G03, CP: 77500
[FacturasGasAdapter] Submitting form. Waiting for CFDI 4.0 stamp...
[FacturasGasAdapter] Invoice stamped successfully. UUID: 4E7A91B2-8C12-4DF3-B87A-12E098F43210
[StorageService] PDF and XML saved to persistent storage.
[WebPush] Notification dispatched to mobile client.
Control financiero y auditoría en la nube
Más allá de automatizar el timbrado, CombusTicket almacena de forma estructurada cada transacción en Redis y en el sistema de almacenamiento configurado (disco local, AWS S3 o MinIO).
El dashboard web ofrece:
- Historial completo de consumos: fecha, estación, litros despachados y método de pago (tarjeta de crédito, débito o vales).
- Métricas acumuladas: gasto mensual desglosado para comparar el costo por kilómetro de cada vehículo.
- Auditoría forense: acceso al ticket original digitalizado, captura de pantalla del resultado y video de la navegación web por si la estación llega a rechazar una aclaración.
Errores comunes y cómo evitarlos
Al automatizar portales web ajenos surgen retos específicos que conviene tener en cuenta:
- Cambios imprevistos en selectores CSS: Los portales actualizan su diseño sin previo aviso. Diseña tus adaptadores con selectores por rol accesible (
getByRole,getByPlaceholder) en lugar de rutas XPath rígidas dependientes del árbol DOM. - Portales caídos por saturación fiscal: El día 30 o 31 de cada mes los portales suelen colapsar. Configura políticas de reintento exponencial en BullMQ para procesar los tickets acumulados durante la madrugada siguiente sin intervención manual.
- Persistencia de sesiones huérfanas: Si un portal entra en un bucle infinito de carga, el proceso de Chromium puede quedar colgado consumiendo CPU. Implementa timeouts duros tanto en la navegación de Playwright como a nivel de trabajo en la cola.
Conclusión
CombusTicket demuestra cómo los principios de ingeniería de software maduros —Clean Architecture, patrones de diseño clásicos como Strategy y desacoplamiento asíncrono con colas— permiten resolver problemas de la vida cotidiana de forma robusta, económica y escalable.
Puntos clave para recordar:
- Aislar el dominio: Frameworks, herramientas de automatización y motores de OCR son detalles de implementación sustituibles.
- Desacoplar la experiencia de usuario: Las tareas lentas pertenecen a colas asíncronas con notificaciones push, nunca a hilos bloqueantes.
- Respetar la extensibilidad: Diseñar contratos claros (
interfaces) permite que la comunidad agregue nuevas estaciones en cuestión de minutos.
El código fuente es completamente abierto y está listo para desplegarse mediante Docker Compose en cualquier servidor:
- Repositorio en GitHub: https://github.com/rrortega/combusticket
🚀 Demo en vivo (Instancia de pruebas activa):
Puedes explorar la interfaz web y el panel interactivo directamente en:
👉 https://chambapro-combusticket-web.2zyghg.easypanel.host/
⚠️ Nota: Esta URL corresponde a mi instancia personal de pruebas y uso continuo en Easypanel. Está disponible para probar la interfaz y el flujo, pero ten en cuenta que la URL es temporal y no garantizo que esté activa indefinidamente en esa dirección. Si deseas usarlo de forma permanente para tus propios vehículos, te recomiendo desplegar tu propia instancia siguiendo las instrucciones del repositorio.
Artículos relacionados sobre arquitectura e ingeniería

¿Estás evaluando cómo automatizar procesos críticos, construir infraestructura con IA o resolver un reto técnico de arquitectura en tu empresa?
Conectemos directamente. Suelo responder consultas de consultoría y compartir aprendizajes técnicos semanalmente en mi red.