Agrega API de pagos externos: tokens por servicio, atados a pasarela e IP
Permite emitir credenciales (token hasheado + IP/CIDR opcional) desde
/app/pagos-externos para que aplicaciones de terceros pidan cobros a
través de Bold/dLocal/PayPal sin acceso a nada más del sistema:
- POST /api/v1/pagos-externos/solicitar genera el link de cobro real
usando solo las pasarelas habilitadas para ese servicio.
- Los webhooks existentes de Bold/dLocal/PayPal (firma obligatoria,
idempotentes) ahora también resuelven referencias "extpay-…" sin
tocar el flujo de contratos ("contrato-{id}").
- Al confirmarse el pago se notifica por webhook firmado (HMAC) y/o
Telegram, configurable por servicio.
- CRUD de servicios protegido con SoloAdmin; token y callback_secret
solo se muestran una vez, en DB se guardan hasheados.
This commit is contained in:
@@ -117,7 +117,13 @@ func BoldWebhook(c *fiber.Ctx) error {
|
||||
log.Printf("[BOLD] Webhook: SALE_APPROVED — payment_id=%s referencia=%s email=%s monto=%d",
|
||||
paymentID, referencia, payerEmail, monto)
|
||||
|
||||
// ─── 7. Marcar contrato como pagado y limpiar enlace ────────────────────
|
||||
// ─── 7. Intentar vincular con una solicitud de pago externo ─────────────
|
||||
if services.ConfirmarPagoExternoPorReferencia(referencia, paymentID, "bold") {
|
||||
models.MarkBoldWebhookProcessed(notificationID)
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// ─── 8. Marcar contrato como pagado y limpiar enlace ────────────────────
|
||||
// La referencia tiene formato "contrato-{id}"
|
||||
if referencia != "" {
|
||||
var contratoID uint
|
||||
|
||||
@@ -320,6 +320,12 @@ func DlocalWebhook(c *fiber.Ctx) error {
|
||||
log.Printf("[DLOCAL] Webhook: PAID — payment_id=%s order=%s email=%s monto=%.2f %s",
|
||||
paymentID, orderID, payerEmail, monto, moneda)
|
||||
|
||||
// Intentar vincular con una solicitud de pago externo (referencia "extpay-…")
|
||||
if services.ConfirmarPagoExternoPorReferencia(orderID, paymentID, "dlocal") {
|
||||
models.MarkDlocalPaymentProcessed(notificationID)
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// Intentar vincular con contrato si el order_id tiene formato "contrato-{id}"
|
||||
if orderID != "" {
|
||||
var contratoID uint
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
func servicioPagoDeContexto(c *fiber.Ctx) (*models.ServicioPagoExterno, error) {
|
||||
servicio, ok := c.Locals("servicio_pago").(*models.ServicioPagoExterno)
|
||||
if !ok || servicio == nil {
|
||||
return nil, fiber.NewError(fiber.StatusUnauthorized, "no autenticado")
|
||||
}
|
||||
return servicio, nil
|
||||
}
|
||||
|
||||
// PasarelasDisponiblesHandler indica qué pasarelas puede usar el token actual.
|
||||
// Ruta: GET /api/v1/pagos-externos/pasarelas
|
||||
func PasarelasDisponiblesHandler(c *fiber.Ctx) error {
|
||||
servicio, err := servicioPagoDeContexto(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"pasarelas": servicio.PasarelasList()})
|
||||
}
|
||||
|
||||
// SolicitarPagoExternoHandler crea un cobro nuevo con el token autenticado.
|
||||
// Ruta: POST /api/v1/pagos-externos/solicitar
|
||||
func SolicitarPagoExternoHandler(c *fiber.Ctx) error {
|
||||
servicio, err := servicioPagoDeContexto(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ReferenciaExterna string `json:"referencia_externa"`
|
||||
Pasarela string `json:"pasarela"`
|
||||
Monto float64 `json:"monto"`
|
||||
Moneda string `json:"moneda"`
|
||||
Descripcion string `json:"descripcion"`
|
||||
ClienteID *uint `json:"cliente_id"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "body inválido"})
|
||||
}
|
||||
if strings.TrimSpace(req.ReferenciaExterna) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "referencia_externa es requerida"})
|
||||
}
|
||||
|
||||
solicitud, err := services.SolicitarPagoExterno(
|
||||
servicio,
|
||||
strings.TrimSpace(req.ReferenciaExterna),
|
||||
strings.ToLower(strings.TrimSpace(req.Pasarela)),
|
||||
strings.TrimSpace(req.Descripcion),
|
||||
strings.ToUpper(strings.TrimSpace(req.Moneda)),
|
||||
req.Monto,
|
||||
req.ClienteID,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"referencia_externa": solicitud.ReferenciaExterna,
|
||||
"referencia_interna": solicitud.ReferenciaInterna,
|
||||
"enlace_pago": solicitud.EnlacePago,
|
||||
"pasarela": solicitud.Pasarela,
|
||||
"estado": solicitud.Estado,
|
||||
"monto": solicitud.Monto,
|
||||
"moneda": solicitud.Moneda,
|
||||
})
|
||||
}
|
||||
|
||||
// EstadoPagoExternoHandler consulta el estado de un cobro. Solo devuelve datos
|
||||
// si la solicitud pertenece al mismo servicio autenticado, para que un token
|
||||
// no pueda enumerar/consultar solicitudes de otro cliente.
|
||||
// Ruta: GET /api/v1/pagos-externos/:referencia/estado
|
||||
func EstadoPagoExternoHandler(c *fiber.Ctx) error {
|
||||
servicio, err := servicioPagoDeContexto(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
|
||||
}
|
||||
referencia := c.Params("referencia")
|
||||
|
||||
solicitud, err := models.GetSolicitudPagoExternaByReferenciaInterna(referencia)
|
||||
if err != nil || solicitud.ServicioID != servicio.ID {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "solicitud no encontrada"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"referencia_externa": solicitud.ReferenciaExterna,
|
||||
"referencia_interna": solicitud.ReferenciaInterna,
|
||||
"estado": solicitud.Estado,
|
||||
"pasarela": solicitud.Pasarela,
|
||||
"monto": solicitud.Monto,
|
||||
"moneda": solicitud.Moneda,
|
||||
"fecha_pago": solicitud.FechaPago,
|
||||
})
|
||||
}
|
||||
@@ -74,17 +74,21 @@ func PaypalWebhook(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
marcarContratoPagadoPorReferencia(evento.Referencia, "paypal")
|
||||
marcarContratoPagadoPorReferencia(evento.Referencia, evento.ResourceID, "paypal")
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
// marcarContratoPagadoPorReferencia confirma el pago del contrato codificado en
|
||||
// una referencia con formato "contrato-{id}".
|
||||
func marcarContratoPagadoPorReferencia(referencia, pasarela string) {
|
||||
// una referencia con formato "contrato-{id}", o de una solicitud de pago
|
||||
// externo con formato "extpay-…".
|
||||
func marcarContratoPagadoPorReferencia(referencia, transaccionID, pasarela string) {
|
||||
if referencia == "" {
|
||||
log.Printf("[%s] Pago confirmado pero sin referencia de contrato: requiere conciliación manual", pasarela)
|
||||
return
|
||||
}
|
||||
if services.ConfirmarPagoExternoPorReferencia(referencia, transaccionID, pasarela) {
|
||||
return
|
||||
}
|
||||
var contratoID uint
|
||||
if _, err := fmt.Sscanf(referencia, "contrato-%d", &contratoID); err != nil || contratoID == 0 {
|
||||
log.Printf("[%s] Referencia '%s' no corresponde a un contrato", pasarela, referencia)
|
||||
|
||||
Reference in New Issue
Block a user