Files
soft_usite/pkg/services/dlocal_service.go
T
2026-05-12 20:41:50 -05:00

665 lines
20 KiB
Go

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 int `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{}
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
}