diff --git a/migrations/migrate.go b/migrations/migrate.go index e647837..f141559 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -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) diff --git a/pkg/models/bold_config.go b/pkg/models/bold_config.go index 15c1502..f74db14 100644 --- a/pkg/models/bold_config.go +++ b/pkg/models/bold_config.go @@ -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 +} diff --git a/resources/views/pasarelas_pago.html b/resources/views/pasarelas_pago.html index 99d2e28..de22a4b 100644 --- a/resources/views/pasarelas_pago.html +++ b/resources/views/pasarelas_pago.html @@ -68,6 +68,33 @@
+ +
+ +
+ + +
@@ -199,7 +226,7 @@

Registra esta URL en el panel Bold → Configuración → Webhooks

- /webhooks/bold + /webhooks/bold
-

- La firma se verifica con HMAC-SHA256. En modo test la secret key puede ser vacía. -

+

La firma se verifica con HMAC-SHA256. En modo test la secret key puede ser vacía.

@@ -234,48 +259,269 @@
+ + + - -
-
-

Últimas notificaciones

- -
-
- Sin notificaciones recibidas aún. -
-
- - - - - - - + +
+ +
+ + + +
+ + +
+
+ Sin notificaciones recibidas aún. +
+
+
TipoPayment IDMontoEstado
+ + + + + + + + + + + + + + + +
EventoPayment IDReferenciaEmail pagadorMontoEstadoFecha
+ +
+ Mostrando de registros +
+ + Pág + +
+ + + +
+
+ ¿Qué es este registro? 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 pagado. Si el usuario abandonó antes de pagar queda en pendiente. +
+ + +
+ + +
+ + +
+
+ Sin intentos registrados aún. Los intentos aparecen cuando un usuario llega al checkout de Bold. +
+
+ + + + + + + + + + + + + + + +
EstadoReferenciaPayment LinkEmailIPFecha intento
+ +
+ Mostrando de intentos +
+ + Pág + +
+
+
+
+
+ + + + +
+
+ +

+ + Detalle de notificación +

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

Payload RAW

+

+                
+
+
+ + +
+
+ +

Detalle de intento de pago

+
+
Estado +
+
Referencia
+
Payment Link
+
Email
+
IP
+
Fecha
+
+
+

Parámetros recibidos de Bold

+

+                
+
+

User Agent

+

+
@@ -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'; } diff --git a/resources/views/query_runner.html b/resources/views/query_runner.html index 093bf2f..5164df9 100644 --- a/resources/views/query_runner.html +++ b/resources/views/query_runner.html @@ -44,20 +44,68 @@ - -
-

- + +
+
+

+ +
+ + +
+
+
+ + + + +
+
+ + + +
+
+
+
Cargando campos…
+
Sin documentos en esta colección.
+ +
+
@@ -127,10 +175,90 @@ style="height:180px; tab-size:2;">
-
- +
+
+ +
+ + +
+ +
+
+
+ + + + + +
+
+ + +
+
+ + + + + +
+
+ + + El campo será eliminado del documento. +
+
+ + +
+
+ + +
+
+
+ + +
+
+
@@ -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); }, diff --git a/rest/controllers/api/bold_controller.go b/rest/controllers/api/bold_controller.go index b6cd63d..b11437d 100644 --- a/rest/controllers/api/bold_controller.go +++ b/rest/controllers/api/bold_controller.go @@ -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}) } diff --git a/rest/controllers/api/pago_controller.go b/rest/controllers/api/pago_controller.go index e120ecc..f821689 100644 --- a/rest/controllers/api/pago_controller.go +++ b/rest/controllers/api/pago_controller.go @@ -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") } diff --git a/rest/controllers/pasarelas_controller.go b/rest/controllers/pasarelas_controller.go index 7ea5e12..ee17395 100644 --- a/rest/controllers/pasarelas_controller.go +++ b/rest/controllers/pasarelas_controller.go @@ -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 ────────────────────────────────────────────────────────────── diff --git a/rest/routes/user.go b/rest/routes/user.go index 474a518..02e37f6 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -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)