This commit is contained in:
Lizandro Guarnizo
2026-05-12 21:15:53 -05:00
parent 217e1b550f
commit 553fc25473
5 changed files with 578 additions and 7 deletions
+27 -1
View File
@@ -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.
+42
View File
@@ -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
+244 -4
View File
@@ -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 };
+262 -1
View File
@@ -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
View File
@@ -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)