diff --git a/main.go b/main.go
index fdf5c45..3b02a55 100755
--- a/main.go
+++ b/main.go
@@ -157,6 +157,7 @@ func main() {
&models.UmindConexion{},
&models.UmindEventoLog{},
&models.UmindPlan{},
+ &models.UmindUso{},
// API Keys de /api/v2 (token + IP obligatoria + scopes)
&models.ApiKey{},
// Integraciones: OCR y transcripción de audio (servicios propios)
diff --git a/migrations/migrate.go b/migrations/migrate.go
index 8915e3b..daf717a 100755
--- a/migrations/migrate.go
+++ b/migrations/migrate.go
@@ -128,6 +128,7 @@ func Migrate() {
&models.UmindConexion{},
&models.UmindEventoLog{},
&models.UmindPlan{},
+ &models.UmindUso{},
// API Keys de /api/v2 (token + IP obligatoria + scopes)
&models.ApiKey{},
// Integraciones: OCR y transcripción de audio (servicios propios)
diff --git a/pkg/models/umind_uso.go b/pkg/models/umind_uso.go
new file mode 100644
index 0000000..d249bbe
--- /dev/null
+++ b/pkg/models/umind_uso.go
@@ -0,0 +1,135 @@
+package models
+
+import (
+ "log"
+ "time"
+
+ "github.com/sujit-baniya/fiber-boilerplate/app"
+ "gorm.io/gorm"
+)
+
+// Tipos de consumo medible.
+const (
+ UsoTipoIA = "ia"
+ UsoTipoOCR = "ocr"
+ UsoTipoWhisper = "whisper"
+)
+
+// UmindUso es una línea de consumo facturable. El Costo se congela con el
+// precio vigente del plan al momento de registrarlo: subir un precio nunca
+// revalúa consumo ya ocurrido, que es lo que haría imposible defender una
+// factura ante un reclamo.
+//
+// TenantID está desnormalizado a propósito (se puede derivar del agente) para
+// poder sumar el consumo de un ciclo sin joins.
+type UmindUso struct {
+ gorm.Model
+ TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"`
+ AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
+ Tipo string `json:"tipo" gorm:"column:tipo;size:10;index"`
+ Cantidad float64 `json:"cantidad" gorm:"column:cantidad"`
+ Unidad string `json:"unidad" gorm:"column:unidad;size:20"`
+ Costo float64 `json:"costo" gorm:"column:costo"`
+ Moneda string `json:"moneda" gorm:"column:moneda;size:3"`
+ // FacturadoAt null = pendiente de cobrar en el próximo ciclo.
+ FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"`
+}
+
+func (UmindUso) TableName() string { return "umind_uso" }
+
+// RegistrarUsoUmind nunca devuelve error, igual que RegistrarEventoUmind: es
+// contabilidad lateral y jamás debe tumbar la respuesta al visitante. Si
+// falla, queda en el log para reconciliar a mano.
+func RegistrarUsoUmind(agenteID uint, tipo string, cantidad float64, unidad string) {
+ if agenteID == 0 || cantidad <= 0 {
+ return
+ }
+ agente, err := GetUmindAgenteByID(agenteID)
+ if err != nil {
+ log.Printf("[UMIND_USO] agente %d no encontrado, no se registra el consumo: %v", agenteID, err)
+ return
+ }
+
+ plan := GetPlanDeTenant(agente.TenantID)
+ moneda := "COP"
+ if plan != nil {
+ moneda = plan.Moneda
+ }
+ costo := costoDeUso(plan, tipo, cantidad)
+
+ uso := &UmindUso{
+ TenantID: agente.TenantID, AgenteID: agenteID,
+ Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
+ Costo: costo, Moneda: moneda,
+ }
+ if err := app.Http.Database.DB.Create(uso).Error; err != nil {
+ log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err)
+ }
+}
+
+// costoDeUso aplica la tarifa del plan. Sin plan (tenants viejos) o tipo no
+// tarifado, el consumo se registra pero no cuesta.
+func costoDeUso(plan *UmindPlan, tipo string, cantidad float64) float64 {
+ if plan == nil {
+ return 0
+ }
+ switch tipo {
+ case UsoTipoIA:
+ return cantidad / 1000 * plan.PrecioPor1kTokens
+ case UsoTipoOCR:
+ return cantidad * plan.PrecioPorOCR
+ case UsoTipoWhisper:
+ return cantidad * plan.PrecioPorTranscripcion
+ }
+ return 0
+}
+
+// GetUsoUmind lista el consumo de un tenant en un rango. tipo vacío = todos.
+func GetUsoUmind(tenantID uint, desde, hasta time.Time, tipo string) ([]UmindUso, error) {
+ var items []UmindUso
+ db := app.Http.Database.DB.Where("tenant_id = ? AND created_at >= ? AND created_at < ?", tenantID, desde, hasta)
+ if tipo != "" {
+ db = db.Where("tipo = ?", tipo)
+ }
+ err := db.Order("created_at DESC").Limit(1000).Find(&items).Error
+ return items, err
+}
+
+// ResumenUso es el agregado por tipo que se muestra en el panel y se adjunta
+// al correo de cobro.
+type ResumenUso struct {
+ Tipo string `json:"tipo"`
+ Unidad string `json:"unidad"`
+ Cantidad float64 `json:"cantidad"`
+ Costo float64 `json:"costo"`
+ Eventos int64 `json:"eventos"`
+}
+
+func GetResumenUso(tenantID uint, desde, hasta time.Time) ([]ResumenUso, error) {
+ var out []ResumenUso
+ err := app.Http.Database.DB.Model(&UmindUso{}).
+ Select("tipo, MAX(unidad) AS unidad, SUM(cantidad) AS cantidad, SUM(costo) AS costo, COUNT(*) AS eventos").
+ Where("tenant_id = ? AND created_at >= ? AND created_at < ? AND deleted_at IS NULL", tenantID, desde, hasta).
+ Group("tipo").Scan(&out).Error
+ return out, err
+}
+
+// SumarUsoPendiente devuelve el consumo todavía no facturado de un tenant —
+// es lo que se le suma a la mensualidad al generar el link de cobro.
+func SumarUsoPendiente(tenantID uint) (float64, error) {
+ var total float64
+ err := app.Http.Database.DB.Model(&UmindUso{}).
+ Where("tenant_id = ? AND facturado_at IS NULL AND deleted_at IS NULL", tenantID).
+ Select("COALESCE(SUM(costo), 0)").Scan(&total).Error
+ return total, err
+}
+
+// MarcarUsoFacturado cierra el consumo pendiente de un tenant. Es idempotente
+// por construcción: el filtro facturado_at IS NULL hace que una segunda
+// llamada (webhook de pago duplicado) no encuentre nada que marcar.
+func MarcarUsoFacturado(tenantID uint) error {
+ ahora := time.Now()
+ return app.Http.Database.DB.Model(&UmindUso{}).
+ Where("tenant_id = ? AND facturado_at IS NULL", tenantID).
+ Update("facturado_at", ahora).Error
+}
diff --git a/pkg/models/umind_uso_test.go b/pkg/models/umind_uso_test.go
new file mode 100644
index 0000000..e85d292
--- /dev/null
+++ b/pkg/models/umind_uso_test.go
@@ -0,0 +1,43 @@
+package models
+
+import "testing"
+
+// El costo se congela con el precio del plan al momento de registrar el
+// consumo. Si esta cuenta se rompe, se le cobra de más o de menos a un
+// cliente real, así que va con test.
+func TestCalculoCostoPorTipo(t *testing.T) {
+ plan := &UmindPlan{
+ PrecioPor1kTokens: 2.5,
+ PrecioPorOCR: 10,
+ PrecioPorTranscripcion: 40,
+ }
+
+ casos := []struct {
+ tipo string
+ cantidad float64
+ esperado float64
+ }{
+ {UsoTipoIA, 1000, 2.5}, // exactamente 1k tokens
+ {UsoTipoIA, 500, 1.25}, // fracción de 1k, no se redondea hacia arriba
+ {UsoTipoIA, 3200, 8.0}, // varios miles
+ {UsoTipoOCR, 1, 10}, // una imagen
+ {UsoTipoOCR, 3, 30}, // varias
+ {UsoTipoWhisper, 1, 40}, // una transcripción
+ {"desconocido", 100, 0}, // tipo no tarifado no cobra nada
+ }
+
+ for _, cas := range casos {
+ got := costoDeUso(plan, cas.tipo, cas.cantidad)
+ if got != cas.esperado {
+ t.Errorf("costoDeUso(%s, %.0f) = %.4f, esperaba %.4f", cas.tipo, cas.cantidad, got, cas.esperado)
+ }
+ }
+}
+
+// Sin plan asignado (tenants viejos) el consumo se registra pero no cuesta —
+// no debe explotar ni inventar un precio.
+func TestCalculoCostoSinPlan(t *testing.T) {
+ if got := costoDeUso(nil, UsoTipoIA, 5000); got != 0 {
+ t.Errorf("costoDeUso sin plan = %.4f, esperaba 0", got)
+ }
+}
diff --git a/pkg/services/cloudflare_service.go b/pkg/services/cloudflare_service.go
index feb6808..8ef5e1e 100644
--- a/pkg/services/cloudflare_service.go
+++ b/pkg/services/cloudflare_service.go
@@ -134,18 +134,18 @@ type CFZoneAccount struct {
// CFZone representa una zona (dominio) en Cloudflare.
type CFZone struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Status string `json:"status"`
- Paused bool `json:"paused"`
- Type string `json:"type"`
- NameServers []string `json:"name_servers"`
- OriginalNS []string `json:"original_name_servers"`
- CreatedOn string `json:"created_on"`
- ModifiedOn string `json:"modified_on"`
- ActivatedOn string `json:"activated_on"`
- Account CFZoneAccount `json:"account"`
- Plan CFPlan `json:"plan"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Paused bool `json:"paused"`
+ Type string `json:"type"`
+ NameServers []string `json:"name_servers"`
+ OriginalNS []string `json:"original_name_servers"`
+ CreatedOn string `json:"created_on"`
+ ModifiedOn string `json:"modified_on"`
+ ActivatedOn string `json:"activated_on"`
+ Account CFZoneAccount `json:"account"`
+ Plan CFPlan `json:"plan"`
}
// CFPlan representa el plan de una zona.
@@ -162,30 +162,30 @@ type CFDNSRecordSettings struct {
// CFDNSRecord representa un registro DNS.
type CFDNSRecord struct {
- ID string `json:"id"`
- Type string `json:"type"`
- Name string `json:"name"`
- Content string `json:"content"`
- Comment string `json:"comment,omitempty"`
- Tags []string `json:"tags,omitempty"`
- Settings CFDNSRecordSettings `json:"settings,omitempty"`
- PrivateRouting bool `json:"private_routing,omitempty"`
- Proxied bool `json:"proxied"`
- Proxiable bool `json:"proxiable"`
- TTL int `json:"ttl"`
- Priority int `json:"priority,omitempty"`
- CreatedOn string `json:"created_on"`
- ModifiedOn string `json:"modified_on"`
- CommentModifiedOn string `json:"comment_modified_on,omitempty"`
- TagsModifiedOn string `json:"tags_modified_on,omitempty"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Content string `json:"content"`
+ Comment string `json:"comment,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Settings CFDNSRecordSettings `json:"settings,omitempty"`
+ PrivateRouting bool `json:"private_routing,omitempty"`
+ Proxied bool `json:"proxied"`
+ Proxiable bool `json:"proxiable"`
+ TTL int `json:"ttl"`
+ Priority int `json:"priority,omitempty"`
+ CreatedOn string `json:"created_on"`
+ ModifiedOn string `json:"modified_on"`
+ CommentModifiedOn string `json:"comment_modified_on,omitempty"`
+ TagsModifiedOn string `json:"tags_modified_on,omitempty"`
}
// CFSSLStatus representa un certificate pack de una zona.
type CFSSLStatus struct {
ID string `json:"id"`
- Type string `json:"type"` // universal, advanced, custom, sni_custom
+ Type string `json:"type"` // universal, advanced, custom, sni_custom
Hosts []string `json:"hosts"`
- Status string `json:"status"` // active, pending_validation, deleted
+ Status string `json:"status"` // active, pending_validation, deleted
ValidationMethod string `json:"validation_method,omitempty"`
ValidityDays int `json:"validity_days,omitempty"`
CertificateAuthority string `json:"certificate_authority,omitempty"`
@@ -243,10 +243,10 @@ type CFTokenVerify struct {
// CFTokenPolicy representa una política de permisos del token.
type CFTokenPolicy struct {
- ID string `json:"id"`
- Effect string `json:"effect"`
- Resources map[string]string `json:"resources"`
- PermGroups []CFTokenPermGroup `json:"permission_groups"`
+ ID string `json:"id"`
+ Effect string `json:"effect"`
+ Resources map[string]string `json:"resources"`
+ PermGroups []CFTokenPermGroup `json:"permission_groups"`
}
// CFTokenPermGroup un grupo de permisos.
@@ -478,14 +478,14 @@ func (c *CloudflareClient) doRequest(method, path string, payload interface{}, d
// CFDNSRecordInput es el payload para crear o actualizar un registro DNS.
type CFDNSRecordInput struct {
- Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…
- Name string `json:"name"` // Nombre del registro (ej. "www" o "@")
- Content string `json:"content"` // Valor del registro
- TTL int `json:"ttl"` // 1 = automático, o segundos (min 60)
- Proxied bool `json:"proxied"` // true = nube naranja
- Priority int `json:"priority,omitempty"` // Solo para MX / SRV
- Comment string `json:"comment,omitempty"` // Comentario descriptivo
- Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"])
+ Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA…
+ Name string `json:"name"` // Nombre del registro (ej. "www" o "@")
+ Content string `json:"content"` // Valor del registro
+ TTL int `json:"ttl"` // 1 = automático, o segundos (min 60)
+ Proxied bool `json:"proxied"` // true = nube naranja
+ Priority int `json:"priority,omitempty"` // Solo para MX / SRV
+ Comment string `json:"comment,omitempty"` // Comentario descriptivo
+ Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"])
Settings *CFDNSRecordSettings `json:"settings,omitempty"` // Configuraciones adicionales
}
diff --git a/pkg/services/ocr_service.go b/pkg/services/ocr_service.go
index 1bf7ceb..58d7006 100644
--- a/pkg/services/ocr_service.go
+++ b/pkg/services/ocr_service.go
@@ -16,7 +16,10 @@ var ocrHTTPClient = &http.Client{Timeout: 30 * time.Second}
// ExtraerTextoOCR manda una imagen al servicio propio de OCR y devuelve el
// texto extraído. mimeType ej: "image/png", "image/jpeg".
-func ExtraerTextoOCR(imagenBytes []byte, mimeType string) (string, error) {
+//
+// agenteID identifica a quién cobrarle la imagen procesada; 0 = no medir, que
+// es lo que pasa el botón "Probar" del panel de staff (no es de ningún cliente).
+func ExtraerTextoOCR(agenteID uint, imagenBytes []byte, mimeType string) (string, error) {
cfg, err := models.GetOcrConfig()
if err != nil {
return "", fmt.Errorf("el servicio de OCR no está configurado (Integraciones → OCR)")
@@ -63,5 +66,6 @@ func ExtraerTextoOCR(imagenBytes []byte, mimeType string) (string, error) {
}
return "", fmt.Errorf("%s", msg)
}
+ RegistrarUso(agenteID, models.UsoTipoOCR, 1, "imagen")
return out.Text, nil
}
diff --git a/pkg/services/saas_dispatch_service.go b/pkg/services/saas_dispatch_service.go
index b1ed2de..f759ec3 100644
--- a/pkg/services/saas_dispatch_service.go
+++ b/pkg/services/saas_dispatch_service.go
@@ -31,9 +31,10 @@ type DispatchPayload struct {
// 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)
+//
+// 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)
diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go
index 3286363..9fae16e 100644
--- a/pkg/services/telegram_agent_service.go
+++ b/pkg/services/telegram_agent_service.go
@@ -103,6 +103,13 @@ type agentChatResp struct {
Message agentMessage `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
+ // Usage lo devuelven todos los proveedores OpenAI-compatibles con el mismo
+ // nombre de campo, así que no hace falta un caso por proveedor.
+ Usage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ } `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
@@ -1277,7 +1284,11 @@ type anthropicReq struct {
type anthropicResp struct {
Content []anthropicContentBlock `json:"content"`
StopReason string `json:"stop_reason"`
- Error *struct {
+ Usage struct {
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ } `json:"usage"`
+ Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
@@ -1286,7 +1297,9 @@ type anthropicResp struct {
// ─── Llamada al AI con function calling ──────────────────────────────────────
// callAI despacha al provider correcto (Anthropic o OpenAI-compatible).
-func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
+// El segundo valor son los tokens totales que reportó el proveedor (0 si no
+// los informa) — lo usa uMind para medir el consumo facturable.
+func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) {
if strings.ToLower(ai.Provider) == "anthropic" {
return callAnthropicAI(ai, messages, tools)
}
@@ -1294,7 +1307,7 @@ func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*a
}
// callOpenAICompatibleAI usa el formato de OpenAI (también vale para qwen, groq, deepseek, etc.)
-func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
+func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) {
baseURL := ai.BaseURL
if baseURL == "" {
baseURL = ProviderDefaultURL(ai.Provider)
@@ -1312,7 +1325,7 @@ func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools
payload, _ := json.Marshal(reqBody)
req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewReader(payload))
if err != nil {
- return nil, err
+ return nil, 0, err
}
req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro())
req.Header.Set("Content-Type", "application/json")
@@ -1320,27 +1333,27 @@ func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
- return nil, err
+ return nil, 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
var chatResp agentChatResp
if err := json.Unmarshal(raw, &chatResp); err != nil {
- return nil, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))]))
+ return nil, 0, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))]))
}
if chatResp.Error != nil {
- return nil, fmt.Errorf("error del AI: %s", chatResp.Error.Message)
+ return nil, 0, fmt.Errorf("error del AI: %s", chatResp.Error.Message)
}
if len(chatResp.Choices) == 0 {
- return nil, fmt.Errorf("el AI no devolvió respuesta")
+ return nil, 0, fmt.Errorf("el AI no devolvió respuesta")
}
msg := chatResp.Choices[0].Message
- return &msg, nil
+ return &msg, chatResp.Usage.TotalTokens, nil
}
// callAnthropicAI llama a la API nativa de Anthropic con tool use.
-func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
+func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) {
// Convertir herramientas al formato Anthropic
anthropicTools := make([]anthropicTool, len(tools))
for i, t := range tools {
@@ -1459,7 +1472,7 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
payload, _ := json.Marshal(reqBody)
req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(payload))
if err != nil {
- return nil, err
+ return nil, 0, err
}
req.Header.Set("x-api-key", ai.ClaveEnClaro())
req.Header.Set("anthropic-version", "2023-06-01")
@@ -1468,17 +1481,17 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
- return nil, err
+ return nil, 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
var anthropicRsp anthropicResp
if err := json.Unmarshal(raw, &anthropicRsp); err != nil {
- return nil, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))]))
+ return nil, 0, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))]))
}
if anthropicRsp.Error != nil {
- return nil, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message)
+ return nil, 0, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message)
}
// Convertir respuesta Anthropic → agentMessage (formato interno OpenAI)
@@ -1512,7 +1525,7 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent
result.Content = strings.Join(textParts, "\n")
}
result.ToolCalls = toolCalls
- return result, nil
+ return result, anthropicRsp.Usage.InputTokens + anthropicRsp.Usage.OutputTokens, nil
}
func ProviderDefaultURL(provider string) string {
@@ -1683,7 +1696,7 @@ Comandos: /reset · /instancias · /ayuda`, nil
// Loop de function calling (máximo 6 rondas)
var finalResponse string
for round := 0; round < 6; round++ {
- aiMsg, err := callAI(ai, messages, tools)
+ aiMsg, _, err := callAI(ai, messages, tools)
if err != nil {
log.Printf("[AGENT] Error llamando AI round %d: %v", round, err)
return "Error al contactar el sistema de IA. Intenta de nuevo.", err
diff --git a/pkg/services/umind_agent_service.go b/pkg/services/umind_agent_service.go
index 0589698..0dd2db2 100644
--- a/pkg/services/umind_agent_service.go
+++ b/pkg/services/umind_agent_service.go
@@ -283,12 +283,15 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
var finalResponse string
for round := 0; round < 3; round++ {
- aiMsg, err := callAI(&ai, messages, tools)
+ aiMsg, tokens, err := callAI(&ai, messages, tools)
if err != nil {
log.Printf("[UMIND] Error llamando AI (agente %d) round %d: %v", agente.ID, round, err)
models.RegistrarEventoUmind(agente.ID, "error", "ai", fmt.Sprintf("Error contactando el AI (ronda %d)", round), err.Error())
return "", fmt.Errorf("error al contactar el sistema de IA")
}
+ // Se mide cada ronda, no solo la última: las rondas de tool-calling
+ // consumen tokens reales aunque el visitante solo vea una respuesta.
+ RegistrarUso(agente.ID, models.UsoTipoIA, float64(tokens), "tokens")
if len(aiMsg.ToolCalls) == 0 {
content := ""
diff --git a/pkg/services/umind_canal_telegram_service.go b/pkg/services/umind_canal_telegram_service.go
index 152cefc..14958c0 100644
--- a/pkg/services/umind_canal_telegram_service.go
+++ b/pkg/services/umind_canal_telegram_service.go
@@ -68,7 +68,7 @@ func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID,
if err != nil {
return fmt.Errorf("no se pudo descargar el audio de Telegram: %w", err)
}
- texto, err = TranscribirAudioSelfHosted(data, "audio.ogg")
+ texto, err = TranscribirAudioSelfHosted(canal.AgenteID, data, "audio.ogg")
if err != nil {
return err
}
@@ -80,7 +80,7 @@ func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID,
if err != nil {
return fmt.Errorf("no se pudo descargar la imagen de Telegram: %w", err)
}
- texto, err = ExtraerTextoOCR(data, "image/jpeg")
+ texto, err = ExtraerTextoOCR(canal.AgenteID, data, "image/jpeg")
if err != nil {
return err
}
diff --git a/pkg/services/umind_canal_whatsapp_service.go b/pkg/services/umind_canal_whatsapp_service.go
index c04b9ed..11a3a69 100644
--- a/pkg/services/umind_canal_whatsapp_service.go
+++ b/pkg/services/umind_canal_whatsapp_service.go
@@ -77,7 +77,7 @@ func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo st
if err != nil {
return fmt.Errorf("no se pudo descargar el audio de WhatsApp: %w", err)
}
- texto, err = TranscribirAudioSelfHosted(data, "audio.ogg")
+ texto, err = TranscribirAudioSelfHosted(canal.AgenteID, data, "audio.ogg")
if err != nil {
return err
}
@@ -92,7 +92,7 @@ func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo st
if mimeType == "" {
mimeType = "image/jpeg"
}
- texto, err = ExtraerTextoOCR(data, mimeType)
+ texto, err = ExtraerTextoOCR(canal.AgenteID, data, mimeType)
if err != nil {
return err
}
diff --git a/pkg/services/umind_uso_service.go b/pkg/services/umind_uso_service.go
new file mode 100644
index 0000000..43873f0
--- /dev/null
+++ b/pkg/services/umind_uso_service.go
@@ -0,0 +1,92 @@
+package services
+
+import (
+ "fmt"
+ "log"
+ "sync"
+ "time"
+
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+)
+
+// avisosTope recuerda a qué tenants ya se les avisó en el ciclo actual, para
+// no mandar un aviso por cada mensaje una vez pasado el tope. Se pierde al
+// reiniciar (y entonces se vuelve a avisar una vez): aceptable para un aviso,
+// y mucho más simple que una columna de estado en el tenant.
+//
+// ponytail: en memoria y por proceso. Si algún día corren varias réplicas,
+// cada una avisa una vez — mover el flag a la BD si eso molesta.
+var avisosTope struct {
+ sync.Mutex
+ ultimo map[uint]string // tenantID → "2026-08" del último aviso
+}
+
+// RegistrarUso persiste el consumo y avisa si el tenant superó el tope de su
+// plan. El aviso NO corta el servicio: el agente sigue respondiendo.
+func RegistrarUso(agenteID uint, tipo string, cantidad float64, unidad string) {
+ if agenteID == 0 || cantidad <= 0 {
+ return
+ }
+ models.RegistrarUsoUmind(agenteID, tipo, cantidad, unidad)
+
+ agente, err := models.GetUmindAgenteByID(agenteID)
+ if err != nil {
+ return
+ }
+ plan := models.GetPlanDeTenant(agente.TenantID)
+ if plan == nil || plan.TopeConsumoMensual <= 0 {
+ return
+ }
+
+ desde, hasta := cicloActual()
+ resumen, err := models.GetResumenUso(agente.TenantID, desde, hasta)
+ if err != nil {
+ return
+ }
+ total := 0.0
+ for _, r := range resumen {
+ total += r.Costo
+ }
+ if total < plan.TopeConsumoMensual {
+ return
+ }
+ avisarTopeConsumo(agente.TenantID, plan, total, desde)
+}
+
+// cicloActual devuelve el mes calendario en curso. Se usa el mes natural (no
+// la fecha de alta del contrato) porque es lo que el cliente entiende al
+// mirar "consumo de agosto" en su panel.
+func cicloActual() (time.Time, time.Time) {
+ ahora := time.Now()
+ desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location())
+ return desde, desde.AddDate(0, 1, 0)
+}
+
+func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, desde time.Time) {
+ periodo := desde.Format("2006-01")
+
+ avisosTope.Lock()
+ if avisosTope.ultimo == nil {
+ avisosTope.ultimo = map[uint]string{}
+ }
+ yaAvisado := avisosTope.ultimo[tenantID] == periodo
+ avisosTope.ultimo[tenantID] = periodo
+ avisosTope.Unlock()
+ if yaAvisado {
+ return
+ }
+
+ tenant, err := models.GetUmindTenantByID(tenantID)
+ nombre := fmt.Sprintf("tenant %d", tenantID)
+ if err == nil {
+ nombre = tenant.Nombre
+ }
+
+ log.Printf("[UMIND_USO] %s superó el tope de consumo del plan %s: %.2f %s de %.2f",
+ nombre, plan.Nombre, total, plan.Moneda, plan.TopeConsumoMensual)
+
+ sendTelegramAdmin(fmt.Sprintf(
+ "📊 Tope de consumo superado\nCliente: %s\nPlan: %s\nConsumo del mes: %s %.2f (tope %s %.2f)\n\nEl servicio sigue activo — el excedente se cobra en el próximo ciclo.",
+ escapeTelegramHTML(nombre), escapeTelegramHTML(plan.Nombre),
+ plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual))
+}
diff --git a/pkg/services/websms_service.go b/pkg/services/websms_service.go
index d3fd949..2158f70 100644
--- a/pkg/services/websms_service.go
+++ b/pkg/services/websms_service.go
@@ -15,8 +15,8 @@ import (
const websmsAPIBase = "https://api.labsmobile.com/json/send"
type WebSmsRequest struct {
- Message string `json:"message"`
- TPOA string `json:"tpoa,omitempty"`
+ Message string `json:"message"`
+ TPOA string `json:"tpoa,omitempty"`
Recipient []WebSmsRecipient `json:"recipient"`
}
@@ -67,7 +67,7 @@ func SendWebSms(cfg *models.WebSmsConfig, para, mensaje string) (*WebSmsResponse
auth := base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.ApiToken))
req := WebSmsRequest{
- Message: mensaje,
+ Message: mensaje,
Recipient: []WebSmsRecipient{{MSISDN: para}},
}
if cfg.Sender != "" {
diff --git a/pkg/services/whisper_asr_service.go b/pkg/services/whisper_asr_service.go
index 0379652..1cfc956 100644
--- a/pkg/services/whisper_asr_service.go
+++ b/pkg/services/whisper_asr_service.go
@@ -16,7 +16,13 @@ var whisperAsrHTTPClient = &http.Client{Timeout: 120 * time.Second} // audio lar
// TranscribirAudioSelfHosted manda un archivo de audio al servicio propio de
// transcripción (whisper-asr-webservice, Basic Auth) y devuelve el texto.
-func TranscribirAudioSelfHosted(audioBytes []byte, filename string) (string, error) {
+//
+// agenteID identifica a quién cobrarle la transcripción; 0 = no medir.
+//
+// ponytail: se cobra por transcripción, no por minuto de audio —
+// whisper-asr-webservice con response_format=json no devuelve la duración. Si
+// hace falta cobrar por minuto, pedirle verbose_json y sumar los segments.
+func TranscribirAudioSelfHosted(agenteID uint, audioBytes []byte, filename string) (string, error) {
cfg, err := models.GetWhisperAsrConfig()
if err != nil {
return "", fmt.Errorf("el servicio de transcripción no está configurado (Integraciones → Whisper ASR)")
@@ -63,5 +69,6 @@ func TranscribirAudioSelfHosted(audioBytes []byte, filename string) (string, err
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("respuesta inesperada del servicio de transcripción: %s", string(raw))
}
+ RegistrarUso(agenteID, models.UsoTipoWhisper, 1, "transcripcion")
return out.Text, nil
}
diff --git a/rest/controllers/cloudflare_controller.go b/rest/controllers/cloudflare_controller.go
index 1899140..dea0f22 100644
--- a/rest/controllers/cloudflare_controller.go
+++ b/rest/controllers/cloudflare_controller.go
@@ -116,9 +116,9 @@ func CleanCloudflareToken(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
- "message": "Token re-guardado limpio",
- "chars_before": before,
- "chars_after": after,
+ "message": "Token re-guardado limpio",
+ "chars_before": before,
+ "chars_after": after,
"chars_removed": before - after,
})
}
@@ -244,11 +244,11 @@ func VerifyCloudflareToken(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{
- "ok": true,
- "auth_type": "global_key",
- "user_email": user.Email,
- "user_id": user.ID,
- "token": tokenDiag,
+ "ok": true,
+ "auth_type": "global_key",
+ "user_email": user.Email,
+ "user_id": user.ID,
+ "token": tokenDiag,
"permissions_test": fiber.Map{
"zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
"dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
@@ -311,10 +311,15 @@ func VerifyCloudflareToken(c *fiber.Ctx) error {
"token_prev": preview,
"was_dirty": dirty,
"detail_error": detailErrMsg,
- "policies": func() interface{} { if detail != nil { return detail.Policies }; return nil }(),
+ "policies": func() interface{} {
+ if detail != nil {
+ return detail.Policies
+ }
+ return nil
+ }(),
"permissions_test": fiber.Map{
- "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
- "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
+ "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg},
+ "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID},
},
"hint": hint,
})
@@ -408,7 +413,12 @@ func GetCloudflareSSL(c *fiber.Ctx) error {
"source": "universal_ssl",
"universal_ssl": fiber.Map{
"enabled": universal.Enabled,
- "note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string { if universal.Enabled { return "ACTIVO" }; return "INACTIVO" }(),
+ "note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string {
+ if universal.Enabled {
+ return "ACTIVO"
+ }
+ return "INACTIVO"
+ }(),
},
"packs_error": packsErr.Error(),
})
diff --git a/rest/controllers/factura_controller.go b/rest/controllers/factura_controller.go
index ecbdeeb..16eab1d 100644
--- a/rest/controllers/factura_controller.go
+++ b/rest/controllers/factura_controller.go
@@ -67,15 +67,15 @@ func CreateFactura(c *fiber.Ctx) error {
return c.Status(400).JSON(fiber.Map{"error": "cliente_id es requerido"})
}
f := &models.Factura{
- ClienteID: req.ClienteID,
- ProyectoID: req.ProyectoID,
- Numero: req.Numero,
- Descripcion: req.Descripcion,
- Monto: req.Monto,
- Moneda: req.Moneda,
- Estado: req.Estado,
- Visible: req.Visible,
- Notas: req.Notas,
+ ClienteID: req.ClienteID,
+ ProyectoID: req.ProyectoID,
+ Numero: req.Numero,
+ Descripcion: req.Descripcion,
+ Monto: req.Monto,
+ Moneda: req.Moneda,
+ Estado: req.Estado,
+ Visible: req.Visible,
+ Notas: req.Notas,
FechaEmision: time.Now(),
}
if req.FechaEmision != "" {
diff --git a/rest/controllers/ocr_controller.go b/rest/controllers/ocr_controller.go
index 5267972..80a30c5 100644
--- a/rest/controllers/ocr_controller.go
+++ b/rest/controllers/ocr_controller.go
@@ -76,7 +76,7 @@ func TestOcrConfigHandler(c *fiber.Ctx) error {
if mimeType == "" {
mimeType = "image/png"
}
- texto, err := services.ExtraerTextoOCR(data, mimeType)
+ texto, err := services.ExtraerTextoOCR(0, data, mimeType)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
diff --git a/rest/controllers/pasarelas_controller.go b/rest/controllers/pasarelas_controller.go
index 59cd53e..970f3c4 100644
--- a/rest/controllers/pasarelas_controller.go
+++ b/rest/controllers/pasarelas_controller.go
@@ -597,4 +597,3 @@ func SavePaypalConfigWeb(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{"message": "Configuración PayPal guardada"})
}
-
diff --git a/rest/controllers/proyecto_controller.go b/rest/controllers/proyecto_controller.go
index a82c81a..1c68f24 100644
--- a/rest/controllers/proyecto_controller.go
+++ b/rest/controllers/proyecto_controller.go
@@ -401,7 +401,9 @@ func DownloadEntregable(c *fiber.Ctx) error {
func UpdateEntregableVisibilidad(c *fiber.Ctx) error {
entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32)
- type Req struct{ Visible bool `json:"visible"` }
+ type Req struct {
+ Visible bool `json:"visible"`
+ }
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
@@ -530,7 +532,9 @@ func GetTickets(c *fiber.Ctx) error {
func UpdateTicketEstadoAdmin(c *fiber.Ctx) error {
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
- type Req struct{ Estado string `json:"estado"` }
+ type Req struct {
+ Estado string `json:"estado"`
+ }
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
@@ -543,7 +547,9 @@ func UpdateTicketEstadoAdmin(c *fiber.Ctx) error {
func AdminResponderTicket(c *fiber.Ctx) error {
ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32)
- type Req struct{ Contenido string `json:"contenido"` }
+ type Req struct {
+ Contenido string `json:"contenido"`
+ }
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
diff --git a/rest/controllers/register_controller.go b/rest/controllers/register_controller.go
index f378f52..504a36a 100755
--- a/rest/controllers/register_controller.go
+++ b/rest/controllers/register_controller.go
@@ -103,7 +103,6 @@ func RequestPasswordResetPost(c *fiber.Ctx) error {
})
}
-
// todo: Generar contrasenas aleatorias /do/generate-password
func generatePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&"
diff --git a/rest/controllers/role_controller.go b/rest/controllers/role_controller.go
index 231f024..544cc0d 100755
--- a/rest/controllers/role_controller.go
+++ b/rest/controllers/role_controller.go
@@ -17,7 +17,7 @@ func CreateNewRole(c *fiber.Ctx) error {
func RemoveRole(c *fiber.Ctx) error {
var role models.Role
c.BodyParser(&role)
-
+
app.Http.Database.
Delete(&models.RoleAndPermission{})
app.Http.Auth.Enforcer.LoadPolicy()
@@ -30,10 +30,10 @@ func AssignRoleToUser(c *fiber.Ctx) error {
c.BodyParser(&roleRequest)
role := models.RoleAndPermission{
Ptype: "g",
- V0: fmt.Sprintf("%d", roleRequest.UserID),
- V1: roleRequest.Role,
+ V0: fmt.Sprintf("%d", roleRequest.UserID),
+ V1: roleRequest.Role,
}
- err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1}).Error
+ err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1}).Error
if err != nil {
app.Http.Database.Create(&role)
app.Http.Auth.Enforcer.LoadPolicy()
@@ -60,7 +60,7 @@ func ChangeRoleForUser(c *fiber.Ctx) error {
var role models.RoleRequest
var role1 models.RoleAndPermission
c.BodyParser(&role)
- err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: "g",V0: fmt.Sprintf("%d", role.UserID), V1: role.OldRole}).Error
+ err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: "g", V0: fmt.Sprintf("%d", role.UserID), V1: role.OldRole}).Error
if err == nil {
role1.V1 = role.Role
role1.DeletedAt = gorm.DeletedAt{Valid: false}
@@ -77,13 +77,13 @@ func AddPermissionOnRole(c *fiber.Ctx) error {
c.BodyParser(&permission)
if permission.Role != "" && permission.Module != "" && permission.Action != "" {
role := models.RoleAndPermission{
- Ptype: "p",
- V0: permission.Role,
- V1: permission.Module,
- V2: permission.Action,
+ Ptype: "p",
+ V0: permission.Role,
+ V1: permission.Module,
+ V2: permission.Action,
Category: "permission",
}
- err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1, V2: role.V2}).Error
+ err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1, V2: role.V2}).Error
if err != nil {
app.Http.Database.Create(&role)
}
@@ -93,13 +93,13 @@ func AddPermissionOnRole(c *fiber.Ctx) error {
if permission.Role != "" && permission.Route != "" && permission.Method != "" {
fmt.Println(1)
role := models.RoleAndPermission{
- Ptype: "p",
- V0: permission.Role,
- V1: permission.Route,
- V2: permission.Method,
+ Ptype: "p",
+ V0: permission.Role,
+ V1: permission.Route,
+ V2: permission.Method,
Category: "route",
}
- err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1, V2: role.V2}).Error
+ err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1, V2: role.V2}).Error
if err != nil {
app.Http.Database.Create(&role)
}
diff --git a/rest/controllers/saas_api_controller.go b/rest/controllers/saas_api_controller.go
index 454bd85..9142692 100644
--- a/rest/controllers/saas_api_controller.go
+++ b/rest/controllers/saas_api_controller.go
@@ -349,9 +349,9 @@ func SaasWebhookInHandler(c *fiber.Ctx) error {
cfg.Nombre, cfg.ID, c.IP(), c.Method())
return c.Status(fiber.StatusOK).JSON(fiber.Map{
- "ok": true,
- "integracion": cfg.Nombre,
- "saas_api_id": cfg.ID,
- "message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre),
+ "ok": true,
+ "integracion": cfg.Nombre,
+ "saas_api_id": cfg.ID,
+ "message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre),
})
}
diff --git a/rest/controllers/servidor_controller.go b/rest/controllers/servidor_controller.go
index 1121c6a..e1e5f38 100755
--- a/rest/controllers/servidor_controller.go
+++ b/rest/controllers/servidor_controller.go
@@ -167,17 +167,17 @@ func UpdateServidor(c *fiber.Ctx) error {
// Usar map para Updates y así no perder campos con valor cero (incluido hostinger_vps_id=nil)
updateMap := map[string]interface{}{
- "nombre": m.Nombre,
- "ip_servidor": m.IpServidor,
- "so": m.So,
- "vencimiento": m.Vencimiento,
- "ram": m.Ram,
- "nucleos": m.Nucleos,
- "disco": m.Disco,
- "ultimo_ping": m.UltimoPing,
- "prov_servidor_id": m.ProvServidorID,
- "tipo_servidor_id": m.TipoServidorID,
- "hostinger_vps_id": m.HostingerVpsID,
+ "nombre": m.Nombre,
+ "ip_servidor": m.IpServidor,
+ "so": m.So,
+ "vencimiento": m.Vencimiento,
+ "ram": m.Ram,
+ "nucleos": m.Nucleos,
+ "disco": m.Disco,
+ "ultimo_ping": m.UltimoPing,
+ "prov_servidor_id": m.ProvServidorID,
+ "tipo_servidor_id": m.TipoServidorID,
+ "hostinger_vps_id": m.HostingerVpsID,
"hostinger_subscription_id": m.HostingerSubscriptionID,
}
if err := app.Http.Database.DB.Model(&models.Servidor{}).Where("id = ?", uid).Updates(updateMap).Error; err != nil {
diff --git a/rest/controllers/tipo_servidor_controller.go b/rest/controllers/tipo_servidor_controller.go
index 6b82d84..10c9240 100755
--- a/rest/controllers/tipo_servidor_controller.go
+++ b/rest/controllers/tipo_servidor_controller.go
@@ -36,13 +36,12 @@ func GetTipoServidor(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{
- "registros": records,
- "total": total,
+ "registros": records,
+ "total": total,
"totalPages": 1,
})
}
-
func CreateTipoServidor(c *fiber.Ctx) error {
var m models.TipoServidor
diff --git a/rest/controllers/umind_admin_controller.go b/rest/controllers/umind_admin_controller.go
index 8630f10..8ed7c2d 100644
--- a/rest/controllers/umind_admin_controller.go
+++ b/rest/controllers/umind_admin_controller.go
@@ -6,6 +6,7 @@ import (
"regexp"
"strconv"
"strings"
+ "time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -611,3 +612,65 @@ func GetUmindEventosHandler(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{"items": items})
}
+
+// ─── Consumo ────────────────────────────────────────────────────────────────
+
+// GetUmindUsoHandler devuelve el consumo de un tenant en un rango de fechas:
+// el resumen agregado por tipo (lo que se factura) y el detalle línea a línea.
+// Sin fechas, el rango es el mes en curso.
+func GetUmindUsoHandler(c *fiber.Ctx) error {
+ tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
+ if err != nil || tenantID == 0 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
+ }
+ desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
+
+ resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ detalle, err := models.GetUsoUmind(uint(tenantID), desde, hasta, c.Query("tipo"))
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ pendiente, _ := models.SumarUsoPendiente(uint(tenantID))
+
+ total := 0.0
+ for _, r := range resumen {
+ total += r.Costo
+ }
+
+ out := fiber.Map{
+ "desde": desde.Format("2006-01-02"), "hasta": hasta.Format("2006-01-02"),
+ "resumen": resumen, "detalle": detalle,
+ "total_periodo": total, "pendiente_facturar": pendiente,
+ }
+ if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
+ out["plan"] = fiber.Map{
+ "nombre": plan.Nombre, "moneda": plan.Moneda,
+ "precio_mensual": plan.PrecioMensual, "tope_consumo_mensual": plan.TopeConsumoMensual,
+ "max_agentes": plan.MaxAgentes,
+ }
+ }
+ return c.JSON(out)
+}
+
+// rangoFechas parsea el filtro del panel. Ante una fecha inválida cae al mes
+// en curso en vez de devolver error: es un filtro de lectura, no vale la pena
+// romperle la pantalla al usuario por un query param mal escrito.
+func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) {
+ ahora := time.Now()
+ desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location())
+ hasta := desde.AddDate(0, 1, 0)
+
+ if d, err := time.Parse("2006-01-02", desdeStr); err == nil {
+ desde = d
+ }
+ if h, err := time.Parse("2006-01-02", hastaStr); err == nil {
+ hasta = h.AddDate(0, 0, 1) // inclusivo: "hasta el 31" incluye el 31 entero
+ }
+ if !hasta.After(desde) {
+ hasta = desde.AddDate(0, 1, 0)
+ }
+ return desde, hasta
+}
diff --git a/rest/controllers/user_controller.go b/rest/controllers/user_controller.go
index 0dddd8a..7733833 100755
--- a/rest/controllers/user_controller.go
+++ b/rest/controllers/user_controller.go
@@ -7,7 +7,6 @@ import (
"strconv"
)
-
func UserInfo(c *fiber.Ctx) error {
users, err := models.GetUserById(c.Params("id"))
if err != nil {
diff --git a/rest/controllers/websms_controller.go b/rest/controllers/websms_controller.go
index 69c78ee..875b709 100644
--- a/rest/controllers/websms_controller.go
+++ b/rest/controllers/websms_controller.go
@@ -189,8 +189,13 @@ func ApiSendSms(c *fiber.Ctx) error {
Para: b.Numero,
Mensaje: b.Mensaje,
Status: status,
- MsgID: func() string { if resp != nil { return resp.MsgID() }; return "" }(),
- Error: errStr,
+ MsgID: func() string {
+ if resp != nil {
+ return resp.MsgID()
+ }
+ return ""
+ }(),
+ Error: errStr,
})
if err != nil {
diff --git a/rest/controllers/whisper_asr_controller.go b/rest/controllers/whisper_asr_controller.go
index 201cf1d..7093039 100644
--- a/rest/controllers/whisper_asr_controller.go
+++ b/rest/controllers/whisper_asr_controller.go
@@ -72,7 +72,7 @@ func TestWhisperAsrConfigHandler(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
}
- texto, err := services.TranscribirAudioSelfHosted(data, fh.Filename)
+ texto, err := services.TranscribirAudioSelfHosted(0, data, fh.Filename)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
diff --git a/rest/routes/user.go b/rest/routes/user.go
index bdd0cf6..e245e93 100755
--- a/rest/routes/user.go
+++ b/rest/routes/user.go
@@ -383,6 +383,7 @@ func UserRoutes(app fiber.Router) {
protected.Get("/umind/conexiones/callback/:proveedor", controllers.UmindOAuthCallbackHandler)
protected.Delete("/umind/conexiones/:id", middlewares.SoloAdmin, controllers.DeleteUmindConexionHandler)
protected.Get("/umind/eventos", controllers.GetUmindEventosHandler)
+ protected.Get("/umind/uso", controllers.GetUmindUsoHandler)
// Planes: definen el límite de agentes y los precios por consumo. Solo staff.
protected.Get("/umind-planes", middlewares.MenuMiddleware, controllers.UmindPlanesPage)
protected.Get("/umind-planes/list", controllers.GetUmindPlanesHandler)