package services import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "io/ioutil" "net/http" "time" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "gorm.io/gorm" ) // Modelo que refleja la tabla en la base de datos type DlocalApi struct { ID uint `gorm:"primaryKey"` Modo string AccessKeyID string AccessKeySecret string AccessKeyIDdev string AccessKeySecretdev string UrlProd string UrlDev string } // Estructura de configuración para usar en la llamada type dlocalConfig struct { Modo string AccessKeyID string AccessKeySecret string AccessKeyIDdev string AccessKeySecretdev string UrlProd string UrlDev string } // Obtiene la configuración desde la base de datos func GetDlocalConfigFromDB(db *gorm.DB) (dlocalConfig, error) { var api DlocalApi if err := db.First(&api).Error; err != nil { return dlocalConfig{}, err } return dlocalConfig{ Modo: api.Modo, AccessKeyID: api.AccessKeyID, AccessKeySecret: api.AccessKeySecret, AccessKeyIDdev: api.AccessKeyIDdev, AccessKeySecretdev: api.AccessKeySecretdev, UrlProd: api.UrlProd, UrlDev: api.UrlDev, }, nil } type PlanRequest struct { Name string `json:"name"` Description string `json:"description"` Country string `json:"country,omitempty"` Currency string `json:"currency"` Amount float64 `json:"amount"` FrequencyType string `json:"frequency_type"` FrequencyValue int `json:"frequency_value,omitempty"` DayOfMonth int `json:"day_of_month,omitempty"` NotificationURL string `json:"notification_url,omitempty"` SuccessURL string `json:"success_url"` BackURL string `json:"back_url"` ErrorURL string `json:"error_url"` } type PlanUpdated struct { Name string `json:"name"` // Plan name Description string `json:"description"` // Plan description Amount float64 `json:"amount"` // Plan amount NotificationURL string `json:"notification_url"` // Webhook notification URL BackURL string `json:"back_url"` // URL to return client to merchant site SuccessURL string `json:"success_url"` // Redirect URL on payment success ErrorURL string `json:"error_url"` // Redirect URL on payment error } type PagoRequest struct { Currency string `json:"currency"` Amount float64 `json:"amount"` // OrderID es la referencia con la que después se reconcilia el pago. // Debe ir en formato "contrato-{id}": es lo que espera el webhook // (dlocal_controller.go) y la verificación por API para vincular el pago con // su contrato. Antes se enviaba el ID numérico y el pago nunca se asociaba. OrderID string `json:"order_id"` Description string `json:"description"` SuccessURL string `json:"success_url"` BackURL string `json:"back_url"` NotificationURL string `json:"notification_url"` } // Llama al endpoint /v1/me de DLocal usando la configuración func GetDLocalMe(cfg dlocalConfig) ([]byte, error) { // Determinar URL base según el modo baseURL := cfg.UrlDev AccessKeyID := cfg.AccessKeyIDdev AccessKeySecret := cfg.AccessKeySecretdev if cfg.Modo == "prod" { baseURL = cfg.UrlProd AccessKeyID = cfg.AccessKeyID AccessKeySecret = cfg.AccessKeySecret } // Construir token de autorización authToken := fmt.Sprintf("Bearer %s:%s", AccessKeyID, AccessKeySecret) // Crear la solicitud HTTP req, err := http.NewRequest("GET", baseURL+"/v1/me", bytes.NewBuffer([]byte("{}"))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", authToken) // Ejecutar la solicitud client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // Leer la respuesta body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } return body, nil } // Función para crear el plan en DLocal func CreatePlan(cfg models.DlocalApi, plan models.PlanRequest) ([]byte, error) { // Determinar la URL base según el modo baseURL := cfg.UrlDev AccessKeyID := cfg.AccessKeyIDdev AccessKeySecret := cfg.AccessKeySecretdev if cfg.Modo == "prod" { baseURL = cfg.UrlProd AccessKeyID = cfg.AccessKeyID AccessKeySecret = cfg.AccessKeySecret } // URL completa para el endpoint url := baseURL + "/v1/subscription/plan" // Construir el token de autorización authToken := fmt.Sprintf("Bearer %s:%s", AccessKeyID, AccessKeySecret) // Convertir el cuerpo de la solicitud a JSON requestBody, err := json.Marshal(plan) if err != nil { return nil, fmt.Errorf("error al convertir el cuerpo de la solicitud a JSON: %v", err) } // Crear la solicitud HTTP req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("error al crear la solicitud: %v", err) } // Agregar los encabezados a la solicitud req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", authToken) // Configurar el cliente HTTP client := &http.Client{ Timeout: 30 * time.Second, } // Ejecutar la solicitud resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error al enviar la solicitud: %v", err) } defer resp.Body.Close() // Leer la respuesta responseBody, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error al leer la respuesta: %v", err) } // Si la respuesta no es exitosa, devolver un error if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("error en la respuesta de DLocal: %s", string(responseBody)) } // Devolver la respuesta exitosa return responseBody, nil } func SeePlan(cfg models.DlocalApi, planID string) ([]byte, error) { // Determinar la URL base según el modo baseURL := cfg.UrlDev accessKeyID := cfg.AccessKeyIDdev accessKeySecret := cfg.AccessKeySecretdev if cfg.Modo == "prod" { baseURL = cfg.UrlProd accessKeyID = cfg.AccessKeyID accessKeySecret = cfg.AccessKeySecret } // Validar planID (opcional: depende de tus requisitos) if planID == "" { return nil, fmt.Errorf("planID no puede estar vacío") } // Construir la URL completa para el endpoint url := fmt.Sprintf("%s/v1/subscription/plan/%s", baseURL, planID) // Construir el token de autorización authToken := fmt.Sprintf("Bearer %s:%s", accessKeyID, accessKeySecret) // Crear la solicitud HTTP req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("error al crear la solicitud: %v", err) } // Agregar los encabezados a la solicitud req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", authToken) // Configurar el cliente HTTP client := &http.Client{ Timeout: 30 * time.Second, } // Ejecutar la solicitud resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error al enviar la solicitud: %v", err) } defer resp.Body.Close() // Leer la respuesta responseBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error al leer la respuesta: %v", err) } // Si la respuesta no es exitosa, devolver un error if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("error en la respuesta de DLocal (%d): %s", resp.StatusCode, string(responseBody)) } // Devolver la respuesta exitosa return responseBody, nil } func UpdatedPlan(cfg models.DlocalApi, plan PlanUpdated, planID string) ([]byte, error) { // Determinar la URL base según el modo baseURL := cfg.UrlDev accessKeyID := cfg.AccessKeyIDdev accessKeySecret := cfg.AccessKeySecretdev if cfg.Modo == "prod" { baseURL = cfg.UrlProd accessKeyID = cfg.AccessKeyID accessKeySecret = cfg.AccessKeySecret } // Construir la URL completa para el endpoint url := fmt.Sprintf("%s/v1/subscription/plan/%s", baseURL, planID) // Construir el token de autorización authToken := fmt.Sprintf("Bearer %s:%s", accessKeyID, accessKeySecret) // Convertir el cuerpo de la solicitud a JSON requestBody, err := json.Marshal(plan) if err != nil { return nil, fmt.Errorf("error al convertir el cuerpo de la solicitud a JSON: %v", err) } // Crear la solicitud HTTP req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("error al crear la solicitud: %v", err) } // Agregar los encabezados a la solicitud req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", authToken) // Configurar el cliente HTTP client := &http.Client{ Timeout: 30 * time.Second, } // Ejecutar la solicitud resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error al enviar la solicitud: %v", err) } defer resp.Body.Close() // Leer la respuesta responseBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error al leer la respuesta: %v", err) } // Si la respuesta no es exitosa, devolver un error if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("error en la respuesta de DLocal (%d): %s", resp.StatusCode, string(responseBody)) } // Devolver la respuesta exitosa return responseBody, nil } func DeactivatePlan(cfg models.DlocalApi, planID string, subscriptionId string) ([]byte, error) { // Determinar la URL base según el modo baseURL := cfg.UrlDev accessKeyID := cfg.AccessKeyIDdev accessKeySecret := cfg.AccessKeySecretdev if cfg.Modo == "prod" { baseURL = cfg.UrlProd accessKeyID = cfg.AccessKeyID accessKeySecret = cfg.AccessKeySecret } // Construir la URL completa para el endpoint url := fmt.Sprintf("%s/v1/subscription/plan/%s/subscription/%s/deactivate", baseURL, planID, subscriptionId) // Construir el token de autorización authToken := fmt.Sprintf("Bearer %s:%s", accessKeyID, accessKeySecret) // Crear la solicitud HTTP req, err := http.NewRequest("PATCH", url, nil) if err != nil { return nil, fmt.Errorf("error al crear la solicitud: %v", err) } // Agregar los encabezados a la solicitud req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", authToken) // Configurar el cliente HTTP client := &http.Client{ Timeout: 30 * time.Second, } // Ejecutar la solicitud resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error al enviar la solicitud: %v", err) } defer resp.Body.Close() // Leer la respuesta responseBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error al leer la respuesta: %v", err) } // Si la respuesta no es exitosa, devolver un error if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("error en la respuesta de DLocal (%d): %s", resp.StatusCode, string(responseBody)) } // Devolver la respuesta exitosa return responseBody, nil } func SeePlanes(cfg models.DlocalApi) ([]byte, error) { // Validar modo y establecer valores var baseURL, accessKeyID, accessKeySecret string switch cfg.Modo { case "prod": baseURL = cfg.UrlProd accessKeyID = cfg.AccessKeyID accessKeySecret = cfg.AccessKeySecret default: baseURL = cfg.UrlDev accessKeyID = cfg.AccessKeyIDdev accessKeySecret = cfg.AccessKeySecretdev } if baseURL == "" || accessKeyID == "" || accessKeySecret == "" { return nil, fmt.Errorf("configuración incompleta para el entorno %q", cfg.Modo) } // Construir la URL del endpoint url := fmt.Sprintf("%s/v1/subscription/plan/all", baseURL) // Crear la solicitud HTTP req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("error al crear la solicitud: %w", err) } // Establecer encabezados req.Header.Set("Content-Type", "application/json") // En DLocal se usa generalmente Basic Auth codificado en base64, pero si realmente usan Bearer personalizado: authToken := fmt.Sprintf("Bearer %s:%s", accessKeyID, accessKeySecret) req.Header.Set("Authorization", authToken) // Cliente HTTP con timeout client := &http.Client{Timeout: 30 * time.Second} // Ejecutar solicitud resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error al enviar la solicitud: %w", err) } defer resp.Body.Close() // Leer respuesta body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error al leer la respuesta: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("respuesta no exitosa (%d): %s", resp.StatusCode, string(body)) } return body, nil } func SeeSubscription(cfg models.DlocalApi, subscriptionId string, invoiceId string) ([]byte, error) { // Determinar la URL base según el modo baseURL := cfg.UrlDev accessKeyID := cfg.AccessKeyIDdev accessKeySecret := cfg.AccessKeySecretdev if cfg.Modo == "prod" { baseURL = cfg.UrlProd accessKeyID = cfg.AccessKeyID accessKeySecret = cfg.AccessKeySecret } // Construir la URL completa para el endpoint url := fmt.Sprintf("%s/v1/subscription/%s/execution/%s", baseURL, subscriptionId, invoiceId) // Crear la solicitud HTTP req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("error al crear la solicitud: %w", err) } // Establecer encabezados req.Header.Set("Content-Type", "application/json") // En DLocal se usa generalmente Basic Auth codificado en base64, pero si realmente usan Bearer personalizado: authToken := fmt.Sprintf("Bearer %s:%s", accessKeyID, accessKeySecret) req.Header.Set("Authorization", authToken) // Cliente HTTP con timeout client := &http.Client{Timeout: 30 * time.Second} // Ejecutar solicitud resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error al enviar la solicitud: %w", err) } defer resp.Body.Close() // Leer respuesta body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error al leer la respuesta: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("respuesta no exitosa (%d): %s", resp.StatusCode, string(body)) } return body, nil } func CreatePago(cfg models.DlocalApi, pago PagoRequest) ([]byte, error) { // Determinar la URL base y credenciales baseURL := cfg.UrlDev AccessKeyID := cfg.AccessKeyIDdev AccessKeySecret := cfg.AccessKeySecretdev if cfg.Modo == "prod" { baseURL = cfg.UrlProd AccessKeyID = cfg.AccessKeyID AccessKeySecret = cfg.AccessKeySecret } // URL del endpoint de pagos url := baseURL + "/v1/payments" // Token de autenticación authToken := fmt.Sprintf("Bearer %s:%s", AccessKeyID, AccessKeySecret) // Convertir el cuerpo de la solicitud a JSON requestBody, err := json.Marshal(pago) if err != nil { return nil, fmt.Errorf("error al convertir el cuerpo de la solicitud a JSON: %v", err) } // Crear la solicitud HTTP req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("error al crear la solicitud: %v", err) } // Encabezados req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", authToken) // Cliente HTTP con timeout client := &http.Client{ Timeout: 30 * time.Second, } // Ejecutar la solicitud resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error al enviar la solicitud: %v", err) } defer resp.Body.Close() // Leer la respuesta responseBody, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error al leer la respuesta: %v", err) } // Verificar si la respuesta fue exitosa if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("error en la respuesta de DLocal: %s", string(responseBody)) } 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, monto float64, payerEmail string, error). func CheckDlocalPaymentByOrderID(cfg models.DlocalApi, orderID string) (bool, string, float64, 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, "", 0, "", 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, "", 0, "", err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return false, "", 0, "", err } if resp.StatusCode != http.StatusOK { return false, "", 0, "", 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, p.Amount, p.Payer.Email, nil } } return false, "", 0, "", 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, single.Amount, single.Payer.Email, nil } return false, "", 0, "", nil }