diff --git a/pkg/models/bold_config.go b/pkg/models/bold_config.go index f74db14..c0d6abb 100644 --- a/pkg/models/bold_config.go +++ b/pkg/models/bold_config.go @@ -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. diff --git a/pkg/models/dlocal_api.go b/pkg/models/dlocal_api.go index 2aa02ff..da1c913 100644 --- a/pkg/models/dlocal_api.go +++ b/pkg/models/dlocal_api.go @@ -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 diff --git a/resources/views/pasarelas_pago.html b/resources/views/pasarelas_pago.html index 5bc7b79..c7dada8 100644 --- a/resources/views/pasarelas_pago.html +++ b/resources/views/pasarelas_pago.html @@ -330,7 +330,8 @@ x-text="log.procesado ? 'Procesado' : 'Pendiente'"> - + +
+ + +
@@ -536,6 +548,14 @@ + @@ -810,6 +830,152 @@ + +
+ + +
+ + +
+ + +
+
+ Sin notificaciones de dLocal registradas aún. +
+
+ + + + + + + + + + + + + + + + +
Tipo / FuentePayment IDReferenciaEmail pagadorMontoEstadoFecha
+ +
+ Mostrando de registros +
+ + Pág + +
+
+
+
+
+ + +
+
+ +

+ + Detalle de notificación dLocal +

+
+
Notification ID
+
Payment ID
+
Order ID
+
Referencia
+
Tipo
+
Email pagador
+
Monto
+
Procesado
+
Fecha
+
+
+

Payload RAW

+

+                    
+
+
+ @@ -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 }; diff --git a/rest/controllers/pasarelas_controller.go b/rest/controllers/pasarelas_controller.go index ee17395..8de82d8 100644 --- a/rest/controllers/pasarelas_controller.go +++ b/rest/controllers/pasarelas_controller.go @@ -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, + }) +} diff --git a/rest/routes/user.go b/rest/routes/user.go index 1e90dbf..feaea6c 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -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)