This commit is contained in:
Lizandro Guarnizo
2026-05-01 22:55:51 -05:00
parent ddbabea23f
commit 560359173b
8 changed files with 755 additions and 80 deletions
+1
View File
@@ -58,6 +58,7 @@ func Migrate() {
// Pasarelas de pago
&models.BoldConfig{},
&models.BoldWebhookLog{},
&models.BoldCallbackLog{},
&models.DlocalPaymentLog{},
); err != nil {
log.Fatalf("Error during main migration: %v", err)
+71
View File
@@ -104,3 +104,74 @@ func GetBoldWebhookLogs(limit int) ([]BoldWebhookLog, error) {
}
return logs, nil
}
// GetBoldWebhookLogsPaginated devuelve los logs con paginación y filtro por tipo.
func GetBoldWebhookLogsPaginated(page, limit int, tipo string) ([]BoldWebhookLog, int64, error) {
var logs []BoldWebhookLog
var total int64
db := app.Http.Database.DB.Model(&BoldWebhookLog{})
if tipo != "" && tipo != "TODOS" {
db = db.Where("tipo = ?", tipo)
}
db.Count(&total)
offset := (page - 1) * limit
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
// ─── Callback log (intentos de pago) ─────────────────────────────────────────
// BoldCallbackLog registra cada visita a la URL de retorno de Bold.
// Esto captura usuarios que iniciaron el proceso de pago (llegaron al checkout)
// pero pueden haber abandonado, fallado o completado el pago.
type BoldCallbackLog struct {
gorm.Model
// Referencia principal recibida de Bold (bold-order-id / payment_link / reference)
Referencia string `json:"referencia" gorm:"column:referencia;type:varchar(120);index"`
PaymentLink string `json:"payment_link" gorm:"column:payment_link;type:varchar(80)"`
// Todos los parámetros GET recibidos en JSON (para depuración)
Params string `json:"params" gorm:"column:params;type:text"`
// Estado: pendiente | pagado | fallido | revertido
Estado string `json:"estado" gorm:"column:estado;type:varchar(20);default:'pendiente'"`
// Datos del cliente si están disponibles
PayerEmail string `json:"payer_email" gorm:"column:payer_email;type:varchar(255)"`
// Datos de red (para análisis)
IP string `json:"ip" gorm:"column:ip;type:varchar(45)"`
UserAgent string `json:"user_agent" gorm:"column:user_agent;type:text"`
}
func (BoldCallbackLog) TableName() string { return "bold_callback_log" }
// SaveBoldCallbackLog guarda un registro de intento de pago.
func SaveBoldCallbackLog(entry BoldCallbackLog) error {
return app.Http.Database.DB.Create(&entry).Error
}
// UpdateBoldCallbackEstado actualiza el estado de los intentos que coincidan con la referencia.
// Se llama desde el webhook cuando llega un evento SALE_APPROVED, SALE_REJECTED, etc.
func UpdateBoldCallbackEstado(referencia, estado string) {
if referencia == "" {
return
}
app.Http.Database.DB.Model(&BoldCallbackLog{}).
Where("referencia = ? AND estado = 'pendiente'", referencia).
Update("estado", estado)
}
// GetBoldCallbackLogsPaginated devuelve los intentos de pago con paginación y filtro.
func GetBoldCallbackLogsPaginated(page, limit int, estado string) ([]BoldCallbackLog, int64, error) {
var logs []BoldCallbackLog
var total int64
db := app.Http.Database.DB.Model(&BoldCallbackLog{})
if estado != "" && estado != "TODOS" {
db = db.Where("estado = ?", estado)
}
db.Count(&total)
offset := (page - 1) * limit
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
+365 -54
View File
@@ -68,6 +68,33 @@
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div x-show="activeTab === 'bold'" x-transition>
<!-- Sub-tabs Bold -->
<div class="border-b border-gray-100 mb-5">
<nav class="-mb-px flex gap-5 text-xs">
<button @click="boldTab = 'config'"
:class="boldTab==='config' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-400 hover:text-gray-600'"
class="pb-2 transition-colors">Configuración</button>
<button @click="boldTab = 'notificaciones'; loadBoldLogs()"
:class="boldTab==='notificaciones' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-400 hover:text-gray-600'"
class="pb-2 transition-colors flex items-center gap-1">
Notificaciones
<span x-show="boldLogsTotal > 0"
class="bg-[#8eb02f] text-white text-[10px] px-1.5 py-0.5 rounded-full"
x-text="boldLogsTotal"></span>
</button>
<button @click="boldTab = 'intentos'; loadBoldCallbacks()"
:class="boldTab==='intentos' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-400 hover:text-gray-600'"
class="pb-2 transition-colors flex items-center gap-1">
Intentos de pago
<span x-show="boldCallbacksTotal > 0"
class="bg-orange-400 text-white text-[10px] px-1.5 py-0.5 rounded-full"
x-text="boldCallbacksTotal"></span>
</button>
</nav>
</div>
<!-- ── Sub-tab: Configuración ─────────────────────────────────── -->
<div x-show="boldTab === 'config'">
<div class="grid lg:grid-cols-2 gap-6">
<!-- Formulario de credenciales Bold -->
@@ -199,7 +226,7 @@
</h3>
<p class="text-xs text-gray-500 mb-3">Registra esta URL en el panel Bold → Configuración → Webhooks</p>
<div class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2">
<span class="text-xs font-mono text-gray-700 flex-1 truncate" id="webhookUrl">/webhooks/bold</span>
<span class="text-xs font-mono text-gray-700 flex-1 truncate">/webhooks/bold</span>
<button @click="copyWebhook()" title="Copiar URL"
class="text-[#8eb02f] hover:text-[#6d8c24] flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -207,9 +234,7 @@
</svg>
</button>
</div>
<p class="text-xs text-gray-400 mt-2">
La firma se verifica con HMAC-SHA256. En modo test la secret key puede ser vacía.
</p>
<p class="text-xs text-gray-400 mt-2">La firma se verifica con HMAC-SHA256. En modo test la secret key puede ser vacía.</p>
</div>
<!-- Eventos soportados -->
@@ -234,48 +259,269 @@
</div>
</div>
</div>
</div>
</div>
</div><!-- /sub-tab config -->
<!-- Log de webhooks recientes (tabla) -->
<div class="border border-gray-200 rounded-xl p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700">Últimas notificaciones</h3>
<button @click="loadBoldLogs()" class="text-xs text-[#8eb02f] hover:underline">Actualizar</button>
</div>
<div x-show="boldLogs.length === 0" class="text-xs text-gray-400 text-center py-4">
Sin notificaciones recibidas aún.
</div>
<div x-show="boldLogs.length > 0" class="overflow-x-auto">
<table class="w-full text-xs table-auto">
<thead>
<tr class="text-left border-b font-semibold text-gray-500">
<th class="pb-2 pr-3">Tipo</th>
<th class="pb-2 pr-3">Payment ID</th>
<th class="pb-2 pr-3">Monto</th>
<th class="pb-2">Estado</th>
<!-- ── Sub-tab: Notificaciones ────────────────────────────────── -->
<div x-show="boldTab === 'notificaciones'">
<!-- Filtros + estadísticas rápidas -->
<div class="flex flex-wrap items-center gap-3 mb-4">
<!-- Contadores por tipo -->
<template x-for="stat in boldLogsStats" :key="stat.tipo">
<button @click="boldLogsFilter = stat.tipo; boldLogsPage = 1; loadBoldLogs()"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-medium transition-all"
:class="boldLogsFilter === stat.tipo
? 'border-[#8eb02f] bg-[#f2f9e6] text-[#6d8c24]'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'">
<span :class="stat.color" class="w-2 h-2 rounded-full inline-block"></span>
<span x-text="stat.label"></span>
<span class="font-bold" x-text="stat.count"></span>
</button>
</template>
<button @click="loadBoldLogs()" class="ml-auto text-xs text-[#8eb02f] hover:underline flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Refrescar
</button>
</div>
<!-- Tabla notificaciones -->
<div class="border border-gray-200 rounded-xl overflow-hidden">
<div x-show="boldLogs.length === 0" class="py-12 text-center text-gray-400 text-sm">
Sin notificaciones recibidas aún.
</div>
<div x-show="boldLogs.length > 0">
<table class="w-full text-xs table-auto">
<thead>
<tr class="bg-gray-50 text-left border-b">
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Evento</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Payment ID</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Referencia</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Email pagador</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Monto</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Estado</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Fecha</th>
<th class="py-3 px-4"></th>
</tr>
</thead>
<tbody>
<template x-for="log in boldLogs" :key="log.ID">
<tr class="border-b last:border-0 hover:bg-gray-50 transition-colors">
<td class="py-3 px-4">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase"
:class="{
'bg-green-50 text-green-700': log.tipo === 'SALE_APPROVED',
'bg-red-50 text-red-600': log.tipo === 'SALE_REJECTED',
'bg-orange-50 text-orange-600': log.tipo === 'SALE_REVERSED',
'bg-purple-50 text-purple-600': log.tipo === 'CHARGEBACK',
'bg-blue-50 text-blue-600': !['SALE_APPROVED','SALE_REJECTED','SALE_REVERSED','CHARGEBACK'].includes(log.tipo)
}"
x-text="log.tipo || '—'"></span>
</td>
<td class="py-3 px-4 font-mono text-gray-500" x-text="(log.payment_id||'—').slice(0,16) + ((log.payment_id||'').length > 16 ? '…' : '')"></td>
<td class="py-3 px-4 font-mono text-gray-600" x-text="log.referencia || '—'"></td>
<td class="py-3 px-4 text-gray-600" x-text="log.payer_email || '—'"></td>
<td class="py-3 px-4 font-semibold text-gray-800" x-text="log.monto ? '$' + Number(log.monto).toLocaleString('es-CO') : '—'"></td>
<td class="py-3 px-4">
<span class="px-2 py-0.5 rounded text-[10px] font-semibold"
:class="log.procesado ? 'bg-green-50 text-green-600' : 'bg-yellow-50 text-yellow-600'"
x-text="log.procesado ? 'Procesado' : 'Pendiente'"></span>
</td>
<td class="py-3 px-4 text-gray-400" x-text="log.CreatedAt ? new Date(log.CreatedAt).toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'}) : '—'"></td>
<td class="py-3 px-4">
<button @click="selectedLog = log; showLogModal = true" title="Ver detalle"
class="text-gray-400 hover:text-[#8eb02f] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
</td>
</tr>
</thead>
<tbody>
<template x-for="log in boldLogs" :key="log.ID">
<tr class="border-b last:border-0">
<td class="py-2 pr-3">
<span class="px-2 py-0.5 rounded font-semibold"
:class="log.tipo === 'SALE_APPROVED' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'"
x-text="log.tipo"></span>
</td>
<td class="py-2 pr-3 font-mono text-gray-600" x-text="log.payment_id || '—'"></td>
<td class="py-2 pr-3 text-gray-700" x-text="log.monto ? '$' + log.monto.toLocaleString('es-CO') : '—'"></td>
<td class="py-2">
<span class="px-2 py-0.5 rounded"
:class="log.procesado ? 'bg-green-50 text-green-600' : 'bg-yellow-50 text-yellow-600'"
x-text="log.procesado ? 'Procesado' : 'Pendiente'"></span>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</tbody>
</table>
<!-- Paginación -->
<div class="flex items-center justify-between px-4 py-3 border-t bg-gray-50 text-xs text-gray-500">
<span>Mostrando <strong x-text="boldLogs.length"></strong> de <strong x-text="boldLogsTotal"></strong> registros</span>
<div class="flex gap-2">
<button :disabled="boldLogsPage <= 1" @click="boldLogsPage--; loadBoldLogs()"
class="px-3 py-1 rounded border border-gray-200 disabled:opacity-40 hover:bg-white">← Ant</button>
<span class="px-2 py-1">Pág <strong x-text="boldLogsPage"></strong></span>
<button :disabled="boldLogs.length < boldLogsLimit" @click="boldLogsPage++; loadBoldLogs()"
class="px-3 py-1 rounded border border-gray-200 disabled:opacity-40 hover:bg-white">Sig →</button>
</div>
</div>
</div>
</div>
</div><!-- /sub-tab notificaciones -->
<!-- ── Sub-tab: Intentos de pago ──────────────────────────────── -->
<div x-show="boldTab === 'intentos'">
<div class="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700">
<strong>¿Qué es este registro?</strong> Cada vez que un usuario llega a la página de retorno
de Bold (callback URL) se registra un intento. Si el webhook posterior confirma el pago,
el estado cambia a <em>pagado</em>. Si el usuario abandonó antes de pagar queda en <em>pendiente</em>.
</div>
<!-- Filtros -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<template x-for="s in [{v:'',l:'Todos'},{v:'pendiente',l:'Pendientes'},{v:'pagado',l:'Pagados'},{v:'fallido',l:'Fallidos'},{v:'revertido',l:'Revertidos'}]" :key="s.v">
<button @click="boldCallbacksFilter = s.v; boldCallbacksPage = 1; loadBoldCallbacks()"
class="px-3 py-1.5 rounded-lg border text-xs font-medium transition-all"
:class="boldCallbacksFilter === s.v
? 'border-[#8eb02f] bg-[#f2f9e6] text-[#6d8c24]'
: 'border-gray-200 bg-white text-gray-500 hover:border-gray-300'"
x-text="s.l">
</button>
</template>
<button @click="loadBoldCallbacks()" class="ml-auto text-xs text-[#8eb02f] hover:underline flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Refrescar
</button>
</div>
<!-- Tabla intentos -->
<div class="border border-gray-200 rounded-xl overflow-hidden">
<div x-show="boldCallbacks.length === 0" class="py-12 text-center text-gray-400 text-sm">
Sin intentos registrados aún. Los intentos aparecen cuando un usuario llega al checkout de Bold.
</div>
<div x-show="boldCallbacks.length > 0">
<table class="w-full text-xs table-auto">
<thead>
<tr class="bg-gray-50 text-left border-b">
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Estado</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Referencia</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Payment Link</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Email</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">IP</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Fecha intento</th>
<th class="py-3 px-4"></th>
</tr>
</thead>
<tbody>
<template x-for="cb in boldCallbacks" :key="cb.ID">
<tr class="border-b last:border-0 hover:bg-gray-50 transition-colors">
<td class="py-3 px-4">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase"
:class="{
'bg-yellow-50 text-yellow-700': cb.estado === 'pendiente',
'bg-green-50 text-green-700': cb.estado === 'pagado',
'bg-red-50 text-red-600': cb.estado === 'fallido',
'bg-orange-50 text-orange-600':cb.estado === 'revertido'
}"
x-text="cb.estado || 'pendiente'"></span>
</td>
<td class="py-3 px-4 font-mono text-gray-600" x-text="cb.referencia || '—'"></td>
<td class="py-3 px-4 font-mono text-gray-500" x-text="(cb.payment_link || '—').slice(0,12) + ((cb.payment_link||'').length > 12 ? '…' : '')"></td>
<td class="py-3 px-4 text-gray-600" x-text="cb.payer_email || '—'"></td>
<td class="py-3 px-4 font-mono text-gray-400" x-text="cb.ip || '—'"></td>
<td class="py-3 px-4 text-gray-400" x-text="cb.CreatedAt ? new Date(cb.CreatedAt).toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'}) : '—'"></td>
<td class="py-3 px-4">
<button @click="selectedCallback = cb; showCallbackModal = true" title="Ver parámetros recibidos"
class="text-gray-400 hover:text-[#8eb02f] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Paginación -->
<div class="flex items-center justify-between px-4 py-3 border-t bg-gray-50 text-xs text-gray-500">
<span>Mostrando <strong x-text="boldCallbacks.length"></strong> de <strong x-text="boldCallbacksTotal"></strong> intentos</span>
<div class="flex gap-2">
<button :disabled="boldCallbacksPage <= 1" @click="boldCallbacksPage--; loadBoldCallbacks()"
class="px-3 py-1 rounded border border-gray-200 disabled:opacity-40 hover:bg-white">← Ant</button>
<span class="px-2 py-1">Pág <strong x-text="boldCallbacksPage"></strong></span>
<button :disabled="boldCallbacks.length < boldCallbacksLimit" @click="boldCallbacksPage++; loadBoldCallbacks()"
class="px-3 py-1 rounded border border-gray-200 disabled:opacity-40 hover:bg-white">Sig →</button>
</div>
</div>
</div>
</div>
</div><!-- /sub-tab intentos -->
</div><!-- /TAB BOLD -->
<!-- Modal detalle de notificación webhook -->
<div x-show="showLogModal" x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div @click.outside="showLogModal = false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg p-6 relative">
<button @click="showLogModal = false" class="absolute top-4 right-4 text-gray-400 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold"
:class="{
'bg-green-50 text-green-700': selectedLog?.tipo === 'SALE_APPROVED',
'bg-red-50 text-red-600': selectedLog?.tipo === 'SALE_REJECTED',
'bg-orange-50 text-orange-600': selectedLog?.tipo === 'SALE_REVERSED',
'bg-purple-50 text-purple-600': selectedLog?.tipo === 'CHARGEBACK',
}"
x-text="selectedLog?.tipo || ''"></span>
Detalle de notificación
</h3>
<div class="space-y-2 text-xs mb-4">
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Notification ID</span><span class="font-mono text-gray-700 text-right" x-text="selectedLog?.notification_id || '—'"></span></div>
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Payment ID</span><span class="font-mono text-gray-700" x-text="selectedLog?.payment_id || '—'"></span></div>
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Referencia</span><span class="font-mono text-gray-700" x-text="selectedLog?.referencia || '—'"></span></div>
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Email pagador</span><span class="text-gray-700" x-text="selectedLog?.payer_email || '—'"></span></div>
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Monto</span><span class="font-semibold text-gray-800" x-text="selectedLog?.monto ? '$' + Number(selectedLog.monto).toLocaleString('es-CO') : '—'"></span></div>
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Procesado</span><span :class="selectedLog?.procesado ? 'text-green-600' : 'text-yellow-600'" x-text="selectedLog?.procesado ? 'Sí' : 'No'"></span></div>
<div class="flex justify-between py-1.5"><span class="text-gray-500">Fecha</span><span class="text-gray-600" x-text="selectedLog?.CreatedAt ? new Date(selectedLog.CreatedAt).toLocaleString('es-CO') : '—'"></span></div>
</div>
<!-- Body raw -->
<div x-show="selectedLog?.raw">
<p class="text-[10px] font-semibold text-gray-400 uppercase mb-1">Payload RAW</p>
<pre class="bg-gray-50 border rounded-lg p-3 text-[10px] font-mono text-gray-600 overflow-x-auto max-h-40"
x-text="tryPrettyJson(selectedLog?.raw)"></pre>
</div>
</div>
</div>
<!-- Modal detalle de intento de pago -->
<div x-show="showCallbackModal" x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div @click.outside="showCallbackModal = false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg p-6 relative">
<button @click="showCallbackModal = false" class="absolute top-4 right-4 text-gray-400 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<h3 class="text-sm font-semibold mb-4">Detalle de intento de pago</h3>
<div class="space-y-2 text-xs mb-4">
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Estado</span>
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase"
:class="{
'bg-yellow-50 text-yellow-700': selectedCallback?.estado === 'pendiente',
'bg-green-50 text-green-700': selectedCallback?.estado === 'pagado',
'bg-red-50 text-red-600': selectedCallback?.estado === 'fallido',
'bg-orange-50 text-orange-600':selectedCallback?.estado === 'revertido'
}"
x-text="selectedCallback?.estado || '—'"></span></div>
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Referencia</span><span class="font-mono text-gray-700" x-text="selectedCallback?.referencia || '—'"></span></div>
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Payment Link</span><span class="font-mono text-gray-700" x-text="selectedCallback?.payment_link || '—'"></span></div>
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Email</span><span class="text-gray-700" x-text="selectedCallback?.payer_email || '—'"></span></div>
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">IP</span><span class="font-mono text-gray-600" x-text="selectedCallback?.ip || '—'"></span></div>
<div class="flex justify-between py-1.5"><span class="text-gray-500">Fecha</span><span class="text-gray-600" x-text="selectedCallback?.CreatedAt ? new Date(selectedCallback.CreatedAt).toLocaleString('es-CO') : '—'"></span></div>
</div>
<div x-show="selectedCallback?.params">
<p class="text-[10px] font-semibold text-gray-400 uppercase mb-1">Parámetros recibidos de Bold</p>
<pre class="bg-gray-50 border rounded-lg p-3 text-[10px] font-mono text-gray-600 overflow-x-auto max-h-40"
x-text="tryPrettyJson(selectedCallback?.params)"></pre>
</div>
<div x-show="selectedCallback?.user_agent" class="mt-3">
<p class="text-[10px] font-semibold text-gray-400 uppercase mb-1">User Agent</p>
<p class="text-[10px] font-mono text-gray-500 break-all" x-text="selectedCallback?.user_agent"></p>
</div>
</div>
</div>
@@ -434,7 +680,36 @@ function pasarelasApp() {
callback_url: '',
nota: '',
},
// Sub-tabs Bold
boldTab: 'config',
// Notificaciones webhook
boldLogs: [],
boldLogsTotal: 0,
boldLogsPage: 1,
boldLogsLimit: 25,
boldLogsFilter: '',
boldLogsStats: [
{ tipo: '', label: 'Todos', color: 'bg-gray-400', count: 0 },
{ tipo: 'SALE_APPROVED', label: 'Aprobados', color: 'bg-green-400', count: 0 },
{ tipo: 'SALE_REJECTED', label: 'Rechazados', color: 'bg-red-400', count: 0 },
{ tipo: 'SALE_REVERSED', label: 'Revertidos', color: 'bg-orange-400', count: 0 },
{ tipo: 'CHARGEBACK', label: 'Contracargos', color: 'bg-purple-400', count: 0 },
],
// Intentos de pago (callbacks)
boldCallbacks: [],
boldCallbacksTotal: 0,
boldCallbacksPage: 1,
boldCallbacksLimit: 25,
boldCallbacksFilter: '',
// Modales detalle
showLogModal: false,
selectedLog: null,
showCallbackModal: false,
selectedCallback: null,
// ─── dLocal ─────────────────────────────────────────────────────
dlocalModo: 'dev',
@@ -452,6 +727,7 @@ function pasarelasApp() {
init() {
this.loadBold();
this.loadDlocal();
// Carga perezosa: se carga cuando el usuario abre esos sub-tabs
this.loadBoldLogs();
},
@@ -462,13 +738,13 @@ function pasarelasApp() {
if (r.data.data) {
const d = r.data.data;
this.bold = {
id: d.ID || 0,
api_key_prod: d.api_key_prod || '',
id: d.ID || 0,
api_key_prod: d.api_key_prod || '',
secret_key_prod: d.secret_key_prod || '',
api_key_test: d.api_key_test || '',
api_key_test: d.api_key_test || '',
secret_key_test: d.secret_key_test || '',
callback_url: d.callback_url || '',
nota: d.nota || '',
callback_url: d.callback_url || '',
nota: d.nota || '',
};
this.boldModo = d.modo || 'test';
}
@@ -491,8 +767,38 @@ function pasarelasApp() {
async loadBoldLogs() {
try {
const r = await axios.get('/app/pasarelas/bold/logs');
const params = new URLSearchParams({
page: this.boldLogsPage,
limit: this.boldLogsLimit,
});
if (this.boldLogsFilter) params.set('tipo', this.boldLogsFilter);
const r = await axios.get('/app/pasarelas/bold/logs?' + params.toString());
this.boldLogs = r.data.data || [];
this.boldLogsTotal = r.data.total || 0;
// Actualizar contadores rápidos (solo cuando carga sin filtro)
if (!this.boldLogsFilter) {
const all = r.data.total || 0;
this.boldLogsStats[0].count = all;
// Cargar contadores por tipo en paralelo
['SALE_APPROVED','SALE_REJECTED','SALE_REVERSED','CHARGEBACK'].forEach((tipo, i) => {
axios.get(`/app/pasarelas/bold/logs?page=1&limit=1&tipo=${tipo}`)
.then(res => { this.boldLogsStats[i+1].count = res.data.total || 0; })
.catch(() => {});
});
}
} catch (_) {}
},
async loadBoldCallbacks() {
try {
const params = new URLSearchParams({
page: this.boldCallbacksPage,
limit: this.boldCallbacksLimit,
});
if (this.boldCallbacksFilter) params.set('estado', this.boldCallbacksFilter);
const r = await axios.get('/app/pasarelas/bold/callbacks?' + params.toString());
this.boldCallbacks = r.data.data || [];
this.boldCallbacksTotal = r.data.total || 0;
} catch (_) {}
},
@@ -504,6 +810,11 @@ function pasarelasApp() {
});
},
tryPrettyJson(raw) {
if (!raw) return '';
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { return raw; }
},
// ─── dLocal helpers ──────────────────────────────────────────────
async loadDlocal() {
try {
@@ -511,13 +822,13 @@ function pasarelasApp() {
if (r.data.data) {
const d = r.data.data;
this.dlocal = {
id: d.ID || 0,
access_key_id: d.access_key_id || '',
access_key_secret: d.access_key_secret || '',
access_key_id_dev: d.access_key_id_dev || '',
access_key_secret_dev: d.access_key_secret_dev || '',
url_prod: d.url_prod || '',
url_dev: d.url_dev || '',
id: d.ID || 0,
access_key_id: d.access_key_id || '',
access_key_secret: d.access_key_secret || '',
access_key_id_dev: d.access_key_id_dev || '',
access_key_secret_dev: d.access_key_secret_dev || '',
url_prod: d.url_prod || '',
url_dev: d.url_dev || '',
};
this.dlocalModo = d.modo || 'dev';
}
+217 -19
View File
@@ -44,20 +44,68 @@
</select>
</div>
<!-- Árbol de tablas / colecciones -->
<div class="flex-1 overflow-y-auto p-2" x-show="tables.length > 0">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1" x-text="isMongo ? 'Colecciones' : 'Tablas'"></p>
<template x-for="t in tables" :key="t">
<button @click="insertTable(t)"
class="w-full text-left text-xs px-2 py-1 rounded hover:bg-[#e9f0cf] text-gray-700 truncate flex items-center gap-1">
<svg class="w-3 h-3 shrink-0 text-[#8eb02f]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/>
<line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/>
</svg>
<span x-text="t"></span>
</button>
</template>
<!-- Árbol de tablas / colecciones + visor de campos -->
<div class="flex-1 flex flex-col overflow-hidden" x-show="tables.length > 0">
<div class="flex-1 overflow-y-auto p-2">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1" x-text="isMongo ? 'Colecciones' : 'Tablas'"></p>
<template x-for="t in tables" :key="t">
<div class="flex items-center group mb-0.5">
<button @click="insertTable(t)"
class="flex-1 text-left text-xs px-2 py-1 rounded-l hover:bg-[#e9f0cf] text-gray-700 truncate flex items-center gap-1">
<svg class="w-3 h-3 shrink-0 text-[#8eb02f]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/>
<line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/>
</svg>
<span x-text="t"></span>
</button>
<!-- Botón ver campos (solo MongoDB) -->
<button x-show="isMongo" @click.stop="loadCollectionFields(t)"
class="shrink-0 px-1.5 py-1 rounded-r hover:bg-[#d4e89c] text-gray-400 transition"
:class="selectedCollection === t ? 'bg-[#d4e89c] !text-[#5a7a1e] opacity-100' : 'opacity-0 group-hover:opacity-100'"
title="Ver campos de la colección">
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h7"/>
</svg>
</button>
</div>
</template>
</div>
<!-- Visor de campos de colección (MongoDB) -->
<div x-show="isMongo && selectedCollection" class="border-t shrink-0 bg-white flex flex-col" style="max-height:220px">
<div class="flex items-center justify-between px-2 py-1.5 bg-[#e9f0cf] border-b shrink-0">
<div class="flex items-center gap-1.5 min-w-0">
<svg class="w-3 h-3 shrink-0 text-[#5a7a1e]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span class="text-[10px] font-semibold text-[#4a6e18] font-mono truncate" x-text="selectedCollection"></span>
</div>
<div class="flex items-center gap-1 shrink-0 ml-1">
<span x-show="loadingFields" class="text-[10px] text-gray-500"></span>
<span x-show="!loadingFields && collectionFields.length" class="text-[10px] text-gray-500" x-text="collectionFields.length + ' campos'"></span>
<button @click="selectedCollection=''; collectionFields=[]" class="text-gray-400 hover:text-gray-700 ml-1 text-xs leading-none" title="Cerrar"></button>
</div>
</div>
<div class="overflow-y-auto flex-1">
<div x-show="loadingFields && !collectionFields.length" class="px-3 py-2 text-xs text-gray-400">Cargando campos…</div>
<div x-show="!loadingFields && !collectionFields.length && selectedCollection" class="px-3 py-2 text-xs text-gray-400">Sin documentos en esta colección.</div>
<template x-for="f in collectionFields" :key="f.name">
<div class="flex items-center gap-1 px-2 py-1.5 hover:bg-[#f0f7d8] group border-b border-gray-50">
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-gray-800 truncate" x-text="f.name"></div>
<div class="font-mono text-[9px] text-gray-400 truncate" x-text="f.preview"></div>
</div>
<div class="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition">
<span class="text-[9px] px-1 py-0.5 rounded bg-gray-100 text-gray-500" x-text="f.type"></span>
<button @click="buildUpdateField(f.name)"
class="text-[10px] px-1.5 py-0.5 rounded text-white"
style="background-color:#8eb02f" title="Generar updateOne para este campo"></button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Mensaje sin conexión -->
@@ -127,10 +175,90 @@
style="height:180px; tab-size:2;"></textarea>
</div>
<!-- Ayuda rápida MongoDB -->
<div x-show="isMongo" class="mt-1.5 flex flex-wrap gap-1">
<template x-for="ex in mongoExamples" :key="ex.label">
<button @click="sqlText = ex.cmd" class="text-[10px] px-2 py-0.5 rounded bg-gray-100 hover:bg-gray-200 text-gray-600 font-mono transition" x-text="ex.label"></button>
</template>
<div x-show="isMongo" class="mt-1.5">
<div class="flex flex-wrap gap-1 mb-1.5">
<template x-for="ex in mongoExamples" :key="ex.label">
<button @click="sqlText = ex.cmd" class="text-[10px] px-2 py-0.5 rounded bg-gray-100 hover:bg-gray-200 text-gray-600 font-mono transition" x-text="ex.label"></button>
</template>
</div>
<!-- ══ Generador de actualización rápida ══ -->
<div class="border rounded-lg overflow-hidden">
<button @click="showUpdateBuilder = !showUpdateBuilder"
class="w-full flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 hover:bg-[#e9f0cf] text-xs text-gray-600 transition text-left select-none">
<svg class="w-3 h-3 transition-transform duration-150" :class="showUpdateBuilder ? 'rotate-90' : ''"
fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
<span class="font-semibold">Generador de actualización</span>
<span class="ml-auto text-[10px] text-gray-400">updateOne / updateMany</span>
</button>
<div x-show="showUpdateBuilder" x-transition class="p-3 bg-white border-t">
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Colección</label>
<input x-model="updateBuilder.collection" list="ub-collections"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
:placeholder="selectedCollection || 'coleccion'">
<datalist id="ub-collections">
<template x-for="t in tables" :key="t"><option :value="t"></option></template>
</datalist>
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Operación</label>
<select x-model="updateBuilder.op" class="w-full border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="$set">$set — asignar valor</option>
<option value="$unset">$unset — eliminar campo</option>
<option value="$inc">$inc — incrementar número</option>
<option value="$push">$push — añadir a array</option>
<option value="$pull">$pull — quitar de array</option>
<option value="$rename">$rename — renombrar campo</option>
</select>
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Campo a actualizar</label>
<input x-model="updateBuilder.field" list="ub-fields"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" placeholder="nombre_campo">
<datalist id="ub-fields">
<template x-for="f in collectionFields" :key="f.name"><option :value="f.name"></option></template>
</datalist>
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">
<span x-text="updateBuilder.op === '$rename' ? 'Nuevo nombre' : 'Nuevo valor'"></span>
</label>
<input x-model="updateBuilder.value"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"
:placeholder="updateBuilder.op === '$inc' ? '1' : updateBuilder.op === '$rename' ? 'nuevo_nombre' : '&quot;valor&quot;'"
x-show="updateBuilder.op !== '$unset'">
<span x-show="updateBuilder.op === '$unset'" class="text-[10px] text-gray-400 block py-1.5 italic">El campo será eliminado del documento.</span>
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Filtro — campo</label>
<input x-model="updateBuilder.filterField" list="ub-fields"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" placeholder="_id">
</div>
<div>
<label class="text-[10px] text-gray-500 font-semibold uppercase block mb-0.5">Filtro — valor</label>
<input x-model="updateBuilder.filterValue"
class="w-full border rounded px-2 py-1 font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" placeholder='"id_del_doc"'>
</div>
</div>
<div class="flex items-center gap-2 mt-2.5 pt-2.5 border-t">
<select x-model="updateBuilder.multi" class="border rounded px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="one">updateOne</option>
<option value="many">updateMany</option>
</select>
<button @click="generateUpdate()"
class="flex-1 py-1.5 text-xs text-white font-semibold rounded transition"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
↗ Generar en editor
</button>
</div>
</div>
</div>
</div>
</div>
@@ -233,12 +361,22 @@ document.addEventListener('alpine:init', () => {
testOk: true,
toast: { show: false, msg: '', type: 'ok' },
isMongo: false,
selectedCollection: '',
collectionFields: [],
loadingFields: false,
showUpdateBuilder: false,
updateBuilder: { collection: '', field: '', value: '', filterField: '_id', filterValue: '""', op: '$set', multi: 'one' },
mongoExamples: [
{ label: 'find()', cmd: 'db.coleccion.find({})' },
{ label: 'findOne()', cmd: 'db.coleccion.findOne({})' },
{ label: 'count()', cmd: 'db.coleccion.countDocuments({})' },
{ label: 'insertOne()', cmd: 'db.coleccion.insertOne({"campo": "valor"})' },
{ label: 'updateOne()', cmd: 'db.coleccion.updateOne({"_id": ""}, {"$set": {"campo": "valor"}})' },
{ label: 'updateOne()', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$set": {"campo": "valor"}}\n)' },
{ label: 'updateMany()', cmd: 'db.coleccion.updateMany(\n {},\n {"$set": {"campo": "valor"}}\n)' },
{ label: '$unset', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$unset": {"campo": ""}}\n)' },
{ label: '$inc', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$inc": {"numero": 1}}\n)' },
{ label: '$push', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$push": {"array": "nuevo_elemento"}}\n)' },
{ label: '$pull', cmd: 'db.coleccion.updateOne(\n {"_id": ""},\n {"$pull": {"array": "elemento"}}\n)' },
{ label: 'deleteOne()', cmd: 'db.coleccion.deleteOne({"_id": ""})' },
{ label: 'aggregate()', cmd: 'db.coleccion.aggregate([{"$group": {"_id": "$campo", "total": {"$sum": 1}}}])' },
],
@@ -266,6 +404,8 @@ document.addEventListener('alpine:init', () => {
this.statusMsg = '';
this.history = [];
this.isMongo = false;
this.selectedCollection = '';
this.collectionFields = [];
if (!this.selectedConxId) return;
const conx = this.conexiones.find(c => c.ID == this.selectedConxId);
this.isMongo = conx?.tipo_db?.nombre?.toLowerCase().includes('mongo') ?? false;
@@ -280,6 +420,8 @@ document.addEventListener('alpine:init', () => {
async loadTables() {
this.tables = [];
this.selectedCollection = '';
this.collectionFields = [];
if (!this.selectedDb) return;
try {
const res = await axios.get('/app/query-runner/tables?conx_db_id=' + this.selectedConxId + '&db=' + encodeURIComponent(this.selectedDb));
@@ -384,6 +526,62 @@ document.addEventListener('alpine:init', () => {
this.runQuery();
},
async loadCollectionFields(name) {
this.selectedCollection = name;
this.collectionFields = [];
if (!this.selectedConxId || !name) return;
this.loadingFields = true;
try {
const res = await axios.post('/app/query-runner/run', {
conx_db_id: parseInt(this.selectedConxId),
database: this.selectedDb,
sql: `db.${name}.findOne({})`
});
const rows = res.data?.rows;
if (rows && rows.length > 0) {
this.collectionFields = Object.entries(rows[0]).map(([k, v]) => ({
name: k,
type: v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v,
preview: v === null ? 'null' : Array.isArray(v) ? `[${v.length} elem]` : String(v).substring(0, 30),
sample: v
}));
}
} catch (_) {}
this.loadingFields = false;
},
buildUpdateField(fieldName) {
const col = this.selectedCollection || 'coleccion';
const f = this.collectionFields.find(f => f.name === fieldName);
let sampleVal = '"nuevo_valor"';
if (f && f.sample !== null && f.sample !== undefined) {
if (typeof f.sample === 'number') sampleVal = String(f.sample);
else if (typeof f.sample === 'boolean') sampleVal = String(f.sample);
else if (Array.isArray(f.sample)) sampleVal = '[]';
else if (typeof f.sample === 'object') sampleVal = '{}';
else {
const safe = String(f.sample).replace(/\\/g, '\\\\').replace(/"/g, '\\"').substring(0, 50);
sampleVal = `"${safe}"`;
}
}
this.sqlText = `db.${col}.updateOne(\n { "_id": "" },\n { "$set": { "${fieldName}": ${sampleVal} } }\n)`;
document.getElementById('sql-editor')?.focus();
},
generateUpdate() {
const col = this.updateBuilder.collection || this.selectedCollection || 'coleccion';
const field = this.updateBuilder.field || 'campo';
const value = this.updateBuilder.value || '"nuevo_valor"';
const filterField = this.updateBuilder.filterField || '_id';
const filterValue = this.updateBuilder.filterValue || '""';
const op = this.updateBuilder.op || '$set';
const method = this.updateBuilder.multi === 'many' ? 'updateMany' : 'updateOne';
let updateDoc;
if (op === '$unset') updateDoc = `{ "${field}": "" }`;
else updateDoc = `{ "${field}": ${value} }`;
this.sqlText = `db.${col}.${method}(\n { "${filterField}": ${filterValue} },\n { "${op}": ${updateDoc} }\n)`;
},
clearEditor() { this.sqlText = ''; this.results = []; this.columns = []; this.statusMsg = ''; },
nullStr(v) { return v === null || v === undefined ? 'NULL' : String(v); },
+19 -2
View File
@@ -83,9 +83,26 @@ func BoldWebhook(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
// ─── 6. Solo procesar SALE_APPROVED ─────────────────────────────────────
// ─── 6. Actualizar estado en callback_log para TODOS los eventos ─────────
if referencia != "" {
estimado := "pendiente"
switch tipo {
case "SALE_APPROVED":
estimado = "pagado"
case "SALE_REJECTED":
estimado = "fallido"
case "SALE_REVERSED", "CHARGEBACK":
estimado = "revertido"
}
models.UpdateBoldCallbackEstado(referencia, estimado)
if paymentID != "" {
models.UpdateBoldCallbackEstado(paymentID, estimado)
}
}
// Solo continuar lógica de negocio para SALE_APPROVED ─────────────────────
if tipo != "SALE_APPROVED" {
log.Printf("[BOLD] Webhook: tipo '%s' ignorado", tipo)
log.Printf("[BOLD] Webhook: tipo '%s' registrado", tipo)
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
}
+45 -2
View File
@@ -10,9 +10,52 @@ import (
)
// PagoExitosoPage renderiza la página pública de confirmación de pago.
// Bold redirige al cliente aquí tras el pago. La URL puede incluir ?ref=contrato-{id}.
// Bold redirige al cliente aquí tras el pago. Registra el intento en bold_callback_log.
func PagoExitosoPage(c *fiber.Ctx) error {
ref := c.Query("ref", "")
// Recolectar todos los parámetros posibles que Bold puede enviar
params := map[string]string{}
for _, k := range []string{"bold-order-id", "order_id", "payment_link", "reference", "id", "ref"} {
if v := c.Query(k, ""); v != "" {
params[k] = v
}
}
c.Request().URI().QueryArgs().VisitAll(func(k, v []byte) {
params[string(k)] = string(v)
})
// Resolver la referencia principal (orden de prioridad)
ref := ""
for _, k := range []string{"ref", "reference", "bold-order-id", "payment_link", "order_id", "id"} {
if v := params[k]; v != "" {
ref = v
break
}
}
paymentLink := params["payment_link"]
// Serializar todos los params para auditoría
paramsJSON := "{"
for k, v := range params {
paramsJSON += `"` + k + `":"` + v + `",`
}
if len(paramsJSON) > 1 {
paramsJSON = paramsJSON[:len(paramsJSON)-1]
}
paramsJSON += "}"
// Registrar el intento de pago en la tabla de callbacks
if ref != "" || paymentLink != "" {
entry := models.BoldCallbackLog{
Referencia: ref,
PaymentLink: paymentLink,
Params: paramsJSON,
Estado: "pendiente",
IP: c.IP(),
UserAgent: string(c.Request().Header.UserAgent()),
}
_ = models.SaveBoldCallbackLog(entry)
}
return c.Render("pago_exitoso", fiber.Map{"Ref": ref}, "layouts/landing")
}
+36 -3
View File
@@ -129,13 +129,46 @@ func GetDlocalConfigAPI(c *fiber.Ctx) error {
// ─── Logs Bold ────────────────────────────────────────────────────────────────
// BoldWebhookLogs devuelve los últimos 50 registros del log de webhooks de Bold.
// BoldWebhookLogs devuelve los logs de notificaciones con paginación y filtro por tipo.
// Query params: page (default 1), limit (default 25), tipo (SALE_APPROVED|SALE_REJECTED|...|TODOS)
func BoldWebhookLogs(c *fiber.Ctx) error {
logs, err := models.GetBoldWebhookLogs(50)
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 25)
tipo := c.Query("tipo", "")
if page < 1 {
page = 1
}
logs, total, err := models.GetBoldWebhookLogsPaginated(page, limit, tipo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"data": logs})
return c.JSON(fiber.Map{
"data": logs,
"total": total,
"page": page,
"limit": limit,
})
}
// BoldCallbackLogs devuelve los intentos de pago (visitas al callback) con paginación y filtro.
// Query params: page (default 1), limit (default 25), estado (pendiente|pagado|fallido|revertido|TODOS)
func BoldCallbackLogs(c *fiber.Ctx) error {
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 25)
estado := c.Query("estado", "")
if page < 1 {
page = 1
}
logs, total, err := models.GetBoldCallbackLogsPaginated(page, limit, estado)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"data": logs,
"total": total,
"page": page,
"limit": limit,
})
}
// ─── Logs dLocal ──────────────────────────────────────────────────────────────
+1
View File
@@ -135,6 +135,7 @@ func UserRoutes(app fiber.Router) {
protected.Get("/pasarelas/bold/config", controllers.GetBoldConfigAPI)
protected.Post("/pasarelas/bold/save", controllers.SaveBoldConfig)
protected.Get("/pasarelas/bold/logs", controllers.BoldWebhookLogs)
protected.Get("/pasarelas/bold/callbacks", controllers.BoldCallbackLogs)
// dLocal
protected.Get("/pasarelas/dlocal/config", controllers.GetDlocalConfigAPI)
protected.Post("/pasarelas/dlocal/save", controllers.SaveDlocalConfigWeb)