From 97e66b82e3fe1a4e296667a0114d9c4ba2bbdf5f Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:20:54 -0500 Subject: [PATCH] =?UTF-8?q?fix(umind):=20el=20aviso=20de=20tope=20se=20rep?= =?UTF-8?q?et=C3=ADa=20en=20cada=20despliegue=20y=20el=20cliente=20no=20se?= =?UTF-8?q?=20enteraba?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qué pasa cuando un cliente supera el tope de consumo de su plan: el servicio sigue funcionando y el excedente se factura en el próximo ciclo. Es la decisión de diseño y no cambia — cortarle el asistente a un negocio en medio de una conversación con un cliente suyo es peor que la factura. Lo que sí estaba mal es cómo se avisaba. El control de "a este ya le avisé" vivía en memoria, con un ponytail: que asumía reinicios raros. Con despliegue automático en cada push, ese flag se borra varias veces por día: el mismo cliente pasado de tope recibía el aviso de nuevo en el siguiente mensaje, y otra vez, y otra. Ahora se marca en la base, con un UPDATE condicionado que además lo hace atómico — dos mensajes que crucen el tope a la vez, o dos réplicas del proceso, avisan una sola vez. Y el aviso iba sólo al Telegram del staff. El dueño del negocio, que es el que va a recibir la factura con el excedente, no se enteraba por ningún lado: tenía que entrar al panel a mirar. Ahora le llega un correo que dice cuánto lleva consumido, cuánto incluye su plan y —lo más importante— que su asistente sigue funcionando con normalidad. Co-Authored-By: Claude Opus 5 --- pkg/models/umind.go | 17 +++++++++ pkg/services/umind_uso_service.go | 59 +++++++++++++++++++---------- pkg/services/umind_uso_tope_test.go | 59 +++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 21 deletions(-) create mode 100644 pkg/services/umind_uso_tope_test.go 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 +}