Prevent double-confirmation of same payment by different users
- 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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
157691e166
commit
ba4ec8b6cf
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PagoConfirmado extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'email_hash',
|
||||||
|
'usuario_id',
|
||||||
|
'valor',
|
||||||
|
'canal',
|
||||||
|
'banco',
|
||||||
|
'referencia',
|
||||||
|
'confirmado_en',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'confirmado_en' => '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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,15 +115,16 @@ class GeminiVisionService
|
|||||||
Analiza esta imagen de comprobante de pago bancario o transferencia.
|
Analiza esta imagen de comprobante de pago bancario o transferencia.
|
||||||
|
|
||||||
Extrae los siguientes campos:
|
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)
|
- valor: monto en numeros enteros sin simbolos ni puntos (ej: 50000)
|
||||||
- referencia: numero de referencia o transaccion (si existe)
|
- referencia: numero de referencia o transaccion (si existe)
|
||||||
- fecha: fecha en formato dd/mm/yyyy
|
- fecha: fecha en formato dd/mm/yyyy
|
||||||
- hora: hora en formato HH:mm (si existe)
|
- hora: hora en formato HH:mm (si existe)
|
||||||
- remitente: nombre de quien envia el dinero (si aparece)
|
- 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:
|
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 no encuentras un campo, usa null para ese campo.
|
||||||
Si la imagen NO es un comprobante de pago, responde exactamente: {"error": "no_es_comprobante"}
|
Si la imagen NO es un comprobante de pago, responde exactamente: {"error": "no_es_comprobante"}
|
||||||
@@ -156,6 +157,7 @@ PROMPT;
|
|||||||
'fecha' => $json['fecha'] ?? null,
|
'fecha' => $json['fecha'] ?? null,
|
||||||
'hora' => $json['hora'] ?? null,
|
'hora' => $json['hora'] ?? null,
|
||||||
'remitente' => $json['remitente'] ?? null,
|
'remitente' => $json['remitente'] ?? null,
|
||||||
|
'llave' => $json['llave'] ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+124
-20
@@ -3,18 +3,20 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\ChatConfig;
|
use App\Models\ChatConfig;
|
||||||
|
use App\Models\PagoConfirmado;
|
||||||
use App\Models\WhatsappSystemConfig;
|
use App\Models\WhatsappSystemConfig;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class PagoValidadorService
|
class PagoValidadorService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Cruza los datos extraídos por IA con los correos IMAP recientes.
|
* @param array $datosPago ['banco','valor','referencia','fecha','hora','remitente','llave']
|
||||||
*
|
* @param int|null $usuarioId
|
||||||
* @param array $datosPago ['banco', 'valor', 'referencia', 'fecha', 'hora', 'remitente']
|
* @param string|null $nombreUsuario nombre registrado en la plataforma
|
||||||
* @return array ['estado' => confirmado|no_encontrado|monto_incorrecto, 'correo' => array|null]
|
* @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') {
|
if (WhatsappSystemConfig::get('correo_imap_enabled', '0') !== '1') {
|
||||||
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'correo_deshabilitado'];
|
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'correo_deshabilitado'];
|
||||||
@@ -31,7 +33,11 @@ class PagoValidadorService
|
|||||||
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_correos'];
|
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) {
|
foreach ($correos as $correo) {
|
||||||
$datosCorreo = BancolombiaParser::parse($correo['body'] ?? '');
|
$datosCorreo = BancolombiaParser::parse($correo['body'] ?? '');
|
||||||
@@ -40,31 +46,129 @@ class PagoValidadorService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$valorCorreo = (int) ($datosCorreo['valor'] ?? 0);
|
// ── 1. Valor ────────────────────────────────────────────
|
||||||
|
$valorCorreo = (int) preg_replace('/[^0-9]/', '', (string) ($datosCorreo['valor'] ?? '0'));
|
||||||
// Verificar referencia si ambas están disponibles
|
if ($valorIA <= 0 || $valorCorreo <= 0 || abs($valorIA - $valorCorreo) > $tolerancia) {
|
||||||
if (! empty($datosPago['referencia']) && ! empty($datosCorreo['referencia'])) {
|
if ($valorCorreo > 0 && $valorIA > 0 && abs($valorIA - $valorCorreo) > $tolerancia) {
|
||||||
if ($datosPago['referencia'] === $datosCorreo['referencia']) {
|
// Monto distinto — seguir buscando en el siguiente correo
|
||||||
return ['estado' => 'confirmado', 'correo' => $datosCorreo];
|
continue;
|
||||||
|
}
|
||||||
|
if ($valorIA <= 0 || $valorCorreo <= 0) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar valor
|
// ── 2. Hash único del correo (anti-doble-uso) ───────────
|
||||||
if ($valorIA > 0 && $valorCorreo > 0) {
|
$emailHash = sha1(($correo['body'] ?? '') . ($correo['date'] ?? ''));
|
||||||
$tolerancia = (int) ChatConfig::get('validacion_monto_tolerancia', '0');
|
|
||||||
|
|
||||||
if (abs($valorIA - $valorCorreo) <= $tolerancia) {
|
if (PagoConfirmado::yaUsado($emailHash)) {
|
||||||
return ['estado' => 'confirmado', 'correo' => $datosCorreo];
|
// Este correo ya confirmó a otro usuario
|
||||||
} else {
|
return ['estado' => 'ya_usado', 'correo' => $datosCorreo,
|
||||||
return ['estado' => 'monto_incorrecto', 'correo' => $datosCorreo,
|
'motivo' => 'correo_ya_aplicado'];
|
||||||
'esperado' => $valorCorreo, 'recibido' => $valorIA];
|
}
|
||||||
|
|
||||||
|
// ── 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'];
|
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
|
private function fetchCorreos(): array
|
||||||
{
|
{
|
||||||
$minutos = (int) WhatsappSystemConfig::get('correo_imap_minutos', '5');
|
$minutos = (int) WhatsappSystemConfig::get('correo_imap_minutos', '5');
|
||||||
|
|||||||
Regular → Executable
+16
-4
@@ -197,21 +197,33 @@ class TelegramBotService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$resultado = app(PagoValidadorService::class)->validar($datosPago);
|
$usuarioId = $state['user_id'] ?? null;
|
||||||
$monto = number_format($datosPago['valor'] ?? 0);
|
$nombreUsuario = $usuarioId ? (User::find($usuarioId)?->name ?? '') : '';
|
||||||
$ref = $datosPago['referencia'] ?? 'N/A';
|
|
||||||
|
$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 = "📊 *Resultado del análisis:*\n\n";
|
||||||
$texto .= "Monto: *\$$monto*\n";
|
$texto .= "Monto: *\$$monto*\n";
|
||||||
$texto .= "Referencia: `{$ref}`\n";
|
$texto .= "Referencia: `{$ref}`\n";
|
||||||
$texto .= "Estado: *" . ($resultado['estado'] === 'confirmado' ? '✅ Confirmado' : '⚠️ No confirmado') . "*";
|
|
||||||
|
|
||||||
if ($resultado['estado'] === 'confirmado') {
|
if ($resultado['estado'] === 'confirmado') {
|
||||||
|
$texto .= "Estado: *✅ Confirmado*";
|
||||||
$buttons = [
|
$buttons = [
|
||||||
[['text' => "✅ Aplicar recarga de \$$monto", 'callback_data' => 'pay_apply|' . ($datosPago['valor'] ?? 0) . '|' . $ref]],
|
[['text' => "✅ Aplicar recarga de \$$monto", 'callback_data' => 'pay_apply|' . ($datosPago['valor'] ?? 0) . '|' . $ref]],
|
||||||
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
|
[['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 {
|
} else {
|
||||||
|
$texto .= "Estado: *⚠️ No confirmado*";
|
||||||
$buttons = [
|
$buttons = [
|
||||||
[['text' => '🧑 Hablar con asesor', 'callback_data' => 'agent']],
|
[['text' => '🧑 Hablar con asesor', 'callback_data' => 'agent']],
|
||||||
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
|
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('pagos_confirmados', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user