p
This commit is contained in:
+25
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user