This commit is contained in:
Lizandro Guarnizo
2026-05-01 17:36:17 -05:00
parent 52c3d7ebe2
commit c9024274f0
10 changed files with 437 additions and 0 deletions
+12
View File
@@ -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
}
+53
View File
@@ -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
}
+30
View File
@@ -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
}
+131
View File
@@ -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
}