up
This commit is contained in:
@@ -106,11 +106,17 @@ func GetBoldWebhookLogs(limit int) ([]BoldWebhookLog, error) {
|
||||
}
|
||||
|
||||
// GetBoldWebhookLogsPaginated devuelve los logs con paginación y filtro por tipo.
|
||||
// El valor especial "APROBADOS" incluye tanto SALE_APPROVED como API_CHECK.
|
||||
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" {
|
||||
switch tipo {
|
||||
case "", "TODOS":
|
||||
// sin filtro
|
||||
case "APROBADOS":
|
||||
db = db.Where("tipo IN ?", []string{"SALE_APPROVED", "API_CHECK"})
|
||||
default:
|
||||
db = db.Where("tipo = ?", tipo)
|
||||
}
|
||||
db.Count(&total)
|
||||
@@ -121,6 +127,26 @@ func GetBoldWebhookLogsPaginated(page, limit int, tipo string) ([]BoldWebhookLog
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// UpdateBoldWebhookLogDatos actualiza email y monto de un log existente (para rellenar datos faltantes en API_CHECK).
|
||||
func UpdateBoldWebhookLogDatos(id uint, payerEmail string, monto int64) error {
|
||||
updates := map[string]interface{}{}
|
||||
if payerEmail != "" {
|
||||
updates["payer_email"] = payerEmail
|
||||
}
|
||||
if monto > 0 {
|
||||
updates["monto"] = monto
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return app.Http.Database.DB.Model(&BoldWebhookLog{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// GetBoldWebhookLogByID carga un registro por su PK.
|
||||
func GetBoldWebhookLogByID(id uint, out *BoldWebhookLog) error {
|
||||
return app.Http.Database.DB.First(out, id).Error
|
||||
}
|
||||
|
||||
// ─── Callback log (intentos de pago) ─────────────────────────────────────────
|
||||
|
||||
// BoldCallbackLog registra cada visita a la URL de retorno de Bold.
|
||||
|
||||
@@ -150,6 +150,48 @@ func GetDlocalPaymentLogs(limit int) ([]DlocalPaymentLog, error) {
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// GetDlocalPaymentLogsPaginated devuelve los logs con paginación y filtro por estado.
|
||||
// El valor especial "APROBADOS" agrupa PAID + AUTHORIZED.
|
||||
func GetDlocalPaymentLogsPaginated(page, limit int, estado string) ([]DlocalPaymentLog, int64, error) {
|
||||
var logs []DlocalPaymentLog
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&DlocalPaymentLog{})
|
||||
switch estado {
|
||||
case "", "TODOS":
|
||||
// sin filtro
|
||||
case "APROBADOS":
|
||||
db = db.Where("estado IN ?", []string{"PAID", "AUTHORIZED"})
|
||||
default:
|
||||
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
|
||||
}
|
||||
|
||||
// GetDlocalPaymentLogByID carga un registro por su PK.
|
||||
func GetDlocalPaymentLogByID(id uint, out *DlocalPaymentLog) error {
|
||||
return app.Http.Database.DB.First(out, id).Error
|
||||
}
|
||||
|
||||
// UpdateDlocalPaymentLogDatos actualiza email y monto de un log existente.
|
||||
func UpdateDlocalPaymentLogDatos(id uint, payerEmail string, monto float64) error {
|
||||
updates := map[string]interface{}{}
|
||||
if payerEmail != "" {
|
||||
updates["payer_email"] = payerEmail
|
||||
}
|
||||
if monto > 0 {
|
||||
updates["monto"] = monto
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return app.Http.Database.DB.Model(&DlocalPaymentLog{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// GetDlocalPaymentLogsByRef devuelve todos los registros que coinciden con una referencia/order_id.
|
||||
func GetDlocalPaymentLogsByRef(ref string) ([]DlocalPaymentLog, error) {
|
||||
var logs []DlocalPaymentLog
|
||||
|
||||
@@ -330,7 +330,8 @@
|
||||
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">
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<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">
|
||||
@@ -338,6 +339,17 @@
|
||||
<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>
|
||||
<!-- Botón Validar: en API_CHECK con datos incompletos, o en cualquier log si contrato no confirmado -->
|
||||
<button x-show="log.tipo === 'API_CHECK' && (!log.payer_email || !log.monto)"
|
||||
@click="validarLog(log)"
|
||||
:disabled="validandoLogID === log.ID"
|
||||
title="Verificar pago en todas las fuentes (Bold API, dLocal, logs)"
|
||||
class="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded border border-indigo-300 text-indigo-600 hover:bg-indigo-50 disabled:opacity-40 transition-colors">
|
||||
<svg x-show="validandoLogID !== log.ID" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
|
||||
<svg x-show="validandoLogID === log.ID" class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
Validar
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -536,6 +548,14 @@
|
||||
<button @click="dlocalTab = 'config'"
|
||||
:class="dlocalTab==='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="dlocalTab = 'notificaciones'; loadDlocalLogs()"
|
||||
:class="dlocalTab==='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="dlocalLogsTotal > 0"
|
||||
class="bg-[#8eb02f] text-white text-[10px] px-1.5 py-0.5 rounded-full"
|
||||
x-text="dlocalLogsTotal"></span>
|
||||
</button>
|
||||
<button @click="dlocalTab = 'planes'; loadDlocalPlanes()"
|
||||
:class="dlocalTab==='planes' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-400 hover:text-gray-600'"
|
||||
class="pb-2 transition-colors">Planes de suscripción</button>
|
||||
@@ -810,6 +830,152 @@
|
||||
|
||||
</div><!-- /sub-tab planes -->
|
||||
|
||||
<!-- ── Sub-tab: Notificaciones dLocal ───────────────────────────── -->
|
||||
<div x-show="dlocalTab === 'notificaciones'">
|
||||
|
||||
<!-- Filtros rápidos -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<template x-for="stat in dlocalLogsStats" :key="stat.estado">
|
||||
<button @click="dlocalLogsFilter = stat.estado; dlocalLogsPage = 1; loadDlocalLogs()"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-medium transition-all"
|
||||
:class="dlocalLogsFilter === stat.estado
|
||||
? '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="loadDlocalLogs()" 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 -->
|
||||
<div class="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div x-show="dlocalLogs.length === 0" class="py-12 text-center text-gray-400 text-sm">
|
||||
Sin notificaciones de dLocal registradas aún.
|
||||
</div>
|
||||
<div x-show="dlocalLogs.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]">Tipo / Fuente</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 dlocalLogs" :key="log.ID">
|
||||
<tr class="border-b last:border-0 hover:bg-gray-50 transition-colors">
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase w-fit"
|
||||
:class="{
|
||||
'bg-green-50 text-green-700': log.estado === 'PAID' || log.estado === 'AUTHORIZED',
|
||||
'bg-red-50 text-red-600': log.estado === 'REJECTED' || log.estado === 'CANCELLED',
|
||||
'bg-yellow-50 text-yellow-700': log.estado === 'PENDING',
|
||||
'bg-gray-50 text-gray-500': !['PAID','AUTHORIZED','REJECTED','CANCELLED','PENDING'].includes(log.estado)
|
||||
}"
|
||||
x-text="log.estado || '—'"></span>
|
||||
<span class="text-[10px] text-gray-400" x-text="log.tipo || log.fuente || ''"></span>
|
||||
</div>
|
||||
</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 || log.order_id || '—'"></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 ? (log.moneda||'$') + ' ' + 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">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="selectedDlocalLog = log; showDlocalLogModal = 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>
|
||||
<!-- Botón Validar: en logs sin estado PAID o con datos incompletos -->
|
||||
<button x-show="log.estado !== 'PAID' && log.estado !== 'AUTHORIZED' || (!log.payer_email || !log.monto)"
|
||||
@click="validarDlocalLog(log)"
|
||||
:disabled="validandoDlocalLogID === log.ID"
|
||||
title="Verificar pago en todas las fuentes"
|
||||
class="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded border border-indigo-300 text-indigo-600 hover:bg-indigo-50 disabled:opacity-40 transition-colors">
|
||||
<svg x-show="validandoDlocalLogID !== log.ID" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
|
||||
<svg x-show="validandoDlocalLogID === log.ID" class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
Validar
|
||||
</button>
|
||||
</div>
|
||||
</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="dlocalLogs.length"></strong> de <strong x-text="dlocalLogsTotal"></strong> registros</span>
|
||||
<div class="flex gap-2">
|
||||
<button :disabled="dlocalLogsPage <= 1" @click="dlocalLogsPage--; loadDlocalLogs()"
|
||||
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="dlocalLogsPage"></strong></span>
|
||||
<button :disabled="dlocalLogs.length < dlocalLogsLimit" @click="dlocalLogsPage++; loadDlocalLogs()"
|
||||
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 dlocal -->
|
||||
|
||||
<!-- Modal detalle log dLocal -->
|
||||
<div x-show="showDlocalLogModal" x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div @click.outside="showDlocalLogModal = false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg p-6 relative">
|
||||
<button @click="showDlocalLogModal = 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': selectedDlocalLog?.estado === 'PAID' || selectedDlocalLog?.estado === 'AUTHORIZED',
|
||||
'bg-red-50 text-red-600': selectedDlocalLog?.estado === 'REJECTED',
|
||||
'bg-yellow-50 text-yellow-700': selectedDlocalLog?.estado === 'PENDING',
|
||||
}"
|
||||
x-text="selectedDlocalLog?.estado || ''"></span>
|
||||
Detalle de notificación dLocal
|
||||
</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 truncate max-w-[220px]" x-text="selectedDlocalLog?.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="selectedDlocalLog?.payment_id || '—'"></span></div>
|
||||
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Order ID</span><span class="font-mono text-gray-700" x-text="selectedDlocalLog?.order_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="selectedDlocalLog?.referencia || '—'"></span></div>
|
||||
<div class="flex justify-between py-1.5 border-b"><span class="text-gray-500">Tipo</span><span class="text-gray-700" x-text="selectedDlocalLog?.tipo || selectedDlocalLog?.fuente || '—'"></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="selectedDlocalLog?.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="selectedDlocalLog?.monto ? (selectedDlocalLog?.moneda||'') + ' ' + Number(selectedDlocalLog.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="selectedDlocalLog?.procesado ? 'text-green-600' : 'text-yellow-600'" x-text="selectedDlocalLog?.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="selectedDlocalLog?.CreatedAt ? new Date(selectedDlocalLog.CreatedAt).toLocaleString('es-CO') : '—'"></span></div>
|
||||
</div>
|
||||
<div x-show="selectedDlocalLog?.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(selectedDlocalLog?.raw)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div><!-- /container -->
|
||||
@@ -844,8 +1010,8 @@ function pasarelasApp() {
|
||||
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: '', label: 'Todos', color: 'bg-gray-400', count: 0 },
|
||||
{ tipo: 'APROBADOS', 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 },
|
||||
@@ -863,6 +1029,7 @@ function pasarelasApp() {
|
||||
selectedLog: null,
|
||||
showCallbackModal: false,
|
||||
selectedCallback: null,
|
||||
validandoLogID: 0,
|
||||
|
||||
// ─── dLocal ─────────────────────────────────────────────────────
|
||||
dlocalModo: 'dev',
|
||||
@@ -879,6 +1046,22 @@ function pasarelasApp() {
|
||||
// Sub-tabs dLocal
|
||||
dlocalTab: 'config',
|
||||
|
||||
// Notificaciones dLocal
|
||||
dlocalLogs: [],
|
||||
dlocalLogsTotal: 0,
|
||||
dlocalLogsPage: 1,
|
||||
dlocalLogsLimit: 25,
|
||||
dlocalLogsFilter: '',
|
||||
dlocalLogsStats: [
|
||||
{ estado: '', label: 'Todos', color: 'bg-gray-400', count: 0 },
|
||||
{ estado: 'APROBADOS', label: 'Aprobados', color: 'bg-green-400', count: 0 },
|
||||
{ estado: 'PENDING', label: 'Pendientes', color: 'bg-yellow-400', count: 0 },
|
||||
{ estado: 'REJECTED', label: 'Rechazados', color: 'bg-red-400', count: 0 },
|
||||
],
|
||||
showDlocalLogModal: false,
|
||||
selectedDlocalLog: null,
|
||||
validandoDlocalLogID: 0,
|
||||
|
||||
// Planes dLocal
|
||||
dlocalPlanes: [],
|
||||
dlocalPlanesLoading: false,
|
||||
@@ -950,7 +1133,7 @@ function pasarelasApp() {
|
||||
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) => {
|
||||
['APROBADOS','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(() => {});
|
||||
@@ -980,6 +1163,27 @@ function pasarelasApp() {
|
||||
});
|
||||
},
|
||||
|
||||
async validarLog(log) {
|
||||
this.validandoLogID = log.ID;
|
||||
try {
|
||||
const { data } = await axios.post(`/app/pasarelas/bold/logs/${log.ID}/validar`);
|
||||
if (data.ok && data.data) {
|
||||
// Actualizar la fila en memoria
|
||||
const idx = this.boldLogs.findIndex(l => l.ID === log.ID);
|
||||
if (idx !== -1) this.boldLogs[idx] = data.data;
|
||||
}
|
||||
const msg = data.mensaje || (data.confirmado ? 'Pago confirmado ✓' : 'Datos actualizados');
|
||||
const tipo = data.confirmado ? 'success' : 'info';
|
||||
this.showToast(msg, data.confirmado ? 'success' : 'error');
|
||||
// Recargar stats si el pago se confirmó ahora
|
||||
if (data.confirmado) this.loadBoldLogs();
|
||||
} catch (e) {
|
||||
this.showToast(e.response?.data?.error || 'Error al validar', 'error');
|
||||
} finally {
|
||||
this.validandoLogID = 0;
|
||||
}
|
||||
},
|
||||
|
||||
tryPrettyJson(raw) {
|
||||
if (!raw) return '';
|
||||
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { return raw; }
|
||||
@@ -1103,6 +1307,42 @@ function pasarelasApp() {
|
||||
navigator.clipboard.writeText(url).then(() => this.showToast('URL copiada: ' + url));
|
||||
},
|
||||
|
||||
async loadDlocalLogs() {
|
||||
try {
|
||||
const params = new URLSearchParams({ page: this.dlocalLogsPage, limit: this.dlocalLogsLimit });
|
||||
if (this.dlocalLogsFilter) params.set('estado', this.dlocalLogsFilter);
|
||||
const r = await axios.get('/app/pasarelas/dlocal/logs?' + params.toString());
|
||||
this.dlocalLogs = r.data.data || [];
|
||||
this.dlocalLogsTotal = r.data.total || 0;
|
||||
if (!this.dlocalLogsFilter) {
|
||||
this.dlocalLogsStats[0].count = r.data.total || 0;
|
||||
['APROBADOS','PENDING','REJECTED'].forEach((e, i) => {
|
||||
axios.get(`/app/pasarelas/dlocal/logs?page=1&limit=1&estado=${e}`)
|
||||
.then(res => { this.dlocalLogsStats[i+1].count = res.data.total || 0; })
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async validarDlocalLog(log) {
|
||||
this.validandoDlocalLogID = log.ID;
|
||||
try {
|
||||
const { data } = await axios.post(`/app/pasarelas/dlocal/logs/${log.ID}/validar`);
|
||||
if (data.ok && data.data) {
|
||||
const idx = this.dlocalLogs.findIndex(l => l.ID === log.ID);
|
||||
if (idx !== -1) this.dlocalLogs[idx] = data.data;
|
||||
}
|
||||
const msg = data.mensaje || (data.confirmado ? 'Pago confirmado ✓' : 'Datos actualizados');
|
||||
this.showToast(msg, data.confirmado ? 'success' : 'error');
|
||||
if (data.confirmado) this.loadDlocalLogs();
|
||||
} catch (e) {
|
||||
this.showToast(e.response?.data?.error || 'Error al validar', 'error');
|
||||
} finally {
|
||||
this.validandoDlocalLogID = 0;
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Toast ───────────────────────────────────────────────────────
|
||||
showToast(msg, type = 'success') {
|
||||
this.toast = { show: true, msg, type };
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
// PasarelasPage renderiza la vista unificada de pasarelas de pago.
|
||||
@@ -173,7 +176,7 @@ func BoldCallbackLogs(c *fiber.Ctx) error {
|
||||
|
||||
// ─── Logs dLocal ──────────────────────────────────────────────────────────────
|
||||
|
||||
// DlocalPaymentLogs devuelve los últimos 100 registros de pagos de dLocal.
|
||||
// DlocalPaymentLogs devuelve los últimos 100 registros de pagos de dLocal (legacy).
|
||||
func DlocalPaymentLogs(c *fiber.Ctx) error {
|
||||
logs, err := models.GetDlocalPaymentLogs(100)
|
||||
if err != nil {
|
||||
@@ -181,3 +184,261 @@ func DlocalPaymentLogs(c *fiber.Ctx) error {
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": logs})
|
||||
}
|
||||
|
||||
// DlocalPaymentLogsPaginated devuelve los logs con paginación y filtro por estado.
|
||||
// Query params: page (default 1), limit (default 25), estado (PAID|PENDING|REJECTED|APROBADOS|TODOS)
|
||||
func DlocalPaymentLogsPaginated(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.GetDlocalPaymentLogsPaginated(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,
|
||||
})
|
||||
}
|
||||
|
||||
// ValidarDlocalLog verifica el estado de pago real de un log dLocal consultando
|
||||
// todas las fuentes disponibles (dlocal_payment_log → Bold API → dLocal API).
|
||||
// Si se confirma el pago, marca el contrato como pagado y envía confirmación.
|
||||
// POST /app/pasarelas/dlocal/logs/:id/validar
|
||||
func ValidarDlocalLog(c *fiber.Ctx) error {
|
||||
logID, err := c.ParamsInt("id")
|
||||
if err != nil || logID <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
|
||||
}
|
||||
|
||||
var entry models.DlocalPaymentLog
|
||||
if err := models.GetDlocalPaymentLogByID(uint(logID), &entry); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Log no encontrado"})
|
||||
}
|
||||
|
||||
ref := entry.Referencia
|
||||
if ref == "" {
|
||||
ref = entry.OrderID
|
||||
}
|
||||
|
||||
var contratoID uint
|
||||
if _, err := fmt.Sscanf(ref, "contrato-%d", &contratoID); err != nil || contratoID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Referencia no tiene formato contrato-{id}"})
|
||||
}
|
||||
|
||||
contrato, err := models.GetContratoParaVerificacion(contratoID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Contrato no encontrado"})
|
||||
}
|
||||
|
||||
payerEmail := entry.PayerEmail
|
||||
if payerEmail == "" {
|
||||
payerEmail = contrato.Cliente.Email
|
||||
}
|
||||
monto := entry.Monto
|
||||
pagoConfirmado := contrato.PagoConfirmado
|
||||
fuenteConfirmacion := ""
|
||||
|
||||
// ─── 1. Ya confirmado en DB ───────────────────────────────────────────────
|
||||
if pagoConfirmado {
|
||||
fuenteConfirmacion = "db"
|
||||
}
|
||||
|
||||
// ─── 2. Mismo log si ya tiene estado PAID ────────────────────────────────
|
||||
if !pagoConfirmado && (entry.Estado == "PAID" || entry.Estado == "AUTHORIZED") {
|
||||
pagoConfirmado = true
|
||||
fuenteConfirmacion = "dlocal_log"
|
||||
}
|
||||
|
||||
// ─── 3. Bold API ─────────────────────────────────────────────────────────
|
||||
if !pagoConfirmado && contrato.EnlacePagoLinkID != "" {
|
||||
boldCfg, boldErr := models.GetBoldConfig()
|
||||
if boldErr == nil {
|
||||
paid, _, apiMonto, apiErr := services.CheckBoldLinkPaid(boldCfg, contrato.EnlacePagoLinkID)
|
||||
if apiErr == nil && paid {
|
||||
pagoConfirmado = true
|
||||
fuenteConfirmacion = "bold_api"
|
||||
if apiMonto > 0 {
|
||||
monto = float64(apiMonto)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4. dLocal API ───────────────────────────────────────────────────────
|
||||
if !pagoConfirmado {
|
||||
dlocalCfg, dlErr := models.GetLastActiveDlocalApi()
|
||||
if dlErr == nil {
|
||||
paid, _, apiMonto, apiEmail, apiErr := services.CheckDlocalPaymentByOrderID(*dlocalCfg, ref)
|
||||
if apiErr == nil && paid {
|
||||
pagoConfirmado = true
|
||||
fuenteConfirmacion = "dlocal_api"
|
||||
if apiMonto > 0 {
|
||||
monto = apiMonto
|
||||
}
|
||||
if apiEmail != "" {
|
||||
payerEmail = apiEmail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si el pago se confirmó ahora (no estaba marcado antes), actualizar contrato
|
||||
if pagoConfirmado && !contrato.PagoConfirmado {
|
||||
_ = models.MarcarContratoPagado(contratoID)
|
||||
go services.EnviarCorreoConfirmacionPago(contratoID)
|
||||
}
|
||||
|
||||
_ = models.UpdateDlocalPaymentLogDatos(uint(logID), payerEmail, monto)
|
||||
_ = models.GetDlocalPaymentLogByID(uint(logID), &entry)
|
||||
|
||||
msg := "Datos actualizados"
|
||||
if pagoConfirmado && !contrato.PagoConfirmado {
|
||||
msg = fmt.Sprintf("Pago confirmado via %s — contrato marcado como pagado", fuenteConfirmacion)
|
||||
} else if pagoConfirmado {
|
||||
msg = fmt.Sprintf("Pago ya confirmado (%s)", fuenteConfirmacion)
|
||||
} else {
|
||||
msg = "Pago aún no confirmado — datos actualizados"
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"ok": true,
|
||||
"confirmado": pagoConfirmado,
|
||||
"fuente": fuenteConfirmacion,
|
||||
"mensaje": msg,
|
||||
"data": entry,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Validación de log API_CHECK ─────────────────────────────────────────────
|
||||
|
||||
// ValidarBoldLog verifica el estado de pago real de un log API_CHECK consultando
|
||||
// todas las fuentes disponibles (dlocal_payment_log → Bold API → dLocal API).
|
||||
// Si se confirma el pago, marca el contrato como pagado y envía el email de confirmación.
|
||||
// También rellena email y monto faltantes. Funciona para cualquier tipo de log Bold.
|
||||
// POST /app/pasarelas/bold/logs/:id/validar
|
||||
func ValidarBoldLog(c *fiber.Ctx) error {
|
||||
logID, err := c.ParamsInt("id")
|
||||
if err != nil || logID <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
|
||||
}
|
||||
|
||||
// Cargar el registro existente
|
||||
var entry models.BoldWebhookLog
|
||||
if err := models.GetBoldWebhookLogByID(uint(logID), &entry); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Log no encontrado"})
|
||||
}
|
||||
|
||||
// Parsear contrato ID de la referencia (contrato-{id})
|
||||
var contratoID uint
|
||||
if _, err := fmt.Sscanf(entry.Referencia, "contrato-%d", &contratoID); err != nil || contratoID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Referencia no tiene formato contrato-{id}"})
|
||||
}
|
||||
|
||||
// Cargar contrato con cliente
|
||||
contrato, err := models.GetContratoParaVerificacion(contratoID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Contrato no encontrado"})
|
||||
}
|
||||
|
||||
// Valores iniciales: reutilizar lo que ya hay en el log, fallback al cliente
|
||||
payerEmail := entry.PayerEmail
|
||||
if payerEmail == "" {
|
||||
payerEmail = contrato.Cliente.Email
|
||||
}
|
||||
monto := entry.Monto
|
||||
pagoConfirmado := contrato.PagoConfirmado
|
||||
fuenteConfirmacion := ""
|
||||
|
||||
// ─── 1. Ya confirmado en DB ───────────────────────────────────────────────
|
||||
if pagoConfirmado {
|
||||
fuenteConfirmacion = "db"
|
||||
}
|
||||
|
||||
// ─── 2. dlocal_payment_log ────────────────────────────────────────────────
|
||||
if !pagoConfirmado {
|
||||
dlocalLogs, dlErr := models.GetDlocalPaymentLogsByRef(entry.Referencia)
|
||||
if dlErr == nil {
|
||||
for _, l := range dlocalLogs {
|
||||
if l.Estado == "PAID" || l.Estado == "AUTHORIZED" {
|
||||
pagoConfirmado = true
|
||||
fuenteConfirmacion = "dlocal_log"
|
||||
if l.PayerEmail != "" {
|
||||
payerEmail = l.PayerEmail
|
||||
}
|
||||
if l.Monto > 0 {
|
||||
monto = int64(l.Monto)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3. Bold API ─────────────────────────────────────────────────────────
|
||||
if !pagoConfirmado && contrato.EnlacePagoLinkID != "" {
|
||||
boldCfg, boldErr := models.GetBoldConfig()
|
||||
if boldErr == nil {
|
||||
paid, _, apiMonto, apiErr := services.CheckBoldLinkPaid(boldCfg, contrato.EnlacePagoLinkID)
|
||||
if apiErr == nil && paid {
|
||||
pagoConfirmado = true
|
||||
fuenteConfirmacion = "bold_api"
|
||||
if apiMonto > 0 {
|
||||
monto = apiMonto
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4. dLocal API ───────────────────────────────────────────────────────
|
||||
if !pagoConfirmado {
|
||||
dlocalCfg, dlErr := models.GetLastActiveDlocalApi()
|
||||
if dlErr == nil {
|
||||
paid, _, apiMonto, apiEmail, apiErr := services.CheckDlocalPaymentByOrderID(*dlocalCfg, entry.Referencia)
|
||||
if apiErr == nil && paid {
|
||||
pagoConfirmado = true
|
||||
fuenteConfirmacion = "dlocal_api"
|
||||
if apiMonto > 0 {
|
||||
monto = int64(apiMonto)
|
||||
}
|
||||
if apiEmail != "" {
|
||||
payerEmail = apiEmail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si el pago se confirmó ahora (no estaba marcado antes), actualizar contrato
|
||||
if pagoConfirmado && !contrato.PagoConfirmado {
|
||||
_ = models.MarcarContratoPagado(contratoID)
|
||||
go services.EnviarCorreoConfirmacionPago(contratoID)
|
||||
}
|
||||
|
||||
// Actualizar log con los mejores datos disponibles
|
||||
_ = models.UpdateBoldWebhookLogDatos(uint(logID), payerEmail, monto)
|
||||
|
||||
// Devolver el log actualizado
|
||||
_ = models.GetBoldWebhookLogByID(uint(logID), &entry)
|
||||
|
||||
msg := "Datos actualizados"
|
||||
if pagoConfirmado && !contrato.PagoConfirmado {
|
||||
msg = fmt.Sprintf("Pago confirmado via %s — contrato marcado como pagado", fuenteConfirmacion)
|
||||
} else if pagoConfirmado {
|
||||
msg = fmt.Sprintf("Pago ya confirmado (%s)", fuenteConfirmacion)
|
||||
} else {
|
||||
msg = "Pago aún no confirmado — datos del contrato actualizados"
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"ok": true,
|
||||
"confirmado": pagoConfirmado,
|
||||
"fuente": fuenteConfirmacion,
|
||||
"mensaje": msg,
|
||||
"data": entry,
|
||||
})
|
||||
}
|
||||
|
||||
+3
-1
@@ -141,11 +141,13 @@ 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.Post("/pasarelas/bold/logs/:id/validar", controllers.ValidarBoldLog)
|
||||
protected.Get("/pasarelas/bold/callbacks", controllers.BoldCallbackLogs)
|
||||
// dLocal
|
||||
protected.Get("/pasarelas/dlocal/config", controllers.GetDlocalConfigAPI)
|
||||
protected.Post("/pasarelas/dlocal/save", controllers.SaveDlocalConfigWeb)
|
||||
protected.Get("/pasarelas/dlocal/logs", controllers.DlocalPaymentLogs)
|
||||
protected.Get("/pasarelas/dlocal/logs", controllers.DlocalPaymentLogsPaginated)
|
||||
protected.Post("/pasarelas/dlocal/logs/:id/validar", controllers.ValidarDlocalLog)
|
||||
protected.Post("/pasarelas/dlocal/registro-pago", apiControllers.DlocalRegistrarPago)
|
||||
// Bold API (crear link, consultar estado)
|
||||
protected.Post("/pasarelas/bold/crear-link", apiControllers.BoldCreatePaymentLink)
|
||||
|
||||
Reference in New Issue
Block a user