El cobro recurrente (link de pago, webhook con firma, renovación automática de vencimiento) ya funcionaba de punta a punta; lo único que le faltaba para cobrar por uso era monto variable. - MontoACobrar = PrecioAcordado + consumo pendiente del cliente. Si el cliente no usa uMind el consumo es 0 y el monto queda idéntico al de antes, así que los contratos existentes no cambian. - El consumo se agrega por Cliente, no por tenant: un contrato es de un cliente y un cliente puede tener varios tenants. - El desglose (cuántos tokens, imágenes y transcripciones, y a cuánto) va como líneas de servicio, así las plantillas de correo existentes lo muestran sin tocarlas. Cobrar un monto variable sin decir de dónde sale es pedir una disputa. - Al confirmarse el pago se cierra el consumo (facturado_at). Solo si el UPDATE del contrato afectó filas: si el webhook llega dos veces, la segunda no vuelve a cerrar nada. - Test de que el detalle del correo suma exactamente lo que se cobra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
207 lines
7.3 KiB
Go
207 lines
7.3 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
|
|
}
|
|
|
|
// ─── 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)
|
|
}
|
|
}
|