From ba4ec8b6cfc4e37dfa7475daf912f9f3ba969517 Mon Sep 17 00:00:00 2001 From: Lizandro Date: Sat, 18 Jul 2026 03:30:20 +0000 Subject: [PATCH] Prevent double-confirmation of same payment by different users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/Models/PagoConfirmado.php | 34 +++++ app/Services/GeminiVisionService.php | 6 +- app/Services/PagoValidadorService.php | 144 +++++++++++++++--- app/Services/TelegramBotService.php | 20 ++- ..._000001_create_pagos_confirmados_table.php | 29 ++++ 5 files changed, 207 insertions(+), 26 deletions(-) create mode 100644 app/Models/PagoConfirmado.php mode change 100644 => 100755 app/Services/PagoValidadorService.php mode change 100644 => 100755 app/Services/TelegramBotService.php create mode 100644 database/migrations/2026_07_18_000001_create_pagos_confirmados_table.php diff --git a/app/Models/PagoConfirmado.php b/app/Models/PagoConfirmado.php new file mode 100644 index 0000000..4695754 --- /dev/null +++ b/app/Models/PagoConfirmado.php @@ -0,0 +1,34 @@ + 'datetime', + ]; + + public static function yaUsado(string $hash): bool + { + return static::where('email_hash', $hash)->exists(); + } + + public static function marcar(string $hash, array $datos): void + { + static::create(array_merge(['email_hash' => $hash], $datos)); + } +} diff --git a/app/Services/GeminiVisionService.php b/app/Services/GeminiVisionService.php index 02ecf97..cd84048 100755 --- a/app/Services/GeminiVisionService.php +++ b/app/Services/GeminiVisionService.php @@ -115,15 +115,16 @@ class GeminiVisionService Analiza esta imagen de comprobante de pago bancario o transferencia. Extrae los siguientes campos: -- banco: nombre del banco (Bancolombia, Nequi, Davivienda, etc.) +- banco: nombre del banco emisor (Bancolombia, Nequi, Davivienda, Banco de Bogota, etc.) - valor: monto en numeros enteros sin simbolos ni puntos (ej: 50000) - referencia: numero de referencia o transaccion (si existe) - fecha: fecha en formato dd/mm/yyyy - hora: hora en formato HH:mm (si existe) - remitente: nombre de quien envia el dinero (si aparece) +- llave: llave de pago Bancolombia o Nequi del destinatario, empieza con @ (ej: @leon8909). Solo si aparece en el comprobante. Responde UNICAMENTE con un JSON valido, sin markdown, sin explicacion: -{"banco": "...", "valor": 50000, "referencia": "...", "fecha": "...", "hora": "...", "remitente": "..."} +{"banco": "...", "valor": 50000, "referencia": "...", "fecha": "...", "hora": "...", "remitente": "...", "llave": "..."} Si no encuentras un campo, usa null para ese campo. Si la imagen NO es un comprobante de pago, responde exactamente: {"error": "no_es_comprobante"} @@ -156,6 +157,7 @@ PROMPT; 'fecha' => $json['fecha'] ?? null, 'hora' => $json['hora'] ?? null, 'remitente' => $json['remitente'] ?? null, + 'llave' => $json['llave'] ?? null, ]; } } diff --git a/app/Services/PagoValidadorService.php b/app/Services/PagoValidadorService.php old mode 100644 new mode 100755 index 6b7ee40..75cd6eb --- a/app/Services/PagoValidadorService.php +++ b/app/Services/PagoValidadorService.php @@ -3,18 +3,20 @@ namespace App\Services; use App\Models\ChatConfig; +use App\Models\PagoConfirmado; use App\Models\WhatsappSystemConfig; use Illuminate\Support\Facades\Log; class PagoValidadorService { /** - * Cruza los datos extraídos por IA con los correos IMAP recientes. - * - * @param array $datosPago ['banco', 'valor', 'referencia', 'fecha', 'hora', 'remitente'] - * @return array ['estado' => confirmado|no_encontrado|monto_incorrecto, 'correo' => array|null] + * @param array $datosPago ['banco','valor','referencia','fecha','hora','remitente','llave'] + * @param int|null $usuarioId + * @param string|null $nombreUsuario nombre registrado en la plataforma + * @param string $canal + * @return array ['estado' => confirmado|no_encontrado|monto_incorrecto|ya_usado, 'correo' => array|null] */ - public function validar(array $datosPago): array + public function validar(array $datosPago, ?int $usuarioId = null, ?string $nombreUsuario = null, string $canal = 'telegram'): array { if (WhatsappSystemConfig::get('correo_imap_enabled', '0') !== '1') { return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'correo_deshabilitado']; @@ -31,7 +33,11 @@ class PagoValidadorService return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_correos']; } - $valorIA = (int) ($datosPago['valor'] ?? 0); + $valorIA = (int) ($datosPago['valor'] ?? 0); + $tolerancia = (int) ChatConfig::get('validacion_monto_tolerancia', '0'); + $horaIA = $datosPago['hora'] ?? ''; + $fechaIA = $datosPago['fecha'] ?? ''; + $llaveIA = strtolower(preg_replace('/\s+/', '', $datosPago['llave'] ?? '')); foreach ($correos as $correo) { $datosCorreo = BancolombiaParser::parse($correo['body'] ?? ''); @@ -40,31 +46,129 @@ class PagoValidadorService continue; } - $valorCorreo = (int) ($datosCorreo['valor'] ?? 0); - - // Verificar referencia si ambas están disponibles - if (! empty($datosPago['referencia']) && ! empty($datosCorreo['referencia'])) { - if ($datosPago['referencia'] === $datosCorreo['referencia']) { - return ['estado' => 'confirmado', 'correo' => $datosCorreo]; + // ── 1. Valor ──────────────────────────────────────────── + $valorCorreo = (int) preg_replace('/[^0-9]/', '', (string) ($datosCorreo['valor'] ?? '0')); + if ($valorIA <= 0 || $valorCorreo <= 0 || abs($valorIA - $valorCorreo) > $tolerancia) { + if ($valorCorreo > 0 && $valorIA > 0 && abs($valorIA - $valorCorreo) > $tolerancia) { + // Monto distinto — seguir buscando en el siguiente correo + continue; + } + if ($valorIA <= 0 || $valorCorreo <= 0) { + continue; } } - // Verificar valor - if ($valorIA > 0 && $valorCorreo > 0) { - $tolerancia = (int) ChatConfig::get('validacion_monto_tolerancia', '0'); + // ── 2. Hash único del correo (anti-doble-uso) ─────────── + $emailHash = sha1(($correo['body'] ?? '') . ($correo['date'] ?? '')); - if (abs($valorIA - $valorCorreo) <= $tolerancia) { - return ['estado' => 'confirmado', 'correo' => $datosCorreo]; - } else { - return ['estado' => 'monto_incorrecto', 'correo' => $datosCorreo, - 'esperado' => $valorCorreo, 'recibido' => $valorIA]; + if (PagoConfirmado::yaUsado($emailHash)) { + // Este correo ya confirmó a otro usuario + return ['estado' => 'ya_usado', 'correo' => $datosCorreo, + 'motivo' => 'correo_ya_aplicado']; + } + + // ── 3. Fecha (mismo día) ──────────────────────────────── + if ($fechaIA && ($datosCorreo['fecha'] ?? '')) { + if (! $this->mismoDia($fechaIA, $datosCorreo['fecha'])) { + continue; } } + + // ── 4. Hora (±10 min) ─────────────────────────────────── + if ($horaIA && ($datosCorreo['hora'] ?? '')) { + if (! $this->horaProxima($horaIA, $datosCorreo['hora'], 10)) { + continue; + } + } + + // ── 5. Llave Bancolombia (si la foto la tiene) ────────── + if ($llaveIA) { + $llaveCorreo = strtolower(preg_replace('/\s+/', '', $datosCorreo['llave'] ?? '')); + if ($llaveCorreo && $llaveIA !== $llaveCorreo) { + continue; + } + } + + // ── 6. Remitente (coincidencia parcial con nombre de usuario) ── + $coincideRemitente = $this->remitenteCoincide( + $datosCorreo['remitente'] ?? '', + $nombreUsuario ?? '' + ); + + // Todos los filtros pasados → confirmar + PagoConfirmado::marcar($emailHash, [ + 'usuario_id' => $usuarioId, + 'valor' => $valorIA, + 'canal' => $canal, + 'banco' => $datosCorreo['banco'] ?? null, + 'referencia' => $datosPago['referencia'] ?? null, + ]); + + return [ + 'estado' => 'confirmado', + 'correo' => $datosCorreo, + 'coincide_remitente' => $coincideRemitente, + ]; } + // Si algún correo tenía el monto correcto pero otro campo falló, devolver monto_incorrecto + // de lo contrario no_encontrado return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia']; } + // ── Helpers ────────────────────────────────────────────────────────── + + private function mismoDia(string $fechaA, string $fechaB): bool + { + // Normaliza dd/mm/yyyy o dd/mm/yy + $normA = $this->normalizarFecha($fechaA); + $normB = $this->normalizarFecha($fechaB); + return $normA && $normB && $normA === $normB; + } + + private function normalizarFecha(string $fecha): ?string + { + if (preg_match('#(\d{1,2})/(\d{1,2})/(\d{2,4})#', $fecha, $m)) { + $y = strlen($m[3]) === 2 ? '20' . $m[3] : $m[3]; + return sprintf('%02d/%02d/%s', $m[1], $m[2], $y); + } + return null; + } + + private function horaProxima(string $horaA, string $horaB, int $minutos): bool + { + $toMin = fn (string $h): ?int => preg_match('/(\d{1,2}):(\d{2})/', $h, $m) + ? (int) $m[1] * 60 + (int) $m[2] + : null; + + $a = $toMin($horaA); + $b = $toMin($horaB); + + return $a !== null && $b !== null && abs($a - $b) <= $minutos; + } + + private function remitenteCoincide(string $remitenteEmail, string $nombreUsuario): bool + { + if (! $remitenteEmail || ! $nombreUsuario) { + return false; + } + // Comparación parcial insensible a mayúsculas y tildes + $normalizar = fn (string $s): string => strtolower( + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s + ); + + $re = $normalizar($remitenteEmail); + $nu = $normalizar($nombreUsuario); + + // Basta con que una palabra del nombre de usuario aparezca en el remitente del email + foreach (explode(' ', $nu) as $palabra) { + if (strlen($palabra) >= 3 && str_contains($re, $palabra)) { + return true; + } + } + return false; + } + private function fetchCorreos(): array { $minutos = (int) WhatsappSystemConfig::get('correo_imap_minutos', '5'); diff --git a/app/Services/TelegramBotService.php b/app/Services/TelegramBotService.php old mode 100644 new mode 100755 index 9692887..e0ce88c --- a/app/Services/TelegramBotService.php +++ b/app/Services/TelegramBotService.php @@ -197,21 +197,33 @@ class TelegramBotService return; } - $resultado = app(PagoValidadorService::class)->validar($datosPago); - $monto = number_format($datosPago['valor'] ?? 0); - $ref = $datosPago['referencia'] ?? 'N/A'; + $usuarioId = $state['user_id'] ?? null; + $nombreUsuario = $usuarioId ? (User::find($usuarioId)?->name ?? '') : ''; + + $resultado = app(PagoValidadorService::class)->validar( + $datosPago, $usuarioId, $nombreUsuario, 'telegram' + ); + $monto = number_format($datosPago['valor'] ?? 0); + $ref = $datosPago['referencia'] ?? 'N/A'; $texto = "📊 *Resultado del análisis:*\n\n"; $texto .= "Monto: *\$$monto*\n"; $texto .= "Referencia: `{$ref}`\n"; - $texto .= "Estado: *" . ($resultado['estado'] === 'confirmado' ? '✅ Confirmado' : '⚠️ No confirmado') . "*"; if ($resultado['estado'] === 'confirmado') { + $texto .= "Estado: *✅ Confirmado*"; $buttons = [ [['text' => "✅ Aplicar recarga de \$$monto", 'callback_data' => 'pay_apply|' . ($datosPago['valor'] ?? 0) . '|' . $ref]], [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], ]; + } elseif ($resultado['estado'] === 'ya_usado') { + $texto .= "Estado: *⛔ Comprobante ya utilizado*\nEste pago ya fue registrado por otro usuario."; + $buttons = [ + [['text' => '🧑 Hablar con asesor', 'callback_data' => 'agent']], + [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], + ]; } else { + $texto .= "Estado: *⚠️ No confirmado*"; $buttons = [ [['text' => '🧑 Hablar con asesor', 'callback_data' => 'agent']], [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], diff --git a/database/migrations/2026_07_18_000001_create_pagos_confirmados_table.php b/database/migrations/2026_07_18_000001_create_pagos_confirmados_table.php new file mode 100644 index 0000000..52a291e --- /dev/null +++ b/database/migrations/2026_07_18_000001_create_pagos_confirmados_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('email_hash', 64)->unique(); + $table->unsignedBigInteger('usuario_id')->nullable(); + $table->integer('valor'); + $table->string('canal', 20)->default('telegram'); + $table->string('banco', 60)->nullable(); + $table->string('referencia', 80)->nullable(); + $table->timestamp('confirmado_en')->useCurrent(); + + $table->index('usuario_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('pagos_confirmados'); + } +};