This commit is contained in:
Lizandro Guarnizo
2026-05-01 13:44:37 -05:00
parent be28fea806
commit 3f6eb7dddc
7 changed files with 191 additions and 12 deletions
+25 -2
View File
@@ -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
}
+24 -5
View File
@@ -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)
+1 -1
View File
@@ -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)
+93
View File
@@ -0,0 +1,93 @@
<div x-data="pagoApp()" x-init="init()" class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-lg max-w-md w-full p-8 text-center">
<!-- Estado: verificando -->
<div x-show="estado === 'verificando'" x-cloak>
<svg class="animate-spin h-12 w-12 text-[#8eb02f] mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<h1 class="text-xl font-bold text-gray-700 mb-2">Verificando tu pago…</h1>
<p class="text-sm text-gray-400">Estamos confirmando con la pasarela. Esto puede tomar unos segundos.</p>
</div>
<!-- Estado: confirmado -->
<div x-show="estado === 'confirmado'" x-cloak>
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-9 h-9 text-green-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-green-700 mb-2">¡Pago confirmado!</h1>
<p class="text-sm text-gray-500 mb-1">Tu pago fue procesado exitosamente.</p>
<p x-show="fechaPago" class="text-xs text-gray-400 mb-6">Fecha: <span x-text="fechaPago"></span></p>
<a href="/" class="inline-block px-6 py-2 bg-[#8eb02f] text-white rounded-lg text-sm font-medium hover:bg-[#7a9a29] transition">
Volver al inicio
</a>
</div>
<!-- Estado: pendiente (sin ref o no encontrado aún) -->
<div x-show="estado === 'pendiente'" x-cloak>
<div class="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-9 h-9 text-yellow-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>
</div>
<h1 class="text-xl font-bold text-yellow-700 mb-2">Pago en proceso</h1>
<p class="text-sm text-gray-500 mb-4">
Tu pago está siendo procesado. Recibirás una confirmación por correo en breve.
</p>
<p class="text-xs text-gray-400 mb-6">Si realizaste el pago, puede tardar algunos minutos en reflejarse.</p>
<a href="/" class="inline-block px-6 py-2 bg-[#8eb02f] text-white rounded-lg text-sm font-medium hover:bg-[#7a9a29] transition">
Volver al inicio
</a>
</div>
</div>
</div>
<script>
function pagoApp() {
return {
estado: 'verificando',
fechaPago: '',
ref: '',
intentos: 0,
maxIntentos: 10,
intervalo: null,
init() {
const params = new URLSearchParams(window.location.search);
this.ref = params.get('ref') || '';
if (!this.ref) {
this.estado = 'pendiente';
return;
}
this.verificar();
this.intervalo = setInterval(() => this.verificar(), 3000);
},
async verificar() {
this.intentos++;
try {
const r = await fetch(`/api/pago-estado?ref=${encodeURIComponent(this.ref)}`);
const data = await r.json();
if (data.confirmado) {
this.estado = 'confirmado';
this.fechaPago = data.fecha_pago || '';
clearInterval(this.intervalo);
return;
}
} catch (_) {}
if (this.intentos >= this.maxIntentos) {
clearInterval(this.intervalo);
this.estado = 'pendiente';
}
},
};
}
</script>
+4 -4
View File
@@ -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)
}
}
}
+40
View File
@@ -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)
}
+4
View File
@@ -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)
}