feat(umind): vincula tenants a clientes y agrega planes con precios

Base para que el cliente administre uMind desde su portal y para el cobro
por consumo. Todavía no cambia nada del comportamiento actual.

- UmindTenant gana ClienteID (punteros, sin not null: los tenants creados
  antes del portal quedan sin asignar y el ALTER TABLE no falla sobre
  datos existentes) y PlanID.
- GetUmindTenantsByClientes es fail-closed: sin clientes, no ve nada.
- UmindPlan define el máximo de agentes y los precios por 1k tokens, por
  imagen OCR, por transcripción y la mensualidad, más un tope de consumo
  que solo avisa. CRUD para staff en /app/umind-planes.
- El modelo va en AMBAS listas de AutoMigrate (main.go y migrations),
  no repetir el error de agregarlo solo en una.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 11:32:38 -05:00
co-authored by Claude Sonnet 5
parent 84506a98e2
commit c69b904b03
14 changed files with 493 additions and 33 deletions
+18
View File
@@ -23,6 +23,12 @@ type UmindTenant struct {
DominiosPermitidos string `json:"dominios_permitidos" gorm:"column:dominios_permitidos;type:text"` // coma-separado, ej: u-site.app,www.u-site.app
Activo bool `json:"activo" gorm:"column:activo;default:true"`
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
// ClienteID es lo que le permite a un PortalUser llegar a sus tenants
// (ver GetClienteIDsForPortalUser). Puntero y sin not null: los tenants
// creados antes de que existiera el portal quedan sin cliente hasta que
// el staff los asigne, y el ALTER TABLE no falla sobre datos existentes.
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
PlanID *uint `json:"plan_id" gorm:"column:plan_id;index"`
}
func (UmindTenant) TableName() string { return "umind_tenants" }
@@ -55,6 +61,18 @@ func GetAllUmindTenants(limit, offset int) ([]UmindTenant, int64, error) {
return items, total, nil
}
// GetUmindTenantsByClientes lista los tenants que pertenecen a alguno de los
// clientes dados — es la base del alcance del portal. Lista vacía devuelve
// vacío sin consultar (fail-closed: un portal user sin clientes no ve nada).
func GetUmindTenantsByClientes(clienteIDs []uint) ([]UmindTenant, error) {
if len(clienteIDs) == 0 {
return []UmindTenant{}, nil
}
var items []UmindTenant
err := app.Http.Database.DB.Where("cliente_id IN ?", clienteIDs).Order("id DESC").Find(&items).Error
return items, err
}
func GetUmindTenantByID(id uint) (*UmindTenant, error) {
var t UmindTenant
if err := app.Http.Database.DB.First(&t, id).Error; err != nil {
+66
View File
@@ -0,0 +1,66 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindPlan define qué puede hacer un tenant y a qué precio. Los precios se
// copian a cada UmindUso al momento de registrarlo, así que subir un precio
// nunca revalúa consumo ya facturado ni pendiente.
type UmindPlan struct {
gorm.Model
Nombre string `json:"nombre" gorm:"column:nombre;size:100;not null"`
MaxAgentes int `json:"max_agentes" gorm:"column:max_agentes;default:1"` // 0 = ilimitado
PrecioMensual float64 `json:"precio_mensual" gorm:"column:precio_mensual;default:0"`
PrecioPor1kTokens float64 `json:"precio_por_1k_tokens" gorm:"column:precio_por_1k_tokens;default:0"`
PrecioPorOCR float64 `json:"precio_por_ocr" gorm:"column:precio_por_ocr;default:0"`
PrecioPorTranscripcion float64 `json:"precio_por_transcripcion" gorm:"column:precio_por_transcripcion;default:0"`
Moneda string `json:"moneda" gorm:"column:moneda;size:3;default:'COP'"`
// TopeConsumoMensual solo dispara un aviso al superarse — no corta el
// servicio. 0 = sin tope.
TopeConsumoMensual float64 `json:"tope_consumo_mensual" gorm:"column:tope_consumo_mensual;default:0"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (UmindPlan) TableName() string { return "umind_planes" }
func CreateUmindPlan(p *UmindPlan) error {
return app.Http.Database.DB.Create(p).Error
}
func GetUmindPlanes() ([]UmindPlan, error) {
var items []UmindPlan
err := app.Http.Database.DB.Order("id ASC").Find(&items).Error
return items, err
}
func GetUmindPlanByID(id uint) (*UmindPlan, error) {
var p UmindPlan
if err := app.Http.Database.DB.First(&p, id).Error; err != nil {
return nil, err
}
return &p, nil
}
func UpdateUmindPlan(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindPlan{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindPlan(id uint) error {
return app.Http.Database.DB.Delete(&UmindPlan{}, id).Error
}
// GetPlanDeTenant devuelve el plan del tenant, o nil si no tiene uno asignado
// (tenants viejos). Los llamadores tratan nil como "sin límites ni precios".
func GetPlanDeTenant(tenantID uint) *UmindPlan {
t, err := GetUmindTenantByID(tenantID)
if err != nil || t.PlanID == nil {
return nil
}
p, err := GetUmindPlanByID(*t.PlanID)
if err != nil {
return nil
}
return p
}