diff --git a/pkg/models/contrato.go b/pkg/models/contrato.go index 044e519..32e6422 100644 --- a/pkg/models/contrato.go +++ b/pkg/models/contrato.go @@ -361,6 +361,11 @@ func MarcarContratoPagado(contratoID uint) (bool, error) { result := app.Http.Database.DB.Model(&Contrato{}). Where("id = ? AND pago_confirmado = false", contratoID). Updates(updates) + if result.Error == nil && result.RowsAffected > 0 { + // Solo si el UPDATE realmente cambió algo: si el webhook llega dos + // veces, la segunda no entra acá y el consumo no se cierra de nuevo. + MarcarUsoFacturadoPorCliente(c.ClienteID) + } return result.RowsAffected > 0, result.Error } diff --git a/pkg/models/umind_uso.go b/pkg/models/umind_uso.go index d249bbe..62e906c 100644 --- a/pkg/models/umind_uso.go +++ b/pkg/models/umind_uso.go @@ -133,3 +133,74 @@ func MarcarUsoFacturado(tenantID uint) error { 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) + } +} diff --git a/pkg/services/renovacion_consumo_test.go b/pkg/services/renovacion_consumo_test.go new file mode 100644 index 0000000..25da316 --- /dev/null +++ b/pkg/services/renovacion_consumo_test.go @@ -0,0 +1,56 @@ +package services + +import ( + "strings" + "testing" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// El detalle que va en el correo tiene que sumar exactamente lo que cobra el +// link de pago. Si esto se desincroniza, el cliente recibe un cobro que no +// coincide con su propio desglose — una disputa asegurada. +func TestItemsDesdeResumenCuadraConElTotal(t *testing.T) { + resumen := []models.ResumenUso{ + {Tipo: models.UsoTipoIA, Unidad: "tokens", Cantidad: 12000, Costo: 30, Eventos: 8}, + {Tipo: models.UsoTipoOCR, Unidad: "imagen", Cantidad: 4, Costo: 40, Eventos: 4}, + {Tipo: models.UsoTipoWhisper, Unidad: "transcripcion", Cantidad: 2, Costo: 80, Eventos: 2}, + } + items := itemsDesdeResumen(resumen, time.Now()) + + if len(items) != 3 { + t.Fatalf("esperaba 3 líneas de detalle, obtuve %d", len(items)) + } + var total float64 + for _, it := range items { + total += it.Precio + } + if total != 150 { + t.Errorf("el detalle suma %.2f, esperaba 150 (30+40+80)", total) + } + // La etiqueta tiene que decir cuánto se consumió, no solo el precio. + if !strings.Contains(items[0].Nombre, "12000") || !strings.Contains(items[0].Nombre, "tokens") { + t.Errorf("la línea de IA no dice la cantidad consumida: %q", items[0].Nombre) + } +} + +// Un consumo en cero no debe aparecer como línea de $0 en el correo. +func TestItemsDesdeResumenOmiteCostoCero(t *testing.T) { + resumen := []models.ResumenUso{ + {Tipo: models.UsoTipoIA, Unidad: "tokens", Cantidad: 500, Costo: 0}, + {Tipo: models.UsoTipoOCR, Unidad: "imagen", Cantidad: 1, Costo: 10}, + } + items := itemsDesdeResumen(resumen, time.Now()) + if len(items) != 1 { + t.Fatalf("esperaba 1 línea (la de costo 0 se omite), obtuve %d", len(items)) + } +} + +// Sin consumo no hay líneas extra: el correo queda igual que antes de que +// existiera el cobro por uso. +func TestItemsDesdeResumenVacio(t *testing.T) { + if items := itemsDesdeResumen(nil, time.Now()); len(items) != 0 { + t.Errorf("sin consumo no debería agregar líneas, obtuve %d", len(items)) + } +} diff --git a/pkg/services/renovacion_service.go b/pkg/services/renovacion_service.go index e3494c7..4dd1611 100644 --- a/pkg/services/renovacion_service.go +++ b/pkg/services/renovacion_service.go @@ -121,13 +121,14 @@ func EnviarCorreoManual(contrato *models.Contrato) error { FechaVenc: contrato.FechaVencimiento.Format("02/01/2006"), }) } + items = append(items, itemsConsumoUmind(contrato.ClienteID, contrato.FechaVencimiento)...) datos := DatosPlantilla{ ClienteNombre: contrato.Cliente.Nombre, ClienteEmpresa: contrato.Cliente.Empresa, ClienteEmail: contrato.Cliente.Email, FechaVencimiento: contrato.FechaVencimiento.Format("02/01/2006"), DiasRestantes: dias, - Total: contrato.PrecioAcordado, + Total: MontoACobrar(contrato), Servicios: items, EnlacePago: enlacePago, } @@ -200,6 +201,14 @@ func EnviarNotificacionGrupo(regla *models.NotificacionRegla, cliente *models.Cl enlacePago = ObtenerOCrearEnlacePago(&contratos[0], regla.PasarelaEnlace) } + // El consumo es del cliente, no de un contrato puntual: se suma una sola + // vez al grupo, igual que lo cobra ObtenerOCrearEnlacePago. + consumo := itemsConsumoUmind(cliente.ID, fechaVenc) + items = append(items, consumo...) + for _, it := range consumo { + total += it.Precio + } + datos := DatosPlantilla{ ClienteNombre: cliente.Nombre, ClienteEmpresa: cliente.Empresa, @@ -313,6 +322,51 @@ func EnviarCorreoConfirmacionPago(contratoID uint, pasarela ...string) { // gateway puede ser "bold", "dlocal" o "ninguna"/"". // Si ya existe un enlace guardado, lo reutiliza (mismo ciclo). // Cuando el pago se aprueba, llamar models.LimpiarEnlacePago para el siguiente ciclo. +// MontoACobrar es la mensualidad del contrato más el consumo de uMind que +// todavía no se facturó. Antes el monto era fijo (PrecioAcordado); esto es lo +// único que hacía falta agregarle al ciclo de cobro que ya existía para +// soportar cobro por uso. +// +// Si el cliente no usa uMind el consumo es 0 y el monto queda idéntico a +// antes, así que no cambia nada para los contratos que ya venían andando. +func MontoACobrar(contrato *models.Contrato) float64 { + return contrato.PrecioAcordado + models.ConsumoPendientePorCliente(contrato.ClienteID) +} + +// itemsConsumoUmind desglosa el consumo pendiente como líneas de servicio, para +// que las plantillas de correo ya existentes lo muestren sin tocarlas. Cobrar +// un monto variable sin decir de dónde sale es pedir una disputa. +func itemsConsumoUmind(clienteID uint, fechaVenc time.Time) []ItemServicio { + return itemsDesdeResumen(models.ResumenPendientePorCliente(clienteID), fechaVenc) +} + +// itemsDesdeResumen es la parte pura: la suma de los Precio que devuelve tiene +// que dar exactamente el consumo que se le cobra al cliente, o el correo dice +// una cosa y el link cobra otra. +func itemsDesdeResumen(resumen []models.ResumenUso, fechaVenc time.Time) []ItemServicio { + etiquetas := map[string]string{ + models.UsoTipoIA: "Consumo de IA (%.0f %s)", + models.UsoTipoOCR: "Lectura de imágenes (%.0f %s)", + models.UsoTipoWhisper: "Transcripción de audios (%.0f %s)", + } + var items []ItemServicio + for _, r := range resumen { + if r.Costo <= 0 { + continue + } + etiqueta, ok := etiquetas[r.Tipo] + if !ok { + etiqueta = r.Tipo + " (%.0f %s)" + } + items = append(items, ItemServicio{ + Nombre: fmt.Sprintf(etiqueta, r.Cantidad, r.Unidad), + Precio: r.Costo, + FechaVenc: fechaVenc.Format("02/01/2006"), + }) + } + return items +} + func ObtenerOCrearEnlacePago(contrato *models.Contrato, gateway string) string { // Normalizar gateway vacío → bold por defecto if gateway == "" { @@ -351,7 +405,7 @@ func obtenerEnlacePaypal(contrato *models.Contrato) string { } referencia := fmt.Sprintf("contrato-%d", contrato.ID) - orderID, urlAprobacion, err := PaypalCrearOrden(cfg, referencia, desc, monedaDeContrato(contrato), contrato.PrecioAcordado) + orderID, urlAprobacion, err := PaypalCrearOrden(cfg, referencia, desc, monedaDeContrato(contrato), MontoACobrar(contrato)) if err != nil { log.Printf("[PAYPAL] error creando orden para contrato %d: %v", contrato.ID, err) return "" @@ -390,7 +444,7 @@ func obtenerEnlaceBold(contrato *models.Contrato) string { AmountType: "CLOSE", Amount: BoldAmountField{ Currency: monedaDeContrato(contrato), - TotalAmount: int64(math.Round(contrato.PrecioAcordado)), + TotalAmount: int64(math.Round(MontoACobrar(contrato))), }, Description: desc, Reference: fmt.Sprintf("contrato-%d", contrato.ID), @@ -425,7 +479,7 @@ func obtenerEnlaceDlocal(contrato *models.Contrato) string { referencia := fmt.Sprintf("contrato-%d", contrato.ID) pagoReq := PagoRequest{ Currency: monedaDeContrato(contrato), - Amount: contrato.PrecioAcordado, + Amount: MontoACobrar(contrato), OrderID: referencia, Description: desc, // Las URLs de retorno y notificación son las del panel, no las de la API