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>
212 lines
6.1 KiB
Go
212 lines
6.1 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
// DispatchPayload contiene las variables disponibles en el PayloadTemplate.
|
|
type DispatchPayload struct {
|
|
ContratoID uint
|
|
Referencia string
|
|
Email string
|
|
Monto float64
|
|
Moneda string
|
|
SaasID uint
|
|
SaasSlug string
|
|
Fuente string // dlocal | bold | manual
|
|
WebhookToken string // token único de la integración (para incluirlo en el payload saliente)
|
|
}
|
|
|
|
// DispatchSaasPaymentNotification notifica a todos los SaaS externos activos
|
|
// asociados al contrato. Se ejecuta en goroutine separada desde los webhooks.
|
|
//
|
|
// Cadena de vinculación:
|
|
//
|
|
// Contrato → contrato_servicios (m2m) → servicios.id
|
|
// servicios.id ↔ saas_productos.servicio_id → saas_productos.id
|
|
// saas_productos.id → saas_api_configs.saas_id (activo = true)
|
|
func DispatchSaasPaymentNotification(contratoID uint, payerEmail, fuente string, monto float64, moneda string) {
|
|
referencia := fmt.Sprintf("contrato-%d", contratoID)
|
|
|
|
// 1. Cargar contrato con sus servicios
|
|
contrato, err := models.GetContratoByID(contratoID)
|
|
if err != nil {
|
|
log.Printf("[DISPATCH] Error cargando contrato %d: %v", contratoID, err)
|
|
return
|
|
}
|
|
if len(contrato.Servicios) == 0 {
|
|
log.Printf("[DISPATCH] Contrato %d sin servicios — nada que despachar", contratoID)
|
|
return
|
|
}
|
|
|
|
// 2. Recopilar IDs de servicios del contrato
|
|
servicioIDs := make([]uint, len(contrato.Servicios))
|
|
for i, s := range contrato.Servicios {
|
|
servicioIDs[i] = s.ID
|
|
}
|
|
|
|
// 3. Buscar SaasProductos cuyo servicio_id esté en los servicios del contrato
|
|
var saasProductos []models.SaasProducto
|
|
if err := app.Http.Database.DB.
|
|
Where("servicio_id IN ? AND activo = ?", servicioIDs, true).
|
|
Find(&saasProductos).Error; err != nil {
|
|
log.Printf("[DISPATCH] Error buscando SaasProductos para contrato %d: %v", contratoID, err)
|
|
return
|
|
}
|
|
if len(saasProductos) == 0 {
|
|
log.Printf("[DISPATCH] Contrato %d: ningún SaasProducto vinculado a sus servicios", contratoID)
|
|
return
|
|
}
|
|
|
|
// 4. Recopilar IDs de SaaS
|
|
saasIDs := make([]uint, len(saasProductos))
|
|
saasMap := make(map[uint]models.SaasProducto, len(saasProductos))
|
|
for i, sp := range saasProductos {
|
|
saasIDs[i] = sp.ID
|
|
saasMap[sp.ID] = sp
|
|
}
|
|
|
|
// 5. Obtener configuraciones API activas para esos SaaS, filtrando por pasarela
|
|
configs, err := models.GetSaasApiConfigsBySaasIDs(saasIDs, fuente) // fuente = "dlocal" | "bold"
|
|
if err != nil {
|
|
log.Printf("[DISPATCH] Error obteniendo SaasApiConfigs: %v", err)
|
|
return
|
|
}
|
|
if len(configs) == 0 {
|
|
log.Printf("[DISPATCH] Contrato %d: ninguna SaasApiConfig activa encontrada", contratoID)
|
|
return
|
|
}
|
|
|
|
// 6. Enviar notificación a cada endpoint
|
|
for _, cfg := range configs {
|
|
saas := saasMap[cfg.SaasID]
|
|
payload := DispatchPayload{
|
|
ContratoID: contratoID,
|
|
Referencia: referencia,
|
|
Email: payerEmail,
|
|
Monto: monto,
|
|
Moneda: moneda,
|
|
SaasID: saas.ID,
|
|
SaasSlug: saas.Slug,
|
|
Fuente: fuente,
|
|
WebhookToken: cfg.WebhookToken,
|
|
}
|
|
dispatchOne(cfg, payload)
|
|
}
|
|
}
|
|
|
|
// dispatchOne realiza la llamada HTTP a un endpoint e inserta el log correspondiente.
|
|
func dispatchOne(cfg models.SaasApiConfig, payload DispatchPayload) {
|
|
logEntry := models.SaasDispatchLog{
|
|
SaasApiConfigID: cfg.ID,
|
|
ContratoID: payload.ContratoID,
|
|
Referencia: payload.Referencia,
|
|
PayerEmail: payload.Email,
|
|
Fuente: payload.Fuente,
|
|
Intentos: 1,
|
|
}
|
|
|
|
// Renderizar payload template
|
|
body, err := renderTemplate(cfg.PayloadTemplate, payload)
|
|
if err != nil {
|
|
logEntry.Estado = "failed"
|
|
logEntry.ErrorMsg = fmt.Sprintf("template error: %v", err)
|
|
_ = models.SaveSaasDispatchLog(&logEntry)
|
|
log.Printf("[DISPATCH] Config %d — error de template: %v", cfg.ID, err)
|
|
return
|
|
}
|
|
|
|
// Construir request
|
|
metodo := strings.ToUpper(cfg.Metodo)
|
|
if metodo == "" {
|
|
metodo = "POST"
|
|
}
|
|
|
|
timeout := cfg.TimeoutSeg
|
|
if timeout <= 0 {
|
|
timeout = 10
|
|
}
|
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
|
|
|
var reqBody io.Reader
|
|
if metodo != "GET" {
|
|
reqBody = bytes.NewBufferString(body)
|
|
}
|
|
|
|
req, err := http.NewRequest(metodo, cfg.EndpointURL, reqBody)
|
|
if err != nil {
|
|
logEntry.Estado = "failed"
|
|
logEntry.ErrorMsg = fmt.Sprintf("error creando request: %v", err)
|
|
_ = models.SaveSaasDispatchLog(&logEntry)
|
|
log.Printf("[DISPATCH] Config %d — error creando request: %v", cfg.ID, err)
|
|
return
|
|
}
|
|
|
|
if metodo != "GET" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if cfg.ApiKeyHeader != "" && cfg.ApiKeyValue != "" {
|
|
req.Header.Set(cfg.ApiKeyHeader, cfg.ApiKeyValue)
|
|
}
|
|
|
|
// Ejecutar llamada
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
logEntry.Estado = "failed"
|
|
logEntry.ErrorMsg = fmt.Sprintf("error HTTP: %v", err)
|
|
_ = models.SaveSaasDispatchLog(&logEntry)
|
|
log.Printf("[DISPATCH] Config %d — error llamando %s: %v", cfg.ID, cfg.EndpointURL, err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBytes, _ := io.ReadAll(resp.Body)
|
|
respBody := truncateStr(string(respBytes), 2000)
|
|
|
|
logEntry.HttpStatus = resp.StatusCode
|
|
logEntry.Respuesta = respBody
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
logEntry.Estado = "success"
|
|
} else {
|
|
logEntry.Estado = "failed"
|
|
}
|
|
|
|
_ = models.SaveSaasDispatchLog(&logEntry)
|
|
log.Printf("[DISPATCH] Config %d → %s %s — HTTP %d", cfg.ID, metodo, cfg.EndpointURL, resp.StatusCode)
|
|
}
|
|
|
|
// renderTemplate aplica text/template sobre el PayloadTemplate del config.
|
|
func renderTemplate(tmplStr string, data DispatchPayload) (string, error) {
|
|
if tmplStr == "" {
|
|
// Si no hay template, enviar payload JSON mínimo
|
|
return fmt.Sprintf(`{"contrato_id":%d,"referencia":"%s","email":"%s","monto":%.2f,"moneda":"%s","fuente":"%s"}`,
|
|
data.ContratoID, data.Referencia, data.Email, data.Monto, data.Moneda, data.Fuente), nil
|
|
}
|
|
t, err := template.New("payload").Parse(tmplStr)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := t.Execute(&buf, data); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func truncateStr(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max] + "…"
|
|
}
|