From c9024274f0992813757dfd8a848c3bc591f05f96 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Fri, 1 May 2026 17:36:17 -0500 Subject: [PATCH] up --- main.go | 1 + migrations/migrate.go | 1 + pkg/models/contrato.go | 12 ++ pkg/models/dlocal_api.go | 53 ++++++ pkg/services/bold_service.go | 30 ++++ pkg/services/dlocal_service.go | 131 +++++++++++++++ rest/controllers/api/dlocal_controller.go | 194 ++++++++++++++++++++++ rest/controllers/pasarelas_controller.go | 11 ++ rest/routes/publicas.go | 2 + rest/routes/user.go | 2 + 10 files changed, 437 insertions(+) diff --git a/main.go b/main.go index 2bdeaf4..25d9017 100755 --- a/main.go +++ b/main.go @@ -52,6 +52,7 @@ func main() { &models.CloudflareConfig{}, &models.BoldConfig{}, &models.BoldWebhookLog{}, + &models.DlocalPaymentLog{}, ) // Seed automático (idempotente) de módulos del sistema migrations.SeedRenovaciones() diff --git a/migrations/migrate.go b/migrations/migrate.go index d260320..e647837 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -58,6 +58,7 @@ func Migrate() { // Pasarelas de pago &models.BoldConfig{}, &models.BoldWebhookLog{}, + &models.DlocalPaymentLog{}, ); err != nil { log.Fatalf("Error during main migration: %v", err) } diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index ad1a254..5e43834 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -179,3 +179,15 @@ func GetEstadoPago(contratoID uint) (bool, *time.Time, error) { } return c.PagoConfirmado, c.FechaPago, nil } + +// GetContratoParaVerificacion devuelve los campos necesarios para verificar el pago directamente +// con la pasarela (pago_confirmado, enlace_pago_link_id). +func GetContratoParaVerificacion(contratoID uint) (*Contrato, error) { + var c Contrato + if err := app.Http.Database.DB. + Select("id", "pago_confirmado", "fecha_pago", "enlace_pago_link_id"). + First(&c, contratoID).Error; err != nil { + return nil, err + } + return &c, nil +} diff --git a/pkg/models/dlocal_api.go b/pkg/models/dlocal_api.go index 7d0d6aa..f32aaa2 100644 --- a/pkg/models/dlocal_api.go +++ b/pkg/models/dlocal_api.go @@ -96,3 +96,56 @@ func GetLastActiveDlocalApi() (*DlocalApi, error) { } return &dlocalConfig, nil } + +// ─── DlocalPaymentLog ───────────────────────────────────────────────────────── + +// DlocalPaymentLog registra cada pago/notificación de dLocal. +// Cubre webhooks automáticos y registros manuales del backoffice. +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 + 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)"` + PayerEmail string `json:"payer_email" gorm:"column:payer_email;type:varchar(255)"` + Monto float64 `json:"monto" gorm:"column:monto"` + Moneda string `json:"moneda" gorm:"column:moneda;type:varchar(10)"` + Procesado bool `json:"procesado" gorm:"column:procesado;default:false"` + Nota string `json:"nota" gorm:"column:nota;type:text"` + Raw string `json:"raw" gorm:"column:raw;type:text"` +} + +func (DlocalPaymentLog) TableName() string { return "dlocal_payment_log" } + +// IsDlocalNotificationDuplicate devuelve true si el notification_id ya existe. +func IsDlocalNotificationDuplicate(notificationID string) bool { + result := app.Http.Database.DB. + Where("notification_id = ?", notificationID). + First(&DlocalPaymentLog{}) + return result.Error == nil +} + +// SaveDlocalPaymentLog inserta un nuevo registro de pago. +func SaveDlocalPaymentLog(entry DlocalPaymentLog) error { + return app.Http.Database.DB.Create(&entry).Error +} + +// MarkDlocalPaymentProcessed marca el registro como procesado. +func MarkDlocalPaymentProcessed(notificationID string) { + app.Http.Database.DB.Model(&DlocalPaymentLog{}). + Where("notification_id = ?", notificationID). + Update("procesado", true) +} + +// GetDlocalPaymentLogs devuelve los últimos N registros ordenados por fecha. +func GetDlocalPaymentLogs(limit int) ([]DlocalPaymentLog, error) { + var logs []DlocalPaymentLog + if err := app.Http.Database.DB.Order("id DESC").Limit(limit).Find(&logs).Error; err != nil { + return nil, err + } + return logs, nil +} diff --git a/pkg/services/bold_service.go b/pkg/services/bold_service.go index 4655008..ca0639f 100644 --- a/pkg/services/bold_service.go +++ b/pkg/services/bold_service.go @@ -157,3 +157,33 @@ func ParseBoldWebhookEvent(rawBody []byte) (*BoldWebhookEvent, error) { } return &event, nil } + +// ─── Consulta directa de estado de link ────────────────────────────────────── + +// BoldLinkStatus refleja el campo status del payload de la API de Bold. +type BoldLinkStatus struct { + Payload struct { + Status string `json:"status"` // ACTIVE | PAID | EXPIRED | CANCELLED + PaymentID string `json:"payment_id"` // presente cuando está pagado + Amount struct { + Total int64 `json:"total_amount"` + Currency string `json:"currency"` + } `json:"amount"` + Reference string `json:"reference"` + } `json:"payload"` +} + +// CheckBoldLinkPaid consulta la API de Bold para saber si un link ya fue pagado. +// Devuelve (pagado bool, paymentID string, error). +func CheckBoldLinkPaid(cfg *models.BoldConfig, linkID string) (bool, string, error) { + raw, err := GetBoldPaymentLinkStatus(cfg, linkID) + if err != nil { + return false, "", err + } + var result BoldLinkStatus + if err := json.Unmarshal(raw, &result); err != nil { + return false, "", fmt.Errorf("bold: parse link status: %w", err) + } + paid := result.Payload.Status == "PAID" || result.Payload.Status == "APPROVED" + return paid, result.Payload.PaymentID, nil +} diff --git a/pkg/services/dlocal_service.go b/pkg/services/dlocal_service.go index e5e328f..4a55e26 100644 --- a/pkg/services/dlocal_service.go +++ b/pkg/services/dlocal_service.go @@ -2,6 +2,9 @@ package services import ( "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -531,3 +534,131 @@ func CreatePago(cfg models.DlocalApi, pago PagoRequest) ([]byte, error) { return responseBody, nil } + +// ─── Webhook dLocal ─────────────────────────────────────────────────────────── + +// DlocalWebhookNotification es la estructura del JSON que dLocal envía en cada notificación. +type DlocalWebhookNotification struct { + ID string `json:"id"` // notification ID único + Type string `json:"type"` // PAYMENT, SUBSCRIPTION_CHARGE, etc. + Status string `json:"status"` // PAID, PENDING, REJECTED, CANCELLED, EXPIRED, AUTHORIZED + CreatedDate time.Time `json:"created_date"` + Payment struct { + ID string `json:"id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + OrderID string `json:"order_id"` + Payer struct { + Email string `json:"email"` + Name string `json:"name"` + } `json:"payer"` + } `json:"payment"` + // Para notificaciones de suscripción + Subscription struct { + ID string `json:"id"` + } `json:"subscription"` + Invoice struct { + ID string `json:"id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + } `json:"invoice"` +} + +// ParseDlocalWebhookNotification parsea el body crudo en la estructura de notificación. +func ParseDlocalWebhookNotification(rawBody []byte) (*DlocalWebhookNotification, error) { + var n DlocalWebhookNotification + if err := json.Unmarshal(rawBody, &n); err != nil { + return nil, err + } + return &n, nil +} + +// VerifyDlocalSignature verifica la firma HMAC-SHA256 enviada por dLocal. +// dLocal calcula: HMAC-SHA256(secretKey, rawBody) → hex. +// El header es "X-dLocal-Signature". +func VerifyDlocalSignature(rawBody []byte, signature string, cfg models.DlocalApi) bool { + secret := cfg.AccessKeySecretdev + if cfg.Modo == "prod" { + secret = cfg.AccessKeySecret + } + if secret == "" { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(rawBody) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} + +// ─── Consulta directa de pagos dLocal ──────────────────────────────────────── + +// dlocalPaymentResp mapea la respuesta de GET /v1/payments?order_id=... +type dlocalPaymentResp struct { + ID string `json:"id"` + Status string `json:"status"` // PAID, PENDING, REJECTED, CANCELLED, EXPIRED, AUTHORIZED + Amount float64 `json:"amount"` + Currency string `json:"currency"` + OrderID string `json:"order_id"` + Payer struct { + Email string `json:"email"` + } `json:"payer"` +} + +type dlocalPaymentListResp struct { + Data []dlocalPaymentResp `json:"data"` +} + +// CheckDlocalPaymentByOrderID busca en la API de dLocal un pago con el order_id dado +// y devuelve (pagado bool, paymentID string, error). +func CheckDlocalPaymentByOrderID(cfg models.DlocalApi, orderID string) (bool, string, error) { + baseURL := cfg.UrlDev + accessKeyID := cfg.AccessKeyIDdev + accessKeySecret := cfg.AccessKeySecretdev + if cfg.Modo == "prod" { + baseURL = cfg.UrlProd + accessKeyID = cfg.AccessKeyID + accessKeySecret = cfg.AccessKeySecret + } + + url := fmt.Sprintf("%s/v1/payments?order_id=%s", baseURL, orderID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, "", err + } + authToken := fmt.Sprintf("Bearer %s:%s", accessKeyID, accessKeySecret) + req.Header.Set("Authorization", authToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false, "", err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return false, "", err + } + if resp.StatusCode != http.StatusOK { + return false, "", fmt.Errorf("dlocal status %d: %s", resp.StatusCode, string(body)) + } + + // La respuesta puede ser lista o un objeto directo + var list dlocalPaymentListResp + if err := json.Unmarshal(body, &list); err == nil && len(list.Data) > 0 { + for _, p := range list.Data { + if p.Status == "PAID" || p.Status == "AUTHORIZED" { + return true, p.ID, nil + } + } + return false, "", nil + } + // Si no es lista, intentar objeto directo + var single dlocalPaymentResp + if err := json.Unmarshal(body, &single); err == nil && single.ID != "" { + paid := single.Status == "PAID" || single.Status == "AUTHORIZED" + return paid, single.ID, nil + } + return false, "", nil +} diff --git a/rest/controllers/api/dlocal_controller.go b/rest/controllers/api/dlocal_controller.go index 4d5fc82..394c93e 100644 --- a/rest/controllers/api/dlocal_controller.go +++ b/rest/controllers/api/dlocal_controller.go @@ -2,6 +2,9 @@ package controllers import ( "fmt" + "log" + "strconv" + "time" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" @@ -218,3 +221,194 @@ func CreatePago(c *fiber.Ctx) error { "response": string(response), }) } + +// ─── Webhook dLocal ─────────────────────────────────────────────────────────── + +// DlocalWebhook recibe notificaciones de pago de dLocal y las registra de forma idempotente. +// dLocal espera HTTP 200; se responde inmediatamente y se procesa a continuación. +func DlocalWebhook(c *fiber.Ctx) error { + rawBody := c.Body() + + // ─── 1. Configuración activa ───────────────────────────────────────────── + cfg, err := models.GetLastActiveDlocalApi() + if err != nil { + log.Println("[DLOCAL] Webhook: sin configuración activa") + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + // ─── 2. Verificar firma HMAC-SHA256 (opcional según config) ────────────── + signature := c.Get("X-dLocal-Signature") + if signature != "" { + if !services.VerifyDlocalSignature(rawBody, signature, *cfg) { + log.Println("[DLOCAL] Webhook: firma inválida — descartado") + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + } + + // ─── 3. Parsear notificación ───────────────────────────────────────────── + notif, err := services.ParseDlocalWebhookNotification(rawBody) + if err != nil { + log.Printf("[DLOCAL] Webhook: error parseando body: %v", err) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + notificationID := notif.ID + if notificationID == "" { + log.Println("[DLOCAL] Webhook: notification_id vacío") + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + // ─── 4. Idempotencia ───────────────────────────────────────────────────── + if models.IsDlocalNotificationDuplicate(notificationID) { + log.Printf("[DLOCAL] Webhook: notificación duplicada %s", notificationID) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + // ─── 5. Extraer campos (pago directo o suscripción) ────────────────────── + paymentID := notif.Payment.ID + orderID := notif.Payment.OrderID + payerEmail := notif.Payment.Payer.Email + monto := notif.Payment.Amount + moneda := notif.Payment.Currency + tipo := notif.Type + estado := notif.Status + + // Si es suscripción, tomar amounts del invoice + if tipo == "SUBSCRIPTION_CHARGE" || tipo == "SUBSCRIPTION_PAYMENT" { + if notif.Invoice.Amount > 0 { + monto = notif.Invoice.Amount + moneda = notif.Invoice.Currency + } + if paymentID == "" { + paymentID = notif.Invoice.ID + } + if orderID == "" { + orderID = notif.Subscription.ID + } + } + + // ─── 6. Guardar log ────────────────────────────────────────────────────── + logEntry := models.DlocalPaymentLog{ + NotificationID: notificationID, + Fuente: "webhook", + Tipo: tipo, + Estado: estado, + PaymentID: paymentID, + OrderID: orderID, + Referencia: orderID, // order_id coincide con la referencia del contrato + PayerEmail: payerEmail, + Monto: monto, + Moneda: moneda, + Procesado: false, + Raw: string(rawBody), + } + if err := models.SaveDlocalPaymentLog(logEntry); err != nil { + log.Printf("[DLOCAL] Webhook: error guardando log: %v", err) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + // ─── 7. Procesar si el estado es PAID ──────────────────────────────────── + if estado != "PAID" { + log.Printf("[DLOCAL] Webhook: estado '%s' — registrado sin procesar", estado) + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) + } + + log.Printf("[DLOCAL] Webhook: PAID — payment_id=%s order=%s email=%s monto=%.2f %s", + paymentID, orderID, payerEmail, monto, moneda) + + // Intentar vincular con contrato si el order_id tiene formato "contrato-{id}" + 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) + } + } + } + + models.MarkDlocalPaymentProcessed(notificationID) + + return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true}) +} + +// ─── Registro manual de pago dLocal ────────────────────────────────────────── + +// DlocalRegistrarPago permite registrar manualmente un pago dLocal desde el backoffice +// cuando la notificación no llegó por webhook (actualización directa, confirmación manual, etc.). +func DlocalRegistrarPago(c *fiber.Ctx) error { + type body struct { + PaymentID string `json:"payment_id"` + OrderID string `json:"order_id"` + Referencia string `json:"referencia"` + 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, … + Nota string `json:"nota"` + } + var b body + if err := c.BodyParser(&b); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) + } + if b.Estado == "" { + b.Estado = "PAID" + } + if b.Tipo == "" { + b.Tipo = "manual" + } + if b.Moneda == "" { + b.Moneda = "USD" + } + + // Generar un notification_id único para entradas manuales + notifID := "manual-" + strconv.FormatInt(time.Now().UnixNano(), 36) + if b.PaymentID != "" { + notifID = "manual-" + b.PaymentID + } + + // No deduplicar entradas manuales con el mismo payment_id — pueden ser actualizaciones + // Se usa prefijo diferente por cada llamada si payment_id es el mismo + if models.IsDlocalNotificationDuplicate(notifID) { + notifID = "manual-" + strconv.FormatInt(time.Now().UnixNano(), 36) + } + + referencia := b.Referencia + if referencia == "" { + referencia = b.OrderID + } + + entry := models.DlocalPaymentLog{ + NotificationID: notifID, + Fuente: "manual", + Tipo: b.Tipo, + Estado: b.Estado, + PaymentID: b.PaymentID, + OrderID: b.OrderID, + Referencia: referencia, + PayerEmail: b.PayerEmail, + Monto: b.Monto, + Moneda: b.Moneda, + Nota: b.Nota, + Procesado: b.Estado == "PAID", + } + if err := models.SaveDlocalPaymentLog(entry); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + // Si el estado es PAID y referencia tiene formato "contrato-{id}", marcar contrato + 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) + } else { + log.Printf("[DLOCAL] Registro manual: contrato %d marcado como pagado", contratoID) + } + } + } + + return c.JSON(fiber.Map{"message": "Pago registrado", "notification_id": notifID}) +} diff --git a/rest/controllers/pasarelas_controller.go b/rest/controllers/pasarelas_controller.go index bdea522..7ea5e12 100644 --- a/rest/controllers/pasarelas_controller.go +++ b/rest/controllers/pasarelas_controller.go @@ -137,3 +137,14 @@ func BoldWebhookLogs(c *fiber.Ctx) error { } return c.JSON(fiber.Map{"data": logs}) } + +// ─── Logs dLocal ────────────────────────────────────────────────────────────── + +// DlocalPaymentLogs devuelve los últimos 100 registros de pagos de dLocal. +func DlocalPaymentLogs(c *fiber.Ctx) error { + logs, err := models.GetDlocalPaymentLogs(100) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"data": logs}) +} diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go index afc64cc..8926515 100755 --- a/rest/routes/publicas.go +++ b/rest/routes/publicas.go @@ -18,6 +18,8 @@ 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) + // dLocal requiere respuesta HTTP 200 inmediata. + web.Post("/webhooks/dlocal", apiControllers.DlocalWebhook) // ─── Página de confirmación de pago ─────────────────────────────────── web.Get("/pago-exitoso", apiControllers.PagoExitosoPage) diff --git a/rest/routes/user.go b/rest/routes/user.go index ac1787d..474a518 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -138,6 +138,8 @@ func UserRoutes(app fiber.Router) { // dLocal protected.Get("/pasarelas/dlocal/config", controllers.GetDlocalConfigAPI) protected.Post("/pasarelas/dlocal/save", controllers.SaveDlocalConfigWeb) + protected.Get("/pasarelas/dlocal/logs", controllers.DlocalPaymentLogs) + protected.Post("/pasarelas/dlocal/registro-pago", apiControllers.DlocalRegistrarPago) // Bold API (crear link, consultar estado) protected.Post("/pasarelas/bold/crear-link", apiControllers.BoldCreatePaymentLink) protected.Get("/pasarelas/bold/link/:linkID", apiControllers.BoldGetLinkStatus)