Files
soft_usite/rest/controllers/pasarelas_controller.go
T
Lizandro GuarnizoandClaude Sonnet 5 08265510ea feat(umind): mide el consumo de IA, OCR y transcripción por tenant
Es la base del cobro por uso: hasta ahora no había ninguna medición de
consumo en todo el repo.

- UmindUso registra cada evento facturable con el costo YA calculado al
  precio vigente del plan. Congelarlo evita que subir un precio revalúe
  consumo pasado, que haría indefendible una factura ante un reclamo.
- callAI devuelve los tokens que reportó el proveedor (campo usage, igual
  en todos los OpenAI-compatibles; input+output en Anthropic). Se mide
  cada ronda de tool-calling, no solo la última: todas gastan tokens.
- ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no
  medir, que es lo que pasan los botones "Probar" del panel de staff.
- Aviso al superar el tope del plan, una vez por mes y sin cortar el
  servicio. El flag de "ya avisé" es en memoria a propósito.
- GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle.
- Test del cálculo de costo por tipo, incluida fracción de 1k tokens y
  tenant sin plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:41:23 -05:00

600 lines
22 KiB
Go

package controllers
import (
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// PasarelasPage renderiza la vista unificada de pasarelas de pago.
func PasarelasPage(c *fiber.Ctx) error {
boldCfg, _ := models.GetBoldConfig()
dlocalCfg, _ := models.GetLastActiveDlocalApi()
paypalCfg, _ := models.GetPaypalConfig()
data := fiber.Map{
"Title": "Pasarelas de Pago",
"Bold": boldCfg,
"Dlocal": dlocalCfg,
"Paypal": paypalCfg,
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}
if err := c.Render("pasarelas_pago", data, "layouts/main"); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return nil
}
// ─── Bold ─────────────────────────────────────────────────────────────────────
// SaveBoldConfig guarda o actualiza la configuración de Bold.
func SaveBoldConfig(c *fiber.Ctx) error {
type body struct {
ID uint `json:"id" form:"id"`
ApiKeyProd string `json:"api_key_prod" form:"api_key_prod"`
SecretKeyProd string `json:"secret_key_prod" form:"secret_key_prod"`
ApiKeyTest string `json:"api_key_test" form:"api_key_test"`
SecretKeyTest string `json:"secret_key_test" form:"secret_key_test"`
Modo string `json:"modo" form:"modo"`
CallbackUrl string `json:"callback_url" form:"callback_url"`
Nota string `json:"nota" form:"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.Modo == "" {
b.Modo = "test"
}
cfg := models.BoldConfig{
ApiKeyProd: b.ApiKeyProd,
SecretKeyProd: b.SecretKeyProd,
ApiKeyTest: b.ApiKeyTest,
SecretKeyTest: b.SecretKeyTest,
Modo: b.Modo,
CallbackUrl: b.CallbackUrl,
Nota: b.Nota,
}
cfg.ID = b.ID
if err := models.SaveBoldConfig(cfg); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Configuración Bold guardada"})
}
// GetBoldConfig devuelve la configuración activa de Bold (para cargar el form).
func GetBoldConfigAPI(c *fiber.Ctx) error {
cfg, err := models.GetBoldConfig()
if err != nil {
return c.JSON(fiber.Map{"data": nil})
}
return c.JSON(fiber.Map{"data": cfg})
}
// ─── dLocal ───────────────────────────────────────────────────────────────────
// SaveDlocalConfig guarda o actualiza la configuración de dLocal.
func SaveDlocalConfigWeb(c *fiber.Ctx) error {
type body struct {
ID uint `json:"id" form:"id"`
AccessKeyID string `json:"access_key_id" form:"access_key_id"`
AccessKeySecret string `json:"access_key_secret" form:"access_key_secret"`
AccessKeyIDdev string `json:"access_key_id_dev" form:"access_key_id_dev"`
AccessKeySecretdev string `json:"access_key_secret_dev" form:"access_key_secret_dev"`
UrlProd string `json:"url_prod" form:"url_prod"`
UrlDev string `json:"url_dev" form:"url_dev"`
Modo string `json:"modo" form:"modo"`
}
var b body
if err := c.BodyParser(&b); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if b.Modo == "" {
b.Modo = "dev"
}
cfg := models.DlocalApi{
AccessKeyID: b.AccessKeyID,
AccessKeySecret: b.AccessKeySecret,
AccessKeyIDdev: b.AccessKeyIDdev,
AccessKeySecretdev: b.AccessKeySecretdev,
UrlProd: b.UrlProd,
UrlDev: b.UrlDev,
Modo: b.Modo,
IsActive: true,
}
cfg.ID = b.ID
// Desactivar configs previas
if cfg.ID == 0 {
if err := models.CreateDlocalApi(&cfg); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
} else {
if err := models.UpdateDlocalApi(&cfg); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
return c.JSON(fiber.Map{"message": "Configuración dLocal guardada"})
}
// GetDlocalConfigAPI devuelve la configuración activa de dLocal.
func GetDlocalConfigAPI(c *fiber.Ctx) error {
cfg, err := models.GetLastActiveDlocalApi()
if err != nil {
return c.JSON(fiber.Map{"data": nil})
}
return c.JSON(fiber.Map{"data": cfg})
}
// ─── Logs Bold ────────────────────────────────────────────────────────────────
// BoldWebhookLogs devuelve los logs de notificaciones con paginación y filtro por tipo.
// Query params: page (default 1), limit (default 25), tipo (SALE_APPROVED|SALE_REJECTED|...|TODOS)
func BoldWebhookLogs(c *fiber.Ctx) error {
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 25)
tipo := c.Query("tipo", "")
if page < 1 {
page = 1
}
logs, total, err := models.GetBoldWebhookLogsPaginated(page, limit, tipo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"data": logs,
"total": total,
"page": page,
"limit": limit,
})
}
// BoldCallbackLogs devuelve los intentos de pago (visitas al callback) con paginación y filtro.
// Query params: page (default 1), limit (default 25), estado (pendiente|pagado|fallido|revertido|TODOS)
func BoldCallbackLogs(c *fiber.Ctx) error {
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 25)
estado := c.Query("estado", "")
if page < 1 {
page = 1
}
logs, total, err := models.GetBoldCallbackLogsPaginated(page, limit, estado)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"data": logs,
"total": total,
"page": page,
"limit": limit,
})
}
// ─── Logs dLocal ──────────────────────────────────────────────────────────────
// DlocalPaymentLogs devuelve los últimos 100 registros de pagos de dLocal (legacy).
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})
}
// DlocalPaymentLogsPaginated devuelve los logs con paginación y filtro por estado.
// Query params: page (default 1), limit (default 25), estado (PAID|PENDING|REJECTED|APROBADOS|TODOS)
func DlocalPaymentLogsPaginated(c *fiber.Ctx) error {
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 25)
estado := c.Query("estado", "")
if page < 1 {
page = 1
}
logs, total, err := models.GetDlocalPaymentLogsPaginated(page, limit, estado)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"data": logs,
"total": total,
"page": page,
"limit": limit,
})
}
// ValidarDlocalLog verifica el estado de pago real de un log dLocal consultando
// todas las fuentes disponibles (dlocal_payment_log → Bold API → dLocal API).
// Si se confirma el pago, marca el contrato como pagado y envía confirmación.
// POST /app/pasarelas/dlocal/logs/:id/validar
func ValidarDlocalLog(c *fiber.Ctx) error {
logID, err := c.ParamsInt("id")
if err != nil || logID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
}
var entry models.DlocalPaymentLog
if err := models.GetDlocalPaymentLogByID(uint(logID), &entry); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Log no encontrado"})
}
ref := entry.Referencia
if ref == "" {
ref = entry.OrderID
}
var contratoID uint
if _, err := fmt.Sscanf(ref, "contrato-%d", &contratoID); err != nil || contratoID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Referencia no tiene formato contrato-{id}"})
}
contrato, err := models.GetContratoParaVerificacion(contratoID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Contrato no encontrado"})
}
payerEmail := entry.PayerEmail
if payerEmail == "" {
payerEmail = contrato.Cliente.Email
}
monto := entry.Monto
pagoConfirmado := contrato.PagoConfirmado
fuenteConfirmacion := ""
// ─── 1. Ya confirmado en DB ───────────────────────────────────────────────
if pagoConfirmado {
fuenteConfirmacion = "db"
}
// ─── 2. Mismo log si ya tiene estado PAID ────────────────────────────────
if !pagoConfirmado && (entry.Estado == "PAID" || entry.Estado == "AUTHORIZED") {
pagoConfirmado = true
fuenteConfirmacion = "dlocal_log"
}
// ─── 3. Bold API ─────────────────────────────────────────────────────────
if !pagoConfirmado && contrato.EnlacePagoLinkID != "" {
boldCfg, boldErr := models.GetBoldConfig()
if boldErr == nil {
paid, _, apiMonto, apiErr := services.CheckBoldLinkPaid(boldCfg, contrato.EnlacePagoLinkID)
if apiErr == nil && paid {
pagoConfirmado = true
fuenteConfirmacion = "bold_api"
if apiMonto > 0 {
monto = float64(apiMonto)
}
}
}
}
// ─── 4. dLocal API ───────────────────────────────────────────────────────
if !pagoConfirmado {
dlocalCfg, dlErr := models.GetLastActiveDlocalApi()
if dlErr == nil {
paid, _, apiMonto, apiEmail, apiErr := services.CheckDlocalPaymentByOrderID(*dlocalCfg, ref)
if apiErr == nil && paid {
pagoConfirmado = true
fuenteConfirmacion = "dlocal_api"
if apiMonto > 0 {
monto = apiMonto
}
if apiEmail != "" {
payerEmail = apiEmail
}
}
}
}
// Si el pago se confirmó ahora (no estaba marcado antes), actualizar contrato
if pagoConfirmado && !contrato.PagoConfirmado {
if ok, _ := models.MarcarContratoPagado(contratoID); ok {
go services.EnviarCorreoConfirmacionPago(contratoID, fuenteConfirmacion)
}
}
_ = models.UpdateDlocalPaymentLogDatos(uint(logID), payerEmail, monto)
_ = models.GetDlocalPaymentLogByID(uint(logID), &entry)
msg := "Datos actualizados"
if pagoConfirmado && !contrato.PagoConfirmado {
msg = fmt.Sprintf("Pago confirmado via %s — contrato marcado como pagado", fuenteConfirmacion)
} else if pagoConfirmado {
msg = fmt.Sprintf("Pago ya confirmado (%s)", fuenteConfirmacion)
} else {
msg = "Pago aún no confirmado — datos actualizados"
}
return c.JSON(fiber.Map{
"ok": true,
"confirmado": pagoConfirmado,
"fuente": fuenteConfirmacion,
"mensaje": msg,
"data": entry,
})
}
// ─── Validación de intento de pago (callback) ───────────────────────────────
// ValidarBoldCallback verifica si el pago de un intento pendiente realmente se realizó.
// Recorre las mismas fuentes que verificarPago() y, si confirma, marca el contrato + actualiza el callback.
// POST /app/pasarelas/bold/callbacks/:id/validar
func ValidarBoldCallback(c *fiber.Ctx) error {
cbID, err := c.ParamsInt("id")
if err != nil || cbID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
}
var cb models.BoldCallbackLog
if err := models.GetBoldCallbackLogByID(uint(cbID), &cb); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Intento no encontrado"})
}
var contratoID uint
if _, err := fmt.Sscanf(cb.Referencia, "contrato-%d", &contratoID); err != nil || contratoID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Referencia no tiene formato contrato-{id}"})
}
contrato, err := models.GetContratoParaVerificacion(contratoID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Contrato no encontrado"})
}
pagoConfirmado := contrato.PagoConfirmado
fuente := ""
// ─── 1. Ya confirmado en DB ───────────────────────────────────────────────
if pagoConfirmado {
fuente = "db"
}
// ─── 2. dlocal_payment_log ───────────────────────────────────────────────
if !pagoConfirmado {
if dlLogs, dlErr := models.GetDlocalPaymentLogsByRef(cb.Referencia); dlErr == nil {
for _, l := range dlLogs {
if l.Estado == "PAID" || l.Estado == "AUTHORIZED" {
pagoConfirmado = true
fuente = "dlocal_log"
break
}
}
}
}
// ─── 3. Bold API ─────────────────────────────────────────────────────────
if !pagoConfirmado && contrato.EnlacePagoLinkID != "" {
if boldCfg, boldErr := models.GetBoldConfig(); boldErr == nil {
if paid, _, _, apiErr := services.CheckBoldLinkPaid(boldCfg, contrato.EnlacePagoLinkID); apiErr == nil && paid {
pagoConfirmado = true
fuente = "bold_api"
}
}
}
// ─── 4. dLocal API ───────────────────────────────────────────────────────
if !pagoConfirmado {
if dlocalCfg, dlErr := models.GetLastActiveDlocalApi(); dlErr == nil {
if paid, _, _, _, apiErr := services.CheckDlocalPaymentByOrderID(*dlocalCfg, cb.Referencia); apiErr == nil && paid {
pagoConfirmado = true
fuente = "dlocal_api"
}
}
}
// Actualizar contrato si se confirmó ahora
if pagoConfirmado && !contrato.PagoConfirmado {
if ok, _ := models.MarcarContratoPagado(contratoID); ok {
go services.EnviarCorreoConfirmacionPago(contratoID, fuente)
}
}
// Actualizar estado del callback
nuevoEstado := cb.Estado
if pagoConfirmado && cb.Estado == "pendiente" {
nuevoEstado = "pagado"
_ = models.UpdateBoldCallbackEstadoByID(uint(cbID), "pagado")
cb.Estado = "pagado"
}
msg := "Pago aún no confirmado"
if pagoConfirmado && !contrato.PagoConfirmado {
msg = fmt.Sprintf("Pago confirmado via %s — contrato marcado como pagado", fuente)
} else if pagoConfirmado {
msg = fmt.Sprintf("Pago ya confirmado (%s)", fuente)
}
_ = nuevoEstado
return c.JSON(fiber.Map{
"ok": true,
"confirmado": pagoConfirmado,
"fuente": fuente,
"mensaje": msg,
"data": cb,
})
}
// ─── Validación de log API_CHECK ─────────────────────────────────────────────
// ValidarBoldLog verifica el estado de pago real de un log API_CHECK consultando
// todas las fuentes disponibles (dlocal_payment_log → Bold API → dLocal API).
// Si se confirma el pago, marca el contrato como pagado y envía el email de confirmación.
// También rellena email y monto faltantes. Funciona para cualquier tipo de log Bold.
// POST /app/pasarelas/bold/logs/:id/validar
func ValidarBoldLog(c *fiber.Ctx) error {
logID, err := c.ParamsInt("id")
if err != nil || logID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ID inválido"})
}
// Cargar el registro existente
var entry models.BoldWebhookLog
if err := models.GetBoldWebhookLogByID(uint(logID), &entry); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Log no encontrado"})
}
// Parsear contrato ID de la referencia (contrato-{id})
var contratoID uint
if _, err := fmt.Sscanf(entry.Referencia, "contrato-%d", &contratoID); err != nil || contratoID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Referencia no tiene formato contrato-{id}"})
}
// Cargar contrato con cliente
contrato, err := models.GetContratoParaVerificacion(contratoID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Contrato no encontrado"})
}
// Valores iniciales: reutilizar lo que ya hay en el log, fallback al cliente
payerEmail := entry.PayerEmail
if payerEmail == "" {
payerEmail = contrato.Cliente.Email
}
monto := entry.Monto
pagoConfirmado := contrato.PagoConfirmado
fuenteConfirmacion := ""
// ─── 1. Ya confirmado en DB ───────────────────────────────────────────────
if pagoConfirmado {
fuenteConfirmacion = "db"
}
// ─── 2. dlocal_payment_log ────────────────────────────────────────────────
if !pagoConfirmado {
dlocalLogs, dlErr := models.GetDlocalPaymentLogsByRef(entry.Referencia)
if dlErr == nil {
for _, l := range dlocalLogs {
if l.Estado == "PAID" || l.Estado == "AUTHORIZED" {
pagoConfirmado = true
fuenteConfirmacion = "dlocal_log"
if l.PayerEmail != "" {
payerEmail = l.PayerEmail
}
if l.Monto > 0 {
monto = int64(l.Monto)
}
break
}
}
}
}
// ─── 3. Bold API ─────────────────────────────────────────────────────────
if !pagoConfirmado && contrato.EnlacePagoLinkID != "" {
boldCfg, boldErr := models.GetBoldConfig()
if boldErr == nil {
paid, _, apiMonto, apiErr := services.CheckBoldLinkPaid(boldCfg, contrato.EnlacePagoLinkID)
if apiErr == nil && paid {
pagoConfirmado = true
fuenteConfirmacion = "bold_api"
if apiMonto > 0 {
monto = apiMonto
}
}
}
}
// ─── 4. dLocal API ───────────────────────────────────────────────────────
if !pagoConfirmado {
dlocalCfg, dlErr := models.GetLastActiveDlocalApi()
if dlErr == nil {
paid, _, apiMonto, apiEmail, apiErr := services.CheckDlocalPaymentByOrderID(*dlocalCfg, entry.Referencia)
if apiErr == nil && paid {
pagoConfirmado = true
fuenteConfirmacion = "dlocal_api"
if apiMonto > 0 {
monto = int64(apiMonto)
}
if apiEmail != "" {
payerEmail = apiEmail
}
}
}
}
// Si el pago se confirmó ahora (no estaba marcado antes), actualizar contrato
if pagoConfirmado && !contrato.PagoConfirmado {
if ok, _ := models.MarcarContratoPagado(contratoID); ok {
go services.EnviarCorreoConfirmacionPago(contratoID, fuenteConfirmacion)
}
}
// Actualizar log con los mejores datos disponibles
_ = models.UpdateBoldWebhookLogDatos(uint(logID), payerEmail, monto)
// Devolver el log actualizado
_ = models.GetBoldWebhookLogByID(uint(logID), &entry)
msg := "Datos actualizados"
if pagoConfirmado && !contrato.PagoConfirmado {
msg = fmt.Sprintf("Pago confirmado via %s — contrato marcado como pagado", fuenteConfirmacion)
} else if pagoConfirmado {
msg = fmt.Sprintf("Pago ya confirmado (%s)", fuenteConfirmacion)
} else {
msg = "Pago aún no confirmado — datos del contrato actualizados"
}
return c.JSON(fiber.Map{
"ok": true,
"confirmado": pagoConfirmado,
"fuente": fuenteConfirmacion,
"mensaje": msg,
"data": entry,
})
}
// ─── PayPal ───────────────────────────────────────────────────────────────────
// GetPaypalConfigAPI devuelve la configuración activa de PayPal.
func GetPaypalConfigAPI(c *fiber.Ctx) error {
cfg, err := models.GetPaypalConfig()
if err != nil {
return c.JSON(fiber.Map{"data": nil})
}
return c.JSON(fiber.Map{"data": cfg})
}
// SavePaypalConfigWeb guarda o actualiza la configuración de PayPal.
func SavePaypalConfigWeb(c *fiber.Ctx) error {
type body struct {
ID uint `json:"id" form:"id"`
ClientIDSandbox string `json:"client_id_sandbox" form:"client_id_sandbox"`
ClientSecretSandbox string `json:"client_secret_sandbox" form:"client_secret_sandbox"`
ClientIDProd string `json:"client_id_prod" form:"client_id_prod"`
ClientSecretProd string `json:"client_secret_prod" form:"client_secret_prod"`
Modo string `json:"modo" form:"modo"`
WebhookID string `json:"webhook_id" form:"webhook_id"`
ReturnURL string `json:"return_url" form:"return_url"`
CancelURL string `json:"cancel_url" form:"cancel_url"`
Nota string `json:"nota" form:"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.Modo == "" {
b.Modo = "sandbox"
}
cfg := models.PaypalConfig{
ClientIDSandbox: b.ClientIDSandbox,
ClientSecretSandbox: b.ClientSecretSandbox,
ClientIDProd: b.ClientIDProd,
ClientSecretProd: b.ClientSecretProd,
Modo: b.Modo,
WebhookID: b.WebhookID,
ReturnURL: b.ReturnURL,
CancelURL: b.CancelURL,
Nota: b.Nota,
}
cfg.ID = b.ID
if err := models.SavePaypalConfig(cfg); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Configuración PayPal guardada"})
}