From 381425b7e696b16f67f4c4a8ba15d04b15d1ef65 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 12 May 2026 22:34:10 -0500 Subject: [PATCH] ip --- migrations/migrate.go | 18 ++++++------ pkg/models/contrato.go | 33 +++++++++++++++------- pkg/services/ciclo_notificaciones_test.go | 2 +- pkg/services/renovacion_service.go | 6 ---- rest/controllers/api/bold_controller.go | 12 ++++---- rest/controllers/api/dlocal_controller.go | 19 +++++++------ rest/controllers/api/pago_controller.go | 18 +++++++----- rest/controllers/diagnostico_controller.go | 3 +- rest/controllers/pasarelas_controller.go | 15 ++++++---- 9 files changed, 72 insertions(+), 54 deletions(-) diff --git a/migrations/migrate.go b/migrations/migrate.go index 649cab1..decf63b 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -167,10 +167,10 @@ func SeedPlantillasBase() { db := app.Http.Database.DB plantillasBase := []struct { - nombre string - asunto string - tipo string - cuerpo string + nombre string + asunto string + tipo string + cuerpo string }{ { nombre: "Pago recibido (base)", @@ -271,7 +271,7 @@ func MigrateRenovaciones() { &models.Cliente{}, &models.PlantillaCorreo{}, &models.NotificacionRegla{}, - &models.Contrato{}, // incluye columnas enlace_pago y enlace_pago_link_id + &models.Contrato{}, // incluye columnas enlace_pago y enlace_pago_link_id &models.NotificacionLog{}, &models.SmtpConfig{}, ); err != nil { @@ -663,11 +663,11 @@ func SeedSaas() { var sub models.Submodules if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil { sub = models.Submodules{ - Title: e.title, + Title: e.title, Description: e.desc, - Url: e.url, - ModuleId: modulo.ID, - ModifiedAt: time.Now(), + Url: e.url, + ModuleId: modulo.ID, + ModifiedAt: time.Now(), } if err := db.Create(&sub).Error; err != nil { log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err) diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index 492840a..95e9a38 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -203,9 +203,11 @@ func periodicidadAMeses(p string) int { } } -// MarcarContratoPagado marca el contrato como pagado y renueva la fecha de vencimiento -// según la periodicidad del servicio renovable asociado. -func MarcarContratoPagado(contratoID uint) error { +// MarcarContratoPagado marca el contrato como pagado y renueva la fecha de vencimiento. +// Retorna (true, nil) si el contrato pasó de no-pagado a pagado en esta llamada. +// Retorna (false, nil) si ya estaba pagado (idempotente). +// WHERE pago_confirmado = false garantiza atomicidad ante llamadas concurrentes. +func MarcarContratoPagado(contratoID uint) (bool, error) { now := time.Now() // Cargar contrato con servicios para calcular nueva fecha de vencimiento @@ -213,12 +215,20 @@ func MarcarContratoPagado(contratoID uint) error { if err := app.Http.Database.DB.Preload("Servicios").First(&c, contratoID).Error; err != nil { log.Printf("[PAGO] No se pudo cargar contrato %d para renovar: %v", contratoID, err) // Fallback: solo marcar pago sin renovar fecha - 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 + result := app.Http.Database.DB.Model(&Contrato{}). + Where("id = ? AND pago_confirmado = false", contratoID). + Updates(map[string]interface{}{ + "pago_confirmado": true, + "fecha_pago": now, + "enlace_pago": "", + "enlace_pago_link_id": "", + }) + return result.RowsAffected > 0, result.Error + } + + // Ya estaba pagado — nada que hacer + if c.PagoConfirmado { + return false, nil } updates := map[string]interface{}{ @@ -254,7 +264,10 @@ func MarcarContratoPagado(contratoID uint) error { ) } - return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(updates).Error + result := app.Http.Database.DB.Model(&Contrato{}). + Where("id = ? AND pago_confirmado = false", contratoID). + Updates(updates) + return result.RowsAffected > 0, result.Error } // GetEstadoPago devuelve si el contrato tiene pago confirmado (para polling desde el frontend). diff --git a/pkg/services/ciclo_notificaciones_test.go b/pkg/services/ciclo_notificaciones_test.go index e1eb97c..11bb39b 100644 --- a/pkg/services/ciclo_notificaciones_test.go +++ b/pkg/services/ciclo_notificaciones_test.go @@ -361,7 +361,7 @@ func TestCicloNotificaciones(t *testing.T) { // ══════════════════════════════════════════════════════════════════════════ t.Run("paso2_pago_confirmado", func(t *testing.T) { // 2a. Simular llegada del webhook: marcar contrato como pagado - if err := models.MarcarContratoPagado(contrato.ID); err != nil { + if _, err := models.MarcarContratoPagado(contrato.ID); err != nil { t.Fatalf("MarcarContratoPagado: %v", err) } diff --git a/pkg/services/renovacion_service.go b/pkg/services/renovacion_service.go index 80bd9af..2f0a78e 100644 --- a/pkg/services/renovacion_service.go +++ b/pkg/services/renovacion_service.go @@ -299,12 +299,6 @@ func EnviarCorreoConfirmacionPago(contratoID uint, pasarela ...string) { } regla := ®las[0] - // Idempotencia: si ya se envió hoy para esta regla+cliente, no reenviar - if models.YaEnviadoHoy(contrato.ClienteID, regla.ID) { - log.Printf("[PAGO-CORREO] correo de confirmación ya enviado hoy para cliente %d", contrato.ClienteID) - return - } - if err := EnviarNotificacionGrupo(regla, &contrato.Cliente, []models.Contrato{*contrato}, pas); err != nil { log.Printf("[PAGO-CORREO] error enviando correo de confirmación para contrato %d: %v", contratoID, err) } else { diff --git a/rest/controllers/api/bold_controller.go b/rest/controllers/api/bold_controller.go index 9416ff0..ca3f656 100644 --- a/rest/controllers/api/bold_controller.go +++ b/rest/controllers/api/bold_controller.go @@ -118,13 +118,15 @@ func BoldWebhook(c *fiber.Ctx) error { if referencia != "" { var contratoID uint if _, err := fmt.Sscanf(referencia, "contrato-%d", &contratoID); err == nil && contratoID > 0 { - if err := models.MarcarContratoPagado(contratoID); err != nil { + if ok, err := models.MarcarContratoPagado(contratoID); err != nil { log.Printf("[BOLD] Webhook: error marcando contrato %d como pagado: %v", contratoID, err) } else { - log.Printf("[BOLD] Webhook: contrato %d marcado como pagado", contratoID) - // Notificar SaaS externos + enviar correo de confirmación (goroutine, no bloquea respuesta) - go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "bold", float64(monto), "COP") - go services.EnviarCorreoConfirmacionPago(contratoID, "bold") + log.Printf("[BOLD] Webhook: contrato %d marcado como pagado (nuevo=%v)", contratoID, ok) + if ok { + // Primer registro del pago: notificar SaaS + enviar correo + go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "bold", float64(monto), "COP") + go services.EnviarCorreoConfirmacionPago(contratoID, "bold") + } } } } diff --git a/rest/controllers/api/dlocal_controller.go b/rest/controllers/api/dlocal_controller.go index 0ced849..6468ca2 100644 --- a/rest/controllers/api/dlocal_controller.go +++ b/rest/controllers/api/dlocal_controller.go @@ -320,13 +320,14 @@ func DlocalWebhook(c *fiber.Ctx) error { if orderID != "" { var contratoID uint if _, err := fmt.Sscanf(orderID, "contrato-%d", &contratoID); err == nil && contratoID > 0 { - if err := models.MarcarContratoPagado(contratoID); err != nil { - log.Printf("[DLOCAL] Webhook: error marcando contrato %d como pagado: %v", contratoID, err) - } else { - log.Printf("[DLOCAL] Webhook: contrato %d marcado como pagado", contratoID) - // Notificar SaaS externos + enviar correo de confirmación (goroutine, no bloquea respuesta) - go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "dlocal", monto, moneda) - go services.EnviarCorreoConfirmacionPago(contratoID, "dlocal") + if ok, err := models.MarcarContratoPagado(contratoID); err != nil { + log.Printf("[DLOCAL] Webhook: error marcando contrato %d como pagado: %v", contratoID, err) + } else { + log.Printf("[DLOCAL] Webhook: contrato %d marcado como pagado (nuevo=%v)", contratoID, ok) + if ok { + go services.DispatchSaasPaymentNotification(contratoID, payerEmail, "dlocal", monto, moneda) + go services.EnviarCorreoConfirmacionPago(contratoID, "dlocal") + } } } } @@ -405,8 +406,8 @@ func DlocalRegistrarPago(c *fiber.Ctx) error { if b.Estado == "PAID" && referencia != "" { var contratoID uint if _, err := fmt.Sscanf(referencia, "contrato-%d", &contratoID); err == nil && contratoID > 0 { - if err := models.MarcarContratoPagado(contratoID); err != nil { - log.Printf("[DLOCAL] Registro manual: error marcando contrato %d: %v", contratoID, err) + if _, markErr := models.MarcarContratoPagado(contratoID); markErr != nil { + log.Printf("[DLOCAL] Registro manual: error marcando contrato %d: %v", contratoID, markErr) } else { log.Printf("[DLOCAL] Registro manual: contrato %d marcado como pagado", contratoID) } diff --git a/rest/controllers/api/pago_controller.go b/rest/controllers/api/pago_controller.go index 0becc97..1bc4049 100644 --- a/rest/controllers/api/pago_controller.go +++ b/rest/controllers/api/pago_controller.go @@ -78,8 +78,9 @@ func PagoExitosoPage(c *fiber.Ctx) error { // Marcar contrato si aún no está confirmado var contratoID uint if _, err := fmt.Sscanf(ref, "contrato-%d", &contratoID); err == nil && contratoID > 0 { - _ = models.MarcarContratoPagado(contratoID) - go services.EnviarCorreoConfirmacionPago(contratoID, "bold") + if ok, _ := models.MarcarContratoPagado(contratoID); ok { + go services.EnviarCorreoConfirmacionPago(contratoID, "bold") + } // Intentar enriquecer con datos del webhook log si ya llegó go func() { if wLogs, err := models.GetBoldWebhookLogsByRef(ref); err == nil { @@ -161,8 +162,9 @@ func verificarPago(ref string) (confirmado bool, fechaPago string) { for _, l := range dlocalLogs { if l.Estado == "PAID" || l.Estado == "AUTHORIZED" { log.Printf("[PAGO-ESTADO] Contrato %d confirmado via dlocal_payment_log (id=%d)", contratoID, l.ID) - _ = models.MarcarContratoPagado(contratoID) + if ok, _ := models.MarcarContratoPagado(contratoID); ok { go services.EnviarCorreoConfirmacionPago(contratoID, "dlocal") + } return true, l.CreatedAt.Format("02/01/2006 15:04") } } @@ -177,8 +179,9 @@ func verificarPago(ref string) (confirmado bool, fechaPago string) { log.Printf("[PAGO-ESTADO] Bold API error para link %s: %v", contrato.EnlacePagoLinkID, boldApiErr) } else if paid { log.Printf("[PAGO-ESTADO] Contrato %d confirmado via Bold API (payment_id=%s)", contratoID, paymentID) - _ = models.MarcarContratoPagado(contratoID) - go services.EnviarCorreoConfirmacionPago(contratoID, "bold") + if ok, _ := models.MarcarContratoPagado(contratoID); ok { + go services.EnviarCorreoConfirmacionPago(contratoID, "bold") + } if paymentID != "" && !models.IsBoldNotificationDuplicate("api-check-"+paymentID) { _ = models.SaveBoldWebhookLog(models.BoldWebhookLog{ NotificationID: "api-check-" + paymentID, @@ -203,8 +206,9 @@ func verificarPago(ref string) (confirmado bool, fechaPago string) { log.Printf("[PAGO-ESTADO] dLocal API error para order_id %s: %v", ref, dlocalApiErr) } else if paid { log.Printf("[PAGO-ESTADO] Contrato %d confirmado via dLocal API (payment_id=%s)", contratoID, paymentID) - _ = models.MarcarContratoPagado(contratoID) - go services.EnviarCorreoConfirmacionPago(contratoID, "dlocal") + if ok, _ := models.MarcarContratoPagado(contratoID); ok { + go services.EnviarCorreoConfirmacionPago(contratoID, "dlocal") + } notifID := "api-check-" + ref if !models.IsDlocalNotificationDuplicate(notifID) { _ = models.SaveDlocalPaymentLog(models.DlocalPaymentLog{ diff --git a/rest/controllers/diagnostico_controller.go b/rest/controllers/diagnostico_controller.go index 02d3038..6469404 100644 --- a/rest/controllers/diagnostico_controller.go +++ b/rest/controllers/diagnostico_controller.go @@ -181,7 +181,8 @@ func EjecutarTestCiclo(c *fiber.Ctx) error { // ── PASO 2: Simular recepción de pago ───────────────────────────────────── paso("paso 2 — marcar contrato como pagado", func() error { - return models.MarcarContratoPagado(contrato.ID) + _, err := models.MarcarContratoPagado(contrato.ID) + return err }) paso("paso 2 — verificar pago_confirmado en DB", func() error { diff --git a/rest/controllers/pasarelas_controller.go b/rest/controllers/pasarelas_controller.go index 724d89c..2623891 100644 --- a/rest/controllers/pasarelas_controller.go +++ b/rest/controllers/pasarelas_controller.go @@ -290,8 +290,9 @@ func ValidarDlocalLog(c *fiber.Ctx) error { // Si el pago se confirmó ahora (no estaba marcado antes), actualizar contrato if pagoConfirmado && !contrato.PagoConfirmado { - _ = models.MarcarContratoPagado(contratoID) - go services.EnviarCorreoConfirmacionPago(contratoID, fuenteConfirmacion) + if ok, _ := models.MarcarContratoPagado(contratoID); ok { + go services.EnviarCorreoConfirmacionPago(contratoID, fuenteConfirmacion) + } } _ = models.UpdateDlocalPaymentLogDatos(uint(logID), payerEmail, monto) @@ -384,8 +385,9 @@ func ValidarBoldCallback(c *fiber.Ctx) error { // Actualizar contrato si se confirmó ahora if pagoConfirmado && !contrato.PagoConfirmado { - _ = models.MarcarContratoPagado(contratoID) - go services.EnviarCorreoConfirmacionPago(contratoID, fuente) + if ok, _ := models.MarcarContratoPagado(contratoID); ok { + go services.EnviarCorreoConfirmacionPago(contratoID, fuente) + } } // Actualizar estado del callback @@ -513,8 +515,9 @@ func ValidarBoldLog(c *fiber.Ctx) error { // Si el pago se confirmó ahora (no estaba marcado antes), actualizar contrato if pagoConfirmado && !contrato.PagoConfirmado { - _ = models.MarcarContratoPagado(contratoID) - go services.EnviarCorreoConfirmacionPago(contratoID, fuenteConfirmacion) + if ok, _ := models.MarcarContratoPagado(contratoID); ok { + go services.EnviarCorreoConfirmacionPago(contratoID, fuenteConfirmacion) + } } // Actualizar log con los mejores datos disponibles