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>
136 lines
4.7 KiB
Go
136 lines
4.7 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"`
|
|
}
|
|
|
|
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
|
|
}
|