Files
soft_usite/pkg/models/umind_uso.go
T
Lizandro GuarnizoandClaude Opus 5 b125ceef95 fix(umind): la config de IA de un cliente podía atender tareas del sistema
Auditando el aislamiento apareció el agujero al revés del que se buscaba: no
un cliente leyendo datos de otro, sino la cuenta de IA de un cliente pagando
trabajo nuestro.

GetAiConfigForService recorre las configs activas y devuelve la primera sin
módulo asignado. Las configs de cliente no llevan módulo — ninguna lo lleva, es
parte del diseño — así que caían justo en ese fallback. Con un cliente que
hubiera conectado su cuenta, su clave terminaba clasificando correos de
soporte, importando plantillas o atendiendo la vCard. Ninguno de los dos se
enteraba: la respuesta llegaba igual y la factura le llegaba a él.

Todos los resolvedores globales filtran ahora tenant_id IS NULL. Un test lo
verifica sobre el código de cada uno, porque son consultas a base y acá no hay
una.

El de embeddings además no podía ser de cliente por otra razón: los vectores de
todos los agentes tienen que salir del mismo modelo o la similitud coseno entre
ellos no significa nada. Un cliente con su propio modelo de embeddings rompía
su propia búsqueda sin un solo error visible.

Del alcance entre clientes, que era lo que se auditaba: los 39 handlers de
uMind validan, y el CRUD de espacios y planes ni siquiera se monta en las rutas
del portal. Lo que faltaba era prueba: UmindScopeDe distingue "staff" de
"cliente sin espacios" por nil contra slice vacío, y esa diferencia no tenía
un solo test. Ahora la cubre uno que además falla si se invierte el fail-closed
de una ruta sin scope — probado inyectando las dos fugas.

Y dos cosas que quedaban colgando:

En /app/ai-config toda config de cliente se mostraba como "Global", que es
justo lo que no es. Ahora dice de qué espacio es, por nombre.

El consumo de una cuenta propia se registraba con el costo del plan. Se sigue
midiendo —el cliente quiere ver cuánto usa su asistente— pero con costo cero y
marcado como cuenta propia: cobrarlo también sería cobrar dos veces lo mismo.
En la pantalla de consumo aparece "va por tu cuenta de IA" en vez de un "$0"
que parecería un error. El OCR y la transcripción siguen costando: son
servicios nuestros, los use quien los use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:15:28 -05:00

235 lines
8.4 KiB
Go

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"`
// CuentaPropia marca el consumo que salió por la cuenta de IA del propio
// cliente. Se sigue midiendo —quiere ver cuánto usa su asistente— pero con
// costo cero: ya se lo factura su proveedor, y cobrárselo también sería
// cobrar dos veces por lo mismo.
CuentaPropia bool `json:"cuenta_propia" gorm:"column:cuenta_propia;default:false;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
}
// Si el agente corre sobre la cuenta de IA del propio cliente, el consumo
// se registra igual pero no se le cobra: su proveedor ya se lo factura.
// Solo aplica a los tokens de IA — el OCR y la transcripción son nuestros
// servicios, los use quien los use.
propia := tipo == UsoTipoIA && agenteUsaCuentaPropia(agente)
costo := 0.0
if !propia {
costo = costoDeUso(plan, tipo, cantidad)
}
uso := &UmindUso{
TenantID: agente.TenantID, AgenteID: agenteID,
Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
Costo: costo, Moneda: moneda, CuentaPropia: propia,
}
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
}
// ─── Puente con la facturación por contrato ─────────────────────────────────
// El cobro recurrente ya existente vive en Contrato (ver renovacion_service).
// Un contrato es de un Cliente, y un Cliente puede tener varios tenants de
// uMind, así que el consumo se agrega por cliente, no por tenant.
func tenantIDsDeCliente(clienteID uint) []uint {
if clienteID == 0 {
return nil
}
tenants, err := GetUmindTenantsByClientes([]uint{clienteID})
if err != nil || len(tenants) == 0 {
return nil
}
ids := make([]uint, 0, len(tenants))
for _, t := range tenants {
ids = append(ids, t.ID)
}
return ids
}
// ConsumoPendientePorCliente suma lo consumido y todavía no facturado de
// todos los tenants de un cliente. Es lo que se le agrega a la mensualidad
// al generar el link de cobro del ciclo.
func ConsumoPendientePorCliente(clienteID uint) float64 {
ids := tenantIDsDeCliente(clienteID)
if len(ids) == 0 {
return 0
}
var total float64
if err := app.Http.Database.DB.Model(&UmindUso{}).
Where("tenant_id IN ? AND facturado_at IS NULL AND deleted_at IS NULL", ids).
Select("COALESCE(SUM(costo), 0)").Scan(&total).Error; err != nil {
log.Printf("[UMIND_USO] no se pudo sumar el consumo pendiente del cliente %d: %v", clienteID, err)
return 0
}
return total
}
// ResumenPendientePorCliente es el desglose que se adjunta al correo de cobro.
// Un cobro variable sin detalle es una disputa asegurada.
func ResumenPendientePorCliente(clienteID uint) []ResumenUso {
ids := tenantIDsDeCliente(clienteID)
if len(ids) == 0 {
return nil
}
var out []ResumenUso
if 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 IN ? AND facturado_at IS NULL AND deleted_at IS NULL", ids).
Group("tipo").Scan(&out).Error; err != nil {
return nil
}
return out
}
// MarcarUsoFacturadoPorCliente cierra el consumo pendiente tras confirmarse
// el pago. Idempotente por el filtro facturado_at IS NULL: un webhook de pago
// repetido no encuentra nada que marcar y no vuelve a cobrar.
func MarcarUsoFacturadoPorCliente(clienteID uint) {
ids := tenantIDsDeCliente(clienteID)
if len(ids) == 0 {
return
}
ahora := time.Now()
if err := app.Http.Database.DB.Model(&UmindUso{}).
Where("tenant_id IN ? AND facturado_at IS NULL", ids).
Update("facturado_at", ahora).Error; err != nil {
log.Printf("[UMIND_USO] no se pudo marcar como facturado el consumo del cliente %d: %v", clienteID, err)
}
}
// agenteUsaCuentaPropia dice si el agente apunta a una config de IA cargada por
// el cliente (con tenant), en vez de a una nuestra.
func agenteUsaCuentaPropia(agente *UmindAgente) bool {
if agente == nil || agente.AiConfigID == nil {
return false
}
var cfg AiConfig
if err := GetAiConfigByID(*agente.AiConfigID, &cfg); err != nil {
return false
}
return cfg.TenantID != nil
}