Files
soft_usite/pkg/models/umind_uso.go
T
Lizandro GuarnizoandClaude Opus 5 047837dd23 feat(umind): el agente emite cotizaciones y contratos con la plantilla del cliente
El motor de PDF ya existía entero para el staff — Chrome headless, text/template
con {{range .Items}}, y hasta el importador que convierte un Word en plantilla
con IA. Lo único que faltaba era que fueran de cada cliente.

PlantillaDocumento gana TenantID *uint: nulo = global del staff (lo de
siempre), con valor = del espacio. Mismo patrón exacto que AiConfig, el que ya
tiene su lección aprendida. Y GetPlantillaDocumentoActiva ahora filtra
tenant_id IS NULL explícitamente: sin eso, la plantilla que un cliente escribe
para su propio contrato podía salir en un documento de la empresa. Hay test.

Un cliente sin plantilla propia cae a la global, así puede emitir una
cotización desde el primer día y personalizarla cuando quiera. Guardar crea
versión nueva en vez de pisar la vieja: si la nueva sale mal, la anterior sigue
ahí. Y se valida que compile ANTES de guardar — una plantilla rota descubierta
al generar deja al cliente esperando un PDF que nunca llega.

La tool solo se le ofrece al modelo si hay alguna plantilla disponible:
prometerle una capacidad que después falla es peor que no tenerla.

El semáforo de tres: cada PDF levanta un Chrome entero, y cien clientes
generando a la vez son cien navegadores. Eso tira el servidor mucho antes que
cualquier consulta a la IA, así que va desde el día uno y no cuando se caiga.

El JSON de ítems mal formado no tumba la generación — sale el documento sin la
tabla, que todavía se puede corregir a mano. Y sin cantidad se asume 1: el
modelo la omite seguido, y un total en cero es peor que uno aproximado.

Cada documento se cobra (levanta un Chrome) y queda en los archivos del
espacio, descargable como cualquier otro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:40:51 -05:00

238 lines
8.5 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"
UsoTipoDocumento = "documento"
)
// 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
case UsoTipoDocumento:
return cantidad * plan.PrecioPorDocumento
}
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
}