- Hora exacta: tolerancia 0 (timestamp comprobante = timestamp email)
- Remitente bloquea: si el email trae el nombre del remitente y no coincide, rechaza
- Nuevo paso antes de mostrar datos bancarios: bot pide nombre completo del titular
- Nombres aceptados se guardan en solicitudes_recarga.nombre_remitente
- Próxima recarga: muestra botones con nombres anteriores confirmados o ingresa uno nuevo
- Funciona igual en Telegram y web chat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
El timestamp del correo Bancolombia registra la hora exacta de la transacción,
igual que el comprobante. El retraso es en la llegada al inbox, no en el contenido.
±2 min cubre desfases menores sin permitir cruces entre transacciones cercanas.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Con return inmediato, si Usuario A usaba email_A ($20k), Usuario B
(también $20k) encontraba ese email ya_usado y se bloqueaba sin revisar
el email_B que le pertenecía.
Ahora: ya_usado → continue (sigue iterando); solo retorna ya_usado
al final del loop si todos los emails coincidentes estaban agotados
(caso real de comprobante reutilizado).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Usa el mismo modelo recarga que el flujo admin, con status='Confirmado'
y reference='breb-bot/chat-{solicitud_id}' para trazabilidad.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Antes: monto coincide → hash ya_usado → retorna "reutilizado" (falso positivo
si había un email viejo del mismo valor en la ventana IMAP de 120 min).
Ahora: monto + fecha + hora + llave deben coincidir primero; solo entonces
se verifica el hash — garantizando que es el mismo comprobante exacto.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sin solicitud activa el bot mostraba el análisis completo (Gemini + IMAP) antes de rechazar.
Ahora se valida primero: si no hay solicitud pendiente, se rechaza de inmediato sin gastar API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SolicitudRecarga: modelo + migración para atar cada intento de recarga al usuario (previene cruce de pagos entre usuarios)
- PublicChat: mostrarTransferencia crea solicitud, updatedFotoComprobante valida contra ella, auto-aplica sin botón extra
- Botón Reintentar en web chat cuando el correo aún no llega (pago_pendiente en flujoData)
- aplicarRecargaValidada corregido: error de sintaxis por fragmento duplicado eliminado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- sin_coincidencia muestra mensaje explicativo + botón Reintentar
- datosPago guardado en estado para reintentar sin reenviar foto
- pay_retry callback: re-corre validación con datos guardados
- Si confirma: limpia pendiente y muestra botón aplicar recarga
- Si sigue sin llegar: mantiene botón Reintentar disponible
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Default de 5 min causaba que pagos enviados minutos después del correo
de Bancolombia quedaran fuera de la ventana si había otros correos recientes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Keywords: captura "no inicia sesión Netflix", "no abre", "error de acceso" → credenciales.listar
- GeminiAgent prompt: bot no pide pantallazos ni promete revisiones; problemas de acceso → credenciales.listar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Si el cliente tiene Telegram → mensaje por Telegram.
Si el cliente tiene chat web activo → mensaje en la conversación.
Ambos canales pueden coexistir.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cuando el servicio de un cliente vence mañana, además del email existente
se envía mensaje por Telegram si el cliente tiene el bot vinculado.
Busca en chat_contacts canal=telegram con el user_id del cliente.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Nuevo GeminiAgentService: entiende lenguaje natural, responde conversacionalmente
y extrae acción + parámetros (ej: servicio:"Netflix", monto:50000)
- handleFreeText usa GeminiAgentService en lugar de GeminiIntentService
- dispatchAction acepta accion_data: va directo a Netflix sin pasar por el listado
- viewServiceByName: busca servicio por nombre (ILIKE) y abre sus planes
- Keywords simplificadas: solo intents genéricos sin parámetros
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
'vamos a hacer una recarga' no era detectado por Gemini (thinkingBudget=0
+ frases coloquiales). Agrega detectarPorKeywords() con regex para las 6
acciones principales. Gemini queda como fallback para casos ambiguos.
También elimina asesor.solicitar del prompt de GeminiIntentService.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Con el asesor removido, el estado 'agente' en conversación bloqueaba
handleText y handleFreeText silenciosamente. Audio transcripto como
'hacer una recarga' no generaba respuesta por este return prematuro.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mount() ahora solo requiere chat_user_id (no chat_conv_id) para mantener
al usuario logueado. limpiarChat() borra mensajes y convId pero preserva
userId, nombreUsuario, saldoUsuario y paso='chat'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Reemplaza botón suelto de logout por dropdown (Alpine.js) con dos opciones
- limpiarChat(): limpia mensajes visibles y cierra conversación actual
- cerrarSesion(): ya existía, se mantiene igual
- Cierra el dropdown al hacer clic fuera (x-click.outside)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Eliminar botón del menú principal, pago confirmado, pago no confirmado
- callback 'agent' redirige al menú principal en vez de transferToAgent
- asesor.solicitar del intent también redirige al menú
- Limpiar textos 'contacta a un asesor' en mensajes de error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
El check 'agente' devolvía silenciosamente antes de leer el step,
descartando el texto '1000' del usuario. Si el bot está esperando
input específico (await_amount, await_email, etc.) tiene prioridad
sobre el estado agente de la conversación.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Si pagos_confirmados no existe o falla la BD, NO confirmar el pago (fail-closed).
Antes fallaba abierto: ignoraba el error y confirmaba igual, permitiendo
recargar con el mismo comprobante múltiples veces.
Agrega motivo error_sistema en Telegram para casos de error interno.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Si pagos_confirmados no existe en producción (migración pendiente), la excepción
escapaba hasta handlePhoto y mostraba "Error analizando el comprobante".
Safety catch permite que la confirmación proceda aunque la tabla no esté.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
El toggle puede no estar guardado como '1' en BD aunque el panel lo muestre activo.
Verificar correo_imap_host es más confiable: si hay host → intentar IMAP.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Con thinkingBudget=0 el modelo no consume tokens en razonamiento interno,
evitando que el JSON de respuesta quede truncado (bug: '{"banco":"Nu",...').
maxOutputTokens 2048→8192 en Vision, 1024→2048 en Intent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Livewire checkbox con value="1" puede guardar '1','on','true' según el navegador.
PagoValidadorService ahora acepta cualquier valor truthy en vez de comparación estricta.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
preg_replace('/[^0-9]/', '', '1,000.00') = '100000' porque el .00 aporta 00.
Fix: primero quitar la parte decimal (/[.,]\d{1,2}$/) antes de limpiar separadores.
Aplicado en BancolombiaParser::normalizarValor() y como doble defensa en PagoValidadorService.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PagoValidadorService: add detailed Log::info at every step (emails count,
body preview, parser result, which filter fails) so failures are visible in laravel.log
- CorreoImapService::cleanBody: detect and decode base64-encoded BODY[1] before
QP decoding — critical fix when Bancolombia sends HTML part as base64
- BancolombiaParser: make esBancolombia() broader (includes 'valor','$','abono' etc.),
add extraerPrimero() helper, add multiple regex fallbacks for remitente/fecha/hora/valor
to handle different Bancolombia email HTML formats beyond the SMS text pattern
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Only email-confirmed payments get the apply button. Unconfirmed payments
show the failure reason and direct the user to an advisor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When email validation fails (IMAP disabled, no emails, no match), show the
specific reason instead of a generic "No confirmado". Also add a manual
"Aplicar de todas formas" button so the user isn't stuck — they can still
apply the recharge after visually verifying the receipt.
Also show banco/fecha/hora in the Telegram analysis result for context.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove MercadoPago as a direct payment option for services and promotions.
If the user has enough balance: show only "Pagar con saldo".
If not: show current balance, amount missing, and a "Recargar saldo" button
that leads to the recharge flow (where MercadoPago and Bre-B remain available).
Applies to both TelegramBotService (startPurchase, viewPromo) and
PublicChat (iniciarCompra, verPromocion).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. PublicChat::comprarPromoConSaldo — double saldo subtraction: $saldo->update()
already updates the model in memory, so $this->saldoUsuario = $saldo->valor - precio
was subtracting twice. Fixed by computing $nuevoSaldo before the update.
Also wrapped in DB::transaction (was missing unlike pagarConSaldo).
2. TelegramBotService::listServices — showed all services without filtering by the
user's rol_id, so users could see services with no plans for their role.
Now uses whereHas('tarifas', rol_id) like PublicChat and the web.
3. TelegramBotService::payWithSaldo — no DB::transaction; if saldo update failed
after historial was created, user got a free service. Now wrapped atomically.
4. TelegramBotService::buyPromoSaldo — same missing transaction + fecha_final was
hardcoded to 30 days instead of promo->dias. Both fixed.
5. TelegramBotService::showCredentials — N+1 queries loading cuentas lazily per
historial. Added cuentas to the eager-load list.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three bugs fixed:
1. TelegramBotService queried Cuentas by tarifa_id + estado=activo which
never matched. Now uses CuentasHelper::contar/buscar — same date-range
+ historial-count logic as the web (pendiente for full accounts,
activo + slot check for per-screen accounts).
2. efectivePrice in TelegramBotService and PublicChat used the Preferencial
table (legacy) instead of Usuario_tarifa (used by the web admin panel).
CuentasHelper::precio checks Usuario_tarifa first, Preferencial as fallback.
3. payWithSaldo compared saldo against tarifa->valor (base price) instead of
the effective price stored in state. Also registered the purchase with the
wrong amount.
Extract shared logic to CuentasHelper (contar, buscar, precio) to avoid
duplication between TelegramBotService and PublicChat.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Create pagos_confirmados table to track consumed email receipts
- PagoValidadorService now validates: valor + fecha + hora (±10min) + llave Bancolombia
- Each matched email is hashed (sha1 body+date) and marked as used; a second
match returns estado=ya_usado instead of confirmado
- Add partial remitente name matching against the registered user's name
- GeminiVisionService prompt now extracts llave (@xxx) from the receipt image
- TelegramBotService shows distinct message for ya_usado state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gemini-3.5-flash uses thinking tokens that share the same budget as output
tokens. With maxOutputTokens=200 (vision) or 100 (intent), the model would
exhaust the budget on thinking (~288-1043 tokens) and truncate the actual
response to 8-11 tokens, producing garbage output like "040825".
Fix: 2048 for vision (needs full JSON), 1024 for intent, 512 for connection test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gemini-3.x models return thinking blocks with thought:true flag in parts[].
Filter those out so only actual response text is used.
Also extract the first {...} JSON object from the text so stray content
before or after (like '040825') doesn't break parsing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rewrote extraerPago() to try v1 then v1beta for model compatibility
- Returns __error key with actual API message when both fail
- TelegramBotService shows that error in chat instead of generic message
- Also ensures model is read fresh from config (no hardcoded URL in constructor)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gemini-2.5-flash and gemini-2.0-flash-lite are restricted/unavailable.
gemini-3.5-flash is confirmed working on this account.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The API returned: Unknown name "thinkingConfig": Cannot find field.
gemini-2.5-flash works with a plain generateContent request, no special config needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GeminiIntentService/VisionService: auto-select v1 for 2.5+ models, v1beta for older
- probarGemini: tries v1 then v1beta, shows actual API error message, filters model
list to only those supporting generateContent so the user sees usable options
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Asterisks in the masked email (liza*****@domain) broke Telegram Markdown
parsing, causing the message to silently fail. Backticks (inline code)
render correctly regardless of the email content.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- processEmail: wrap DB lookup and ChatContact create/update in try-catch
so any exception sends an error message to the user instead of silently
failing and leaving state stuck at await_email
- sendOtp: inform user if mail sending fails so they know to wait/retry
- TelegramWebhookController: top-level try-catch returns 200 always so
Telegram never retries on server errors (retries caused duplicate state resets)
- ChatContact canal_id cast to string to avoid type mismatch on insert
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
thinkingConfig must be a top-level key in the request, NOT nested inside
generationConfig. This is required for gemini-2.5-flash and newer models.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gemini-2.5-flash and newer require thinkingBudget:0 to disable thinking
mode, otherwise the API rejects the request or returns unexpected format.
Regex matches gemini-2.5-* and gemini-3.x-* automatically.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- gemini_model is now a saved config field (default: gemini-2.0-flash)
- Config panel shows a text input to set the model name
- probarGemini() reads the configured model and on error calls ListModels
to show exactly which model names are available for the account's API key
- GeminiIntentService and GeminiVisionService build the URL from config at runtime
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gemini-1.5-flash was removed from the v1beta API. Updated all three
references (GeminiIntentService, GeminiVisionService, config test).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add language=es to Whisper query params so audio is always transcribed in Spanish regardless of accent or background noise
- Show "Procesando..." after echoing the transcript so user knows the AI is interpreting the intent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
probarGemini() sends a minimal prompt to gemini-1.5-flash and reports
latency + trimmed response text, or the error message from the API.
Mirrors the existing Whisper test button pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lizandro.guarnizo (17 chars) was showing 'lizandro.' (9 chars including the dot)
because ceil(17/2)=9. Now always shows exactly min(4, len) chars: 'liza*************@domain'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- STATE_TTL: 7200s → 315360000s (10 years) so sessions survive indefinitely
- Telegram: "salir"/"/salir" now shows inline confirmation keyboard instead of going straight to menu; "Cerrar sesión" button added to main menu; logout_ask/logout_confirm/confirmLogout() handler clears state and returns to email prompt
- Web chat: "Cerrar sesión" button in main menu; clicking it shows a confirmation message with "Sí, cerrar sesión" / "No, quedarme" buttons; only the confirmed action calls cerrarSesion()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Send response_format=json as URL query param (not form field)
- If JSON parse yields empty text, fall back to raw plain-text body
- Prevents false 'error' logs when Whisper returns plain text
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Migration + AiUsageLog model with tokens, duration, time, cost fields
- GeminiIntentService and GeminiVisionService log every API call
with input/output token counts from usageMetadata and response time
- TelegramBotService logs Whisper calls with audio duration (from Telegram)
and calculates cost at $1/second
- Whisper voice duration now passed from TelegramWebhookController
- Free text in Telegram now tries Gemini intent detection before showing menu
- /chat/ia-logs: Livewire component with summary cards + filterable table
- Whisper connection test button in config panel
- "Logs IA" link added to Chat/Bot nav section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- handleVoice() downloads Telegram audio and transcribes via Whisper API
- transcribeAudio() calls Whisper with Basic Auth, handles json response
- TelegramWebhookController now routes message.voice and message.audio
- Whisper URL, token and toggle configurable from admin config panel
- Transcribed text echoed back to user then processed as normal text intent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Any message matching salir/menu/volver/cancelar/inicio resets flow and shows main menu
- Register /menu /salir /cancelar as official bot commands via setMyCommands
- Commands are registered automatically when saving the webhook from admin panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
apiCall() and downloadTelegramFile() were using file_get_contents() for
outgoing requests to api.telegram.org, which fails on restricted servers.
Now use Http:: (Guzzle) consistently, matching the rest of the project.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
file_get_contents fails on many hosts due to allow_url_fopen or SSL restrictions.
Http:: (Guzzle) is more reliable and shows a clearer error message on failure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add detectarIntencionKeyword() with regex patterns in Colombian Spanish
that runs before Gemini (instant, no API cost)
- Gemini is now only called for ambiguous text
- Improve Gemini prompt with real examples and informal Spanish
- Replace generic "no entendí" with actionable buttons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Filter promos by TarifaPromo.rol_id (same as services)
- Fix visible filter: use string 'true' not boolean
- Get price from Usuario_promo or TarifaPromo instead of Promociones.precio
- Get utilidad from TarifaPromo for correct margin tracking
- Applied same fixes to TelegramBotService promo flows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace tarifa_id+estado='ocupado' query with the same logic used in
ShowServicios: filter by servicio_id, date range, historiales_count
- Full-account plans (pantallas == completa): look for estado='pendiente'
accounts without assigned profiles
- Partial plans (screen sharing): look for estado='activo' accounts
with remaining slots (historiales_count <= pantallas_servicio - pantallas)
- Wrap purchase in DB::transaction to avoid race conditions
- Remove incorrect cuenta->update(['estado' => 'ocupado']) — availability
is now tracked via historial_cuentas count, not a flag
- Use effective price (Preferencial override) for saldo deduction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add pollMensajes() so wire:poll no longer force-scrolls to bottom
- MutationObserver detects new DOM nodes: auto-scrolls only if user
is already at the bottom (within 80px threshold)
- Floating '↓ N mensaje nuevo' badge appears when new bot messages
arrive while user is scrolled up; tap badge to jump to bottom
- scroll-chat event (emitted on button clicks/sends) still forces
scroll since the user just took an action
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Web chat: 'Otro valor' button sets flujo=recarga.monto_custom,
next text message is parsed as the amount (min $1.000)
- Telegram: '✏️ Otro valor' button sets step=await_amount,
next message is parsed and routed to method selection
- Preset amounts in Telegram now show 2 per row for compactness
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New TelegramBotService: full OTP auth, registration, main menu with
inline keyboards, buy plans, buy promos, recharge (MP + Bre-B),
credentials, history, profile, agent transfer, photo receipt analysis
- TelegramWebhookController: now handles text, callback_query and photo
- Role-based pricing: tarifas filtered by user rol_id in both web chat
and Telegram; Preferencial price overrides base tarifa valor
- Services and promos now render as horizontal scroll slider
(cards_slider tipo_ui) instead of stacked individual cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wrapper del chat: flex-1 + min-h-0 (en vez de h-full) para scroll correcto
- Área de mensajes: min-h-0 explícito — crítico para que flex-1 haga scroll en iOS/Android
- Input: flex-shrink:0 inline para reforzar que nunca se comprima
- visualViewport: usa requestAnimationFrame para scroll más suave, elimina listener scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- abrirChat(): mueve cargarMensajes() al final (después de crear mensajes en DB)
- abrirChat(): siempre agrega saludo+menú al autenticarse (no hay duplicación
porque mount() no llama a abrirChat())
- mount(): solo restaura estado desde sesión, sin agregar mensajes nuevos
- public-chat.blade.php: root div recupera flex flex-col (flex-1 en hijos funciona)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- mount(): restaura userId y convId desde sesión Laravel al recargar página
- abrirChat(): guarda chat_user_id y chat_conv_id en sesión
- cerrarSesion(): limpia la sesión correctamente
- Desktop: chat centrado como frame (420px, bordes redondeados, sombra)
- Móvil: pantalla completa sin restricción de ancho
- teclado virtual: ajuste de altura solo en móvil
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix: autocomplete="off" en campo OTP (evita que el correo aparezca ahí)
- Recarga: nuevo paso elegir método (MercadoPago o Transferencia bancaria)
- Transferencia: muestra card con banco/clave/titular/monto y aviso de comprobante
- Validación automática al enviar foto del comprobante (flujo existente)
- Config admin: nuevos campos banco/clave/titular para transferencia
- UI: card de banco con estilo diferenciado (azul), aviso destacado en ámbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Reemplaza campo teléfono por correo electrónico en el inicio del chat
- Si el correo existe: envía OTP y abre el chat
- Si no existe: ofrece crear cuenta automáticamente (pide nombre)
- Si rechaza registro: vuelve al formulario inicial
- Cuenta nueva se crea con contraseña aleatoria
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Chat público en /chat con flujos de compra, recarga y credenciales
- Verificación de identidad por código OTP enviado al correo registrado
- Botones enriquecidos: cards, links, credenciales, validación de comprobante
- Gemini IA para detección de intención en texto libre
- Gemini Vision para extraer datos de foto de comprobante
- Validador de pagos cruzando IA con correos IMAP (Bancolombia)
- MercadoPago como pasarela de pago (reemplaza Wompi en el chat)
- Migraciones: payload en chat_messages, user_id en chat_contacts
- Config admin: API key Gemini, toggles IA y validación de foto
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>