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)