From fbd75d8cc6a80bf23e4efd438e498848fef5e495 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 12 May 2026 21:22:30 -0500 Subject: [PATCH] up --- pkg/models/bold_config.go | 14 +++- pkg/models/contrato.go | 10 +-- pkg/models/dlocal_api.go | 6 +- pkg/models/notificacion_regla.go | 2 +- pkg/models/saas_api_config.go | 16 ++-- pkg/services/bold_service.go | 2 +- resources/views/pasarelas_pago.html | 31 +++++++ rest/controllers/api/dlocal_controller.go | 4 +- rest/controllers/contrato_controller.go | 52 ++++++------ rest/controllers/pasarelas_controller.go | 98 +++++++++++++++++++++++ rest/routes/user.go | 1 + 11 files changed, 188 insertions(+), 48 deletions(-) diff --git a/pkg/models/bold_config.go b/pkg/models/bold_config.go index c0d6abb..e0f5078 100644 --- a/pkg/models/bold_config.go +++ b/pkg/models/bold_config.go @@ -13,11 +13,11 @@ type BoldConfig struct { ApiKeyProd string `json:"api_key_prod" gorm:"column:api_key_prod;type:text"` SecretKeyProd string `json:"secret_key_prod" gorm:"column:secret_key_prod;type:text"` // Claves de prueba / test - ApiKeyTest string `json:"api_key_test" gorm:"column:api_key_test;type:text"` + ApiKeyTest string `json:"api_key_test" gorm:"column:api_key_test;type:text"` // En modo test la secret key es cadena vacía según la doc oficial SecretKeyTest string `json:"secret_key_test" gorm:"column:secret_key_test;type:text"` // Modo activo: "test" | "production" - Modo string `json:"modo" gorm:"column:modo;default:'test'"` + Modo string `json:"modo" gorm:"column:modo;default:'test'"` // URL a la que Bold redirige al usuario tras el pago CallbackUrl string `json:"callback_url" gorm:"column:callback_url;type:text"` // Nota interna @@ -186,6 +186,16 @@ func UpdateBoldCallbackEstado(referencia, estado string) { Update("estado", estado) } +// GetBoldCallbackLogByID carga un intento de pago por su PK. +func GetBoldCallbackLogByID(id uint, out *BoldCallbackLog) error { + return app.Http.Database.DB.First(out, id).Error +} + +// UpdateBoldCallbackEstadoByID actualiza el estado de un intento de pago específico por su PK. +func UpdateBoldCallbackEstadoByID(id uint, estado string) error { + return app.Http.Database.DB.Model(&BoldCallbackLog{}).Where("id = ?", id).Update("estado", estado).Error +} + // GetBoldCallbackLogsPaginated devuelve los intentos de pago con paginación y filtro. func GetBoldCallbackLogsPaginated(page, limit int, estado string) ([]BoldCallbackLog, int64, error) { var logs []BoldCallbackLog diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index 03866b9..5ddba46 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -22,8 +22,8 @@ type Contrato struct { // Enlace de pago Bold: único por ciclo de pago. // Se reutiliza en múltiples notificaciones del mismo ciclo. // Se anula (vacía) cuando SALE_APPROVED llega y se registra el pago. - EnlacePago string `json:"enlace_pago" gorm:"column:enlace_pago;type:text"` - EnlacePagoLinkID string `json:"enlace_pago_link_id" gorm:"column:enlace_pago_link_id;type:varchar(64)"` + EnlacePago string `json:"enlace_pago" gorm:"column:enlace_pago;type:text"` + EnlacePagoLinkID string `json:"enlace_pago_link_id" gorm:"column:enlace_pago_link_id;type:varchar(64)"` // Confirmación de pago PagoConfirmado bool `json:"pago_confirmado" gorm:"column:pago_confirmado;default:false"` FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"` @@ -191,9 +191,9 @@ func LimpiarEnlacePago(contratoID uint) error { func MarcarContratoPagado(contratoID uint) error { now := time.Now() return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{ - "pago_confirmado": true, - "fecha_pago": now, - "enlace_pago": "", + "pago_confirmado": true, + "fecha_pago": now, + "enlace_pago": "", "enlace_pago_link_id": "", }).Error } diff --git a/pkg/models/dlocal_api.go b/pkg/models/dlocal_api.go index da1c913..623c135 100644 --- a/pkg/models/dlocal_api.go +++ b/pkg/models/dlocal_api.go @@ -105,9 +105,9 @@ type DlocalPaymentLog struct { gorm.Model // notification_id único; para entradas manuales se genera con prefijo "manual-" NotificationID string `json:"notification_id" gorm:"column:notification_id;uniqueIndex;type:varchar(128);not null"` - Fuente string `json:"fuente" gorm:"column:fuente;type:varchar(20)"` // "webhook" | "manual" - Tipo string `json:"tipo" gorm:"column:tipo;type:varchar(50)"` // PAYMENT, SUBSCRIPTION_CHARGE, … - Estado string `json:"estado" gorm:"column:estado;type:varchar(30)"` // PAID, PENDING, REJECTED, CANCELLED, EXPIRED + Fuente string `json:"fuente" gorm:"column:fuente;type:varchar(20)"` // "webhook" | "manual" + Tipo string `json:"tipo" gorm:"column:tipo;type:varchar(50)"` // PAYMENT, SUBSCRIPTION_CHARGE, … + Estado string `json:"estado" gorm:"column:estado;type:varchar(30)"` // PAID, PENDING, REJECTED, CANCELLED, EXPIRED PaymentID string `json:"payment_id" gorm:"column:payment_id;type:varchar(64)"` OrderID string `json:"order_id" gorm:"column:order_id;type:varchar(120)"` Referencia string `json:"referencia" gorm:"column:referencia;type:varchar(120)"` diff --git a/pkg/models/notificacion_regla.go b/pkg/models/notificacion_regla.go index a90a18f..2a72ed6 100644 --- a/pkg/models/notificacion_regla.go +++ b/pkg/models/notificacion_regla.go @@ -13,7 +13,7 @@ type NotificacionRegla struct { PlantillaID uint `json:"plantilla_id" gorm:"column:plantilla_id"` Plantilla PlantillaCorreo `json:"plantilla" gorm:"foreignKey:PlantillaID"` Activo bool `json:"activo" gorm:"column:activo;default:true"` - AplicaA string `json:"aplica_a" gorm:"column:aplica_a;default:'todos'"` // todos | renovable | unico + AplicaA string `json:"aplica_a" gorm:"column:aplica_a;default:'todos'"` // todos | renovable | unico PasarelaEnlace string `json:"pasarela_enlace" gorm:"column:pasarela_enlace;default:'bold'"` // bold | dlocal | ninguna } diff --git a/pkg/models/saas_api_config.go b/pkg/models/saas_api_config.go index 9160a32..740e141 100644 --- a/pkg/models/saas_api_config.go +++ b/pkg/models/saas_api_config.go @@ -10,15 +10,15 @@ import ( // La vinculación es: Contrato → Servicios (m2m) → servicio_id ↔ SaasProducto.ServicioID → SaasApiConfig.SaasID type SaasApiConfig struct { gorm.Model - SaasID uint `json:"saas_id" gorm:"column:saas_id;not null;index"` - SaasProducto SaasProducto `json:"saas_producto" gorm:"foreignKey:SaasID"` - Nombre string `json:"nombre" gorm:"column:nombre;not null"` // etiqueta amigable + SaasID uint `json:"saas_id" gorm:"column:saas_id;not null;index"` + SaasProducto SaasProducto `json:"saas_producto" gorm:"foreignKey:SaasID"` + Nombre string `json:"nombre" gorm:"column:nombre;not null"` // etiqueta amigable // Pasarela que dispara este callback: dlocal | bold | ambas (default) - Pasarela string `json:"pasarela" gorm:"column:pasarela;default:'ambas'"` - EndpointURL string `json:"endpoint_url" gorm:"column:endpoint_url;type:text;not null"` - Metodo string `json:"metodo" gorm:"column:metodo;default:'POST'"` // POST|PUT|GET - ApiKeyHeader string `json:"api_key_header" gorm:"column:api_key_header"` // ej: "X-API-Key" - ApiKeyValue string `json:"api_key_value" gorm:"column:api_key_value;type:text"` // valor secreto + Pasarela string `json:"pasarela" gorm:"column:pasarela;default:'ambas'"` + EndpointURL string `json:"endpoint_url" gorm:"column:endpoint_url;type:text;not null"` + Metodo string `json:"metodo" gorm:"column:metodo;default:'POST'"` // POST|PUT|GET + ApiKeyHeader string `json:"api_key_header" gorm:"column:api_key_header"` // ej: "X-API-Key" + ApiKeyValue string `json:"api_key_value" gorm:"column:api_key_value;type:text"` // valor secreto // PayloadTemplate es un JSON con marcadores que se reemplazarán antes de enviar. // Variables disponibles: {{.ContratoID}} {{.Referencia}} {{.Email}} {{.Monto}} {{.Moneda}} {{.SaasID}} {{.SaasSlug}} {{.Fuente}} PayloadTemplate string `json:"payload_template" gorm:"column:payload_template;type:text"` diff --git a/pkg/services/bold_service.go b/pkg/services/bold_service.go index c21341d..b045cc3 100644 --- a/pkg/services/bold_service.go +++ b/pkg/services/bold_service.go @@ -164,7 +164,7 @@ func ParseBoldWebhookEvent(rawBody []byte) (*BoldWebhookEvent, error) { // La API de Bold devuelve los campos directamente en el root del JSON (no dentro de "payload"). type BoldLinkStatus struct { ID string `json:"id"` - Status string `json:"status"` // ACTIVE | PROCESSING | PAID | REJECTED | CANCELLED | EXPIRED + Status string `json:"status"` // ACTIVE | PROCESSING | PAID | REJECTED | CANCELLED | EXPIRED TransactionID string `json:"transaction_id"` // ID de la transacción cuando está pagado Reference string `json:"reference"` Total int64 `json:"total"` diff --git a/resources/views/pasarelas_pago.html b/resources/views/pasarelas_pago.html index c7dada8..da72469 100644 --- a/resources/views/pasarelas_pago.html +++ b/resources/views/pasarelas_pago.html @@ -434,6 +434,7 @@ +
+ + +
@@ -1030,6 +1042,7 @@ function pasarelasApp() { showCallbackModal: false, selectedCallback: null, validandoLogID: 0, + validandoCallbackID: 0, // ─── dLocal ───────────────────────────────────────────────────── dlocalModo: 'dev', @@ -1155,6 +1168,24 @@ function pasarelasApp() { } catch (_) {} }, + async validarCallback(cb) { + this.validandoCallbackID = cb.ID; + try { + const { data } = await axios.post(`/app/pasarelas/bold/callbacks/${cb.ID}/validar`); + if (data.ok && data.data) { + const idx = this.boldCallbacks.findIndex(c => c.ID === cb.ID); + if (idx !== -1) this.boldCallbacks[idx] = data.data; + } + const msg = data.mensaje || (data.confirmado ? 'Pago confirmado ✓' : 'Pago aún no confirmado'); + this.showToast(msg, data.confirmado ? 'success' : 'error'); + if (data.confirmado) this.loadBoldCallbacks(); + } catch (e) { + this.showToast(e.response?.data?.error || 'Error al validar', 'error'); + } finally { + this.validandoCallbackID = 0; + } + }, + copyWebhook() { const base = window.location.origin; const url = base + '/webhooks/bold'; diff --git a/rest/controllers/api/dlocal_controller.go b/rest/controllers/api/dlocal_controller.go index ea6a50b..06d84ed 100644 --- a/rest/controllers/api/dlocal_controller.go +++ b/rest/controllers/api/dlocal_controller.go @@ -348,8 +348,8 @@ func DlocalRegistrarPago(c *fiber.Ctx) error { PayerEmail string `json:"payer_email"` Monto float64 `json:"monto"` Moneda string `json:"moneda"` - Estado string `json:"estado"` // PAID, PENDING, REJECTED, … - Tipo string `json:"tipo"` // PAYMENT, SUBSCRIPTION_CHARGE, manual, … + Estado string `json:"estado"` // PAID, PENDING, REJECTED, … + Tipo string `json:"tipo"` // PAYMENT, SUBSCRIPTION_CHARGE, manual, … Nota string `json:"nota"` } var b body diff --git a/rest/controllers/contrato_controller.go b/rest/controllers/contrato_controller.go index bd8d494..08cbfe6 100644 --- a/rest/controllers/contrato_controller.go +++ b/rest/controllers/contrato_controller.go @@ -244,34 +244,34 @@ func GetHistorialContrato(c *fiber.Ctx) error { // Construir timeline unificado type Evento struct { - Tipo string `json:"tipo"` // creacion | renovacion | notificacion | pago - Icono string `json:"icono"` - Titulo string `json:"titulo"` - Detalle string `json:"detalle"` - Estado string `json:"estado"` // ok | error | info - FechaISO string `json:"fecha"` + Tipo string `json:"tipo"` // creacion | renovacion | notificacion | pago + Icono string `json:"icono"` + Titulo string `json:"titulo"` + Detalle string `json:"detalle"` + Estado string `json:"estado"` // ok | error | info + FechaISO string `json:"fecha"` } var timeline []Evento // Evento: creación del contrato timeline = append(timeline, Evento{ - Tipo: "creacion", - Icono: "document", - Titulo: "Contrato creado", - Detalle: "Inicio: " + contrato.FechaInicio.Format("02/01/2006") + " · Vence: " + contrato.FechaVencimiento.Format("02/01/2006"), - Estado: "info", + Tipo: "creacion", + Icono: "document", + Titulo: "Contrato creado", + Detalle: "Inicio: " + contrato.FechaInicio.Format("02/01/2006") + " · Vence: " + contrato.FechaVencimiento.Format("02/01/2006"), + Estado: "info", FechaISO: contrato.CreatedAt.Format(time.RFC3339), }) // Evento: renovaciones (updated_at con estado renovado — heurístico por estado) if contrato.Estado == "renovado" { timeline = append(timeline, Evento{ - Tipo: "renovacion", - Icono: "refresh", - Titulo: "Contrato renovado", - Detalle: "Nuevo vencimiento: " + contrato.FechaVencimiento.Format("02/01/2006"), - Estado: "ok", + Tipo: "renovacion", + Icono: "refresh", + Titulo: "Contrato renovado", + Detalle: "Nuevo vencimiento: " + contrato.FechaVencimiento.Format("02/01/2006"), + Estado: "ok", FechaISO: contrato.UpdatedAt.Format(time.RFC3339), }) } @@ -293,11 +293,11 @@ func GetHistorialContrato(c *fiber.Ctx) error { reglaLabel = " (" + n.Regla.Nombre + ")" } timeline = append(timeline, Evento{ - Tipo: "notificacion", - Icono: "mail", - Titulo: "Notificación enviada" + reglaLabel, - Detalle: detalle, - Estado: estado, + Tipo: "notificacion", + Icono: "mail", + Titulo: "Notificación enviada" + reglaLabel, + Detalle: detalle, + Estado: estado, FechaISO: n.CreatedAt.Format(time.RFC3339), }) } @@ -316,11 +316,11 @@ func GetHistorialContrato(c *fiber.Ctx) error { detalle += " · " + p.Fuente } timeline = append(timeline, Evento{ - Tipo: "pago", - Icono: "currency", - Titulo: "Pago recibido", - Detalle: detalle, - Estado: estado, + Tipo: "pago", + Icono: "currency", + Titulo: "Pago recibido", + Detalle: detalle, + Estado: estado, FechaISO: p.CreatedAt.Format(time.RFC3339), }) } diff --git a/rest/controllers/pasarelas_controller.go b/rest/controllers/pasarelas_controller.go index 8de82d8..c453cce 100644 --- a/rest/controllers/pasarelas_controller.go +++ b/rest/controllers/pasarelas_controller.go @@ -315,6 +315,104 @@ func ValidarDlocalLog(c *fiber.Ctx) error { }) } +// ─── Validación de intento de pago (callback) ─────────────────────────────── + +// ValidarBoldCallback verifica si el pago de un intento pendiente realmente se realizó. +// Recorre las mismas fuentes que verificarPago() y, si confirma, marca el contrato + actualiza el callback. +// POST /app/pasarelas/bold/callbacks/:id/validar +func ValidarBoldCallback(c *fiber.Ctx) error { + cbID, err := c.ParamsInt("id") + if err != nil || cbID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"}) + } + + var cb models.BoldCallbackLog + if err := models.GetBoldCallbackLogByID(uint(cbID), &cb); err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Intento no encontrado"}) + } + + var contratoID uint + if _, err := fmt.Sscanf(cb.Referencia, "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"}) + } + + pagoConfirmado := contrato.PagoConfirmado + fuente := "" + + // ─── 1. Ya confirmado en DB ─────────────────────────────────────────────── + if pagoConfirmado { + fuente = "db" + } + + // ─── 2. dlocal_payment_log ─────────────────────────────────────────────── + if !pagoConfirmado { + if dlLogs, dlErr := models.GetDlocalPaymentLogsByRef(cb.Referencia); dlErr == nil { + for _, l := range dlLogs { + if l.Estado == "PAID" || l.Estado == "AUTHORIZED" { + pagoConfirmado = true + fuente = "dlocal_log" + break + } + } + } + } + + // ─── 3. Bold API ───────────────────────────────────────────────────────── + if !pagoConfirmado && contrato.EnlacePagoLinkID != "" { + if boldCfg, boldErr := models.GetBoldConfig(); boldErr == nil { + if paid, _, _, apiErr := services.CheckBoldLinkPaid(boldCfg, contrato.EnlacePagoLinkID); apiErr == nil && paid { + pagoConfirmado = true + fuente = "bold_api" + } + } + } + + // ─── 4. dLocal API ─────────────────────────────────────────────────────── + if !pagoConfirmado { + if dlocalCfg, dlErr := models.GetLastActiveDlocalApi(); dlErr == nil { + if paid, _, _, _, apiErr := services.CheckDlocalPaymentByOrderID(*dlocalCfg, cb.Referencia); apiErr == nil && paid { + pagoConfirmado = true + fuente = "dlocal_api" + } + } + } + + // Actualizar contrato si se confirmó ahora + if pagoConfirmado && !contrato.PagoConfirmado { + _ = models.MarcarContratoPagado(contratoID) + go services.EnviarCorreoConfirmacionPago(contratoID) + } + + // Actualizar estado del callback + nuevoEstado := cb.Estado + if pagoConfirmado && cb.Estado == "pendiente" { + nuevoEstado = "pagado" + _ = models.UpdateBoldCallbackEstadoByID(uint(cbID), "pagado") + cb.Estado = "pagado" + } + + msg := "Pago aún no confirmado" + if pagoConfirmado && !contrato.PagoConfirmado { + msg = fmt.Sprintf("Pago confirmado via %s — contrato marcado como pagado", fuente) + } else if pagoConfirmado { + msg = fmt.Sprintf("Pago ya confirmado (%s)", fuente) + } + _ = nuevoEstado + + return c.JSON(fiber.Map{ + "ok": true, + "confirmado": pagoConfirmado, + "fuente": fuente, + "mensaje": msg, + "data": cb, + }) +} + // ─── Validación de log API_CHECK ───────────────────────────────────────────── // ValidarBoldLog verifica el estado de pago real de un log API_CHECK consultando diff --git a/rest/routes/user.go b/rest/routes/user.go index feaea6c..6f0658f 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -143,6 +143,7 @@ func UserRoutes(app fiber.Router) { protected.Get("/pasarelas/bold/logs", controllers.BoldWebhookLogs) protected.Post("/pasarelas/bold/logs/:id/validar", controllers.ValidarBoldLog) protected.Get("/pasarelas/bold/callbacks", controllers.BoldCallbackLogs) + protected.Post("/pasarelas/bold/callbacks/:id/validar", controllers.ValidarBoldCallback) // dLocal protected.Get("/pasarelas/dlocal/config", controllers.GetDlocalConfigAPI) protected.Post("/pasarelas/dlocal/save", controllers.SaveDlocalConfigWeb)