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 } // ─── 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) } }