feat(cobro): suma el consumo de uMind al ciclo de cobro existente

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>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 11:53:40 -05:00
co-authored by Claude Sonnet 5
parent 5b78f6677c
commit 023a8af494
4 changed files with 190 additions and 4 deletions
+58 -4
View File
@@ -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