From 3f6eb7dddc76d8dcc356dd692d78a5184ac835bb Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Fri, 1 May 2026 13:44:37 -0500 Subject: [PATCH] p --- pkg/models/contrato.go | 27 ++++++- pkg/services/query_runner_service.go | 29 ++++++-- pkg/services/renovacion_service.go | 2 +- resources/views/pago_exitoso.html | 93 +++++++++++++++++++++++++ rest/controllers/api/bold_controller.go | 8 +-- rest/controllers/api/pago_controller.go | 40 +++++++++++ rest/routes/publicas.go | 4 ++ 7 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 resources/views/pago_exitoso.html create mode 100644 rest/controllers/api/pago_controller.go diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index fc844a7..ad1a254 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -22,8 +22,11 @@ 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"` } func (Contrato) TableName() string { return "contratos" } @@ -156,3 +159,23 @@ func LimpiarEnlacePago(contratoID uint) error { "enlace_pago_link_id": "", }).Error } + +// MarcarContratoPagado marca el contrato como pagado al recibir confirmación de la pasarela. +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": "", + "enlace_pago_link_id": "", + }).Error +} + +// GetEstadoPago devuelve si el contrato tiene pago confirmado (para polling desde el frontend). +func GetEstadoPago(contratoID uint) (bool, *time.Time, error) { + var c Contrato + if err := app.Http.Database.DB.Select("pago_confirmado", "fecha_pago").First(&c, contratoID).Error; err != nil { + return false, nil, err + } + return c.PagoConfirmado, c.FechaPago, nil +} diff --git a/pkg/services/query_runner_service.go b/pkg/services/query_runner_service.go index 6c53e5c..779942e 100644 --- a/pkg/services/query_runner_service.go +++ b/pkg/services/query_runner_service.go @@ -1,6 +1,7 @@ package services import ( + "context" "database/sql" "fmt" "strings" @@ -14,6 +15,9 @@ import ( "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) +// connTimeout es el tiempo máximo para establecer una conexión a la DB externa. +const connTimeout = 8 * time.Second + // QueryResult contiene el resultado de una consulta SQL. type QueryResult struct { Columns []string `json:"columns"` @@ -39,16 +43,16 @@ func openDynamicDB(c models.ConxDb) (*sql.DB, error) { switch { case strings.Contains(driver, "postgres"): driverName = "postgres" - dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s sslmode=disable", host, port, user, pass) + dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s sslmode=disable connect_timeout=8", host, port, user, pass) case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"): driverName = "mysql" - dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/", user, pass, host, port) + dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/?timeout=8s&readTimeout=20s&writeTimeout=20s", user, pass, host, port) case strings.Contains(driver, "sqlite"): driverName = "sqlite3" dsn = host // para sqlite el host es la ruta del archivo case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"): driverName = "sqlserver" - dsn = fmt.Sprintf("sqlserver://%s:%s@%s:%s", user, pass, host, port) + dsn = fmt.Sprintf("sqlserver://%s:%s@%s:%s?dial+timeout=8", user, pass, host, port) default: return nil, fmt.Errorf("driver no soportado: %s", driver) } @@ -59,6 +63,13 @@ func openDynamicDB(c models.ConxDb) (*sql.DB, error) { } db.SetConnMaxLifetime(30 * time.Second) db.SetMaxOpenConns(2) + // Verificar conectividad inmediatamente para fallar rápido en lugar de bloquear al hacer la primera query + ctx, cancel := context.WithTimeout(context.Background(), connTimeout) + defer cancel() + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, fmt.Errorf("no se pudo conectar al servidor de base de datos: %w", err) + } return db, nil } @@ -169,7 +180,9 @@ func ListDatabases(conx models.ConxDb) ([]string, error) { return []string{"main"}, nil } - rows, err := db.Query(query) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + rows, err := db.QueryContext(ctx, query) if err != nil { return nil, err } @@ -253,13 +266,19 @@ func openDynamicDBWithName(c models.ConxDb, dbName string) (*sql.DB, error) { var dsn string switch { case strings.Contains(driver, "postgres"): - dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", host, port, user, pass, dbName) + dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable connect_timeout=8", host, port, user, pass, dbName) db, err := sql.Open("postgres", dsn) if err != nil { return nil, err } db.SetConnMaxLifetime(30 * time.Second) db.SetMaxOpenConns(2) + ctx, cancel := context.WithTimeout(context.Background(), connTimeout) + defer cancel() + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, fmt.Errorf("no se pudo conectar a la base de datos '%s': %w", dbName, err) + } return db, nil default: return openDynamicDB(c) diff --git a/pkg/services/renovacion_service.go b/pkg/services/renovacion_service.go index 30cd948..5cff912 100644 --- a/pkg/services/renovacion_service.go +++ b/pkg/services/renovacion_service.go @@ -291,7 +291,7 @@ func obtenerEnlaceBold(contrato *models.Contrato) string { }, Description: desc, Reference: fmt.Sprintf("contrato-%d", contrato.ID), - CallbackURL: cfg.CallbackUrl, + CallbackURL: cfg.CallbackUrl + fmt.Sprintf("?ref=contrato-%d", contrato.ID), } result, err := CreateBoldPaymentLink(cfg, req) diff --git a/resources/views/pago_exitoso.html b/resources/views/pago_exitoso.html new file mode 100644 index 0000000..eb95322 --- /dev/null +++ b/resources/views/pago_exitoso.html @@ -0,0 +1,93 @@ +
+
+ + +
+ + + + +

Verificando tu pago…

+

Estamos confirmando con la pasarela. Esto puede tomar unos segundos.

+
+ + +
+
+ + + +
+

¡Pago confirmado!

+

Tu pago fue procesado exitosamente.

+

Fecha:

+ + Volver al inicio + +
+ + +
+
+ + + +
+

Pago en proceso

+

+ Tu pago está siendo procesado. Recibirás una confirmación por correo en breve. +

+

Si realizaste el pago, puede tardar algunos minutos en reflejarse.

+ + Volver al inicio + +
+ +
+
+ + diff --git a/rest/controllers/api/bold_controller.go b/rest/controllers/api/bold_controller.go index 7e8dceb..b6cd63d 100644 --- a/rest/controllers/api/bold_controller.go +++ b/rest/controllers/api/bold_controller.go @@ -92,15 +92,15 @@ func BoldWebhook(c *fiber.Ctx) error { log.Printf("[BOLD] Webhook: SALE_APPROVED — payment_id=%s referencia=%s email=%s monto=%d", paymentID, referencia, payerEmail, monto) - // ─── 7. Limpiar enlace de pago del contrato (para generar uno nuevo en la próxima notificación) + // ─── 7. Marcar contrato como pagado y limpiar enlace ──────────────────── // La referencia tiene formato "contrato-{id}" if referencia != "" { var contratoID uint if _, err := fmt.Sscanf(referencia, "contrato-%d", &contratoID); err == nil && contratoID > 0 { - if err := models.LimpiarEnlacePago(contratoID); err != nil { - log.Printf("[BOLD] Webhook: error limpiando enlace pago contrato %d: %v", contratoID, err) + if err := models.MarcarContratoPagado(contratoID); err != nil { + log.Printf("[BOLD] Webhook: error marcando contrato %d como pagado: %v", contratoID, err) } else { - log.Printf("[BOLD] Webhook: enlace pago limpiado para contrato %d", contratoID) + log.Printf("[BOLD] Webhook: contrato %d marcado como pagado", contratoID) } } } diff --git a/rest/controllers/api/pago_controller.go b/rest/controllers/api/pago_controller.go new file mode 100644 index 0000000..b446fda --- /dev/null +++ b/rest/controllers/api/pago_controller.go @@ -0,0 +1,40 @@ +package controllers + +import ( + "fmt" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// 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}. +func PagoExitosoPage(c *fiber.Ctx) error { + ref := c.Query("ref", "") + return c.Render("pago_exitoso", fiber.Map{"Ref": ref}, "layouts/landing") +} + +// PagoEstadoAPI devuelve el estado de pago de un contrato para polling desde el frontend. +// GET /api/pago-estado?ref=contrato-{id} +func PagoEstadoAPI(c *fiber.Ctx) error { + ref := c.Query("ref", "") + if ref == "" { + return c.JSON(fiber.Map{"confirmado": false, "error": "ref requerido"}) + } + + var contratoID uint + if _, err := fmt.Sscanf(ref, "contrato-%d", &contratoID); err != nil || contratoID == 0 { + return c.JSON(fiber.Map{"confirmado": false, "error": "ref inválido"}) + } + + confirmado, fechaPago, err := models.GetEstadoPago(contratoID) + if err != nil { + return c.JSON(fiber.Map{"confirmado": false, "error": "contrato no encontrado"}) + } + + resp := fiber.Map{"confirmado": confirmado} + if confirmado && fechaPago != nil { + resp["fecha_pago"] = fechaPago.Format("02/01/2006 15:04") + } + return c.JSON(resp) +} diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go index 11c3614..afc64cc 100755 --- a/rest/routes/publicas.go +++ b/rest/routes/publicas.go @@ -18,5 +18,9 @@ func RutasPublicas(web fiber.Router) { // ─── Webhooks públicos (sin autenticación) ──────────────────────────── // Bold requiere respuesta HTTP 200 rápida, sin middleware de sesión. web.Post("/webhooks/bold", apiControllers.BoldWebhook) + + // ─── Página de confirmación de pago ─────────────────────────────────── + web.Get("/pago-exitoso", apiControllers.PagoExitosoPage) + web.Get("/api/pago-estado", apiControllers.PagoEstadoAPI) }