diff --git a/pkg/models/umind.go b/pkg/models/umind.go index 38c47c2..8c4ee1a 100644 --- a/pkg/models/umind.go +++ b/pkg/models/umind.go @@ -31,6 +31,23 @@ type UmindTenant struct { // 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"` + // UltimoAvisoTope guarda el período ("2026-08") en que se avisó que se + // pasó el tope de consumo, para no repetirlo por cada mensaje. En la base + // y no en memoria: con despliegues varias veces al día, un flag en RAM se + // borra seguido y el cliente recibe el mismo aviso una y otra vez. + UltimoAvisoTope string `json:"ultimo_aviso_tope" gorm:"column:ultimo_aviso_tope;size:7"` +} + +// MarcarAvisoTope deja registrado que ya se avisó en ese período. Devuelve +// false si el aviso ya estaba puesto, que es la señal de no volver a mandarlo. +// +// El UPDATE condicionado es lo que hace la operación atómica: si dos mensajes +// cruzan el tope a la vez, sólo uno afecta una fila y sólo ese avisa. +func MarcarAvisoTope(tenantID uint, periodo string) bool { + res := app.Http.Database.DB.Model(&UmindTenant{}). + Where("id = ? AND (ultimo_aviso_tope IS NULL OR ultimo_aviso_tope <> ?)", tenantID, periodo). + Update("ultimo_aviso_tope", periodo) + return res.Error == nil && res.RowsAffected > 0 } func (UmindTenant) TableName() string { return "umind_tenants" } diff --git a/pkg/services/umind_uso_service.go b/pkg/services/umind_uso_service.go index 43873f0..a969902 100644 --- a/pkg/services/umind_uso_service.go +++ b/pkg/services/umind_uso_service.go @@ -3,24 +3,11 @@ package services import ( "fmt" "log" - "sync" "time" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" ) -// avisosTope recuerda a qué tenants ya se les avisó en el ciclo actual, para -// no mandar un aviso por cada mensaje una vez pasado el tope. Se pierde al -// reiniciar (y entonces se vuelve a avisar una vez): aceptable para un aviso, -// y mucho más simple que una columna de estado en el tenant. -// -// ponytail: en memoria y por proceso. Si algún día corren varias réplicas, -// cada una avisa una vez — mover el flag a la BD si eso molesta. -var avisosTope struct { - sync.Mutex - ultimo map[uint]string // tenantID → "2026-08" del último aviso -} - // RegistrarUso persiste el consumo y avisa si el tenant superó el tope de su // plan. El aviso NO corta el servicio: el agente sigue respondiendo. func RegistrarUso(agenteID uint, tipo string, cantidad float64, unidad string) { @@ -65,14 +52,9 @@ func cicloActual() (time.Time, time.Time) { func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, desde time.Time) { periodo := desde.Format("2006-01") - avisosTope.Lock() - if avisosTope.ultimo == nil { - avisosTope.ultimo = map[uint]string{} - } - yaAvisado := avisosTope.ultimo[tenantID] == periodo - avisosTope.ultimo[tenantID] = periodo - avisosTope.Unlock() - if yaAvisado { + // El marcado va en la base y de forma atómica: dos mensajes que crucen el + // tope al mismo tiempo (o dos réplicas del proceso) avisan una sola vez. + if !models.MarcarAvisoTope(tenantID, periodo) { return } @@ -89,4 +71,39 @@ func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, des "📊 Tope de consumo superado\nCliente: %s\nPlan: %s\nConsumo del mes: %s %.2f (tope %s %.2f)\n\nEl servicio sigue activo — el excedente se cobra en el próximo ciclo.", escapeTelegramHTML(nombre), escapeTelegramHTML(plan.Nombre), plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual)) + + avisarTopeAlCliente(tenantID, plan, total) +} + +// avisarTopeAlCliente le manda el aviso al dueño del espacio. Enterarse recién +// en la factura de que se pasó del tope es la clase de sorpresa que hace +// perder un cliente, y hasta ahora sólo se avisaba al staff. +func avisarTopeAlCliente(tenantID uint, plan *models.UmindPlan, total float64) { + tenant, err := models.GetUmindTenantByID(tenantID) + if err != nil || tenant.ClienteID == nil { + return + } + usuarios, err := models.GetPortalUsersByClienteID(*tenant.ClienteID) + if err != nil || len(usuarios) == 0 { + return + } + + asunto := "Tu asistente superó el consumo incluido en el plan" + cuerpo := fmt.Sprintf(`
Hola,
+Tu asistente %s superó el consumo incluido en el plan %s este mes.
+Consumo hasta ahora: %s %.2f — incluido en el plan: %s %.2f.
+El asistente sigue funcionando con normalidad. El excedente se factura en el próximo ciclo. Podés ver el detalle en tu panel, en Consumo.
`, + tenant.Nombre, plan.Nombre, plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual) + + for _, u := range usuarios { + if u.Email == "" { + continue + } + destino := u.Email + go func() { + if err := soporteSendMail(destino, asunto, cuerpo); err != nil { + log.Printf("[UMIND_USO] No se pudo avisar del tope a %s: %v", destino, err) + } + }() + } } diff --git a/pkg/services/umind_uso_tope_test.go b/pkg/services/umind_uso_tope_test.go new file mode 100644 index 0000000..570136f --- /dev/null +++ b/pkg/services/umind_uso_tope_test.go @@ -0,0 +1,59 @@ +package services + +import ( + "os" + "strings" + "testing" + "time" +) + +// El período del aviso sale del mes calendario. Si se calculara mal, el aviso +// se repetiría todos los meses el día 1 o no se repetiría nunca más. +func TestElPeriodoDelCicloEsElMesCalendario(t *testing.T) { + desde, hasta := cicloActual() + + if desde.Day() != 1 || desde.Hour() != 0 || desde.Minute() != 0 { + t.Errorf("el ciclo tiene que arrancar el día 1 a las 00:00, arrancó %v", desde) + } + if !hasta.After(desde) { + t.Errorf("el fin del ciclo (%v) tiene que ser posterior al inicio (%v)", hasta, desde) + } + if hasta.Sub(desde) < 28*24*time.Hour || hasta.Sub(desde) > 31*24*time.Hour { + t.Errorf("el ciclo dura %v, fuera del rango de un mes", hasta.Sub(desde)) + } + + // El período que se guarda para no repetir el aviso. + periodo := desde.Format("2006-01") + if len(periodo) != 7 || !strings.Contains(periodo, "-") { + t.Errorf("el período tiene que ser YYYY-MM, es %q", periodo) + } + // Tiene que entrar en la columna, que es size:7. + if len(periodo) > 7 { + t.Errorf("el período %q no entra en la columna de 7 caracteres", periodo) + } +} + +// El aviso se manda una sola vez por mes. Antes el control estaba en memoria y +// cada despliegue lo borraba — con varios despliegues por día, el mismo cliente +// recibía el aviso una y otra vez. +func TestElControlDelAvisoNoViveEnMemoria(t *testing.T) { + fuente, err := leerFuente("umind_uso_service.go") + if err != nil { + t.Fatal(err) + } + if strings.Contains(fuente, "avisosTope") { + t.Error("quedó el flag en memoria: un reinicio vuelve a disparar el aviso") + } + if !strings.Contains(fuente, "MarcarAvisoTope") { + t.Error("el aviso tiene que marcarse en la base para sobrevivir a un reinicio") + } + // Y el cliente tiene que enterarse, no sólo el staff. + if !strings.Contains(fuente, "avisarTopeAlCliente") { + t.Error("falta el aviso al cliente: enterarse en la factura es la peor forma") + } +} + +func leerFuente(nombre string) (string, error) { + b, err := os.ReadFile(nombre) + return string(b), err +}