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:
co-authored by
Claude Sonnet 5
parent
5b78f6677c
commit
023a8af494
@@ -361,6 +361,11 @@ func MarcarContratoPagado(contratoID uint) (bool, error) {
|
|||||||
result := app.Http.Database.DB.Model(&Contrato{}).
|
result := app.Http.Database.DB.Model(&Contrato{}).
|
||||||
Where("id = ? AND pago_confirmado = false", contratoID).
|
Where("id = ? AND pago_confirmado = false", contratoID).
|
||||||
Updates(updates)
|
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
|
return result.RowsAffected > 0, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,3 +133,74 @@ func MarcarUsoFacturado(tenantID uint) error {
|
|||||||
Where("tenant_id = ? AND facturado_at IS NULL", tenantID).
|
Where("tenant_id = ? AND facturado_at IS NULL", tenantID).
|
||||||
Update("facturado_at", ahora).Error
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,13 +121,14 @@ func EnviarCorreoManual(contrato *models.Contrato) error {
|
|||||||
FechaVenc: contrato.FechaVencimiento.Format("02/01/2006"),
|
FechaVenc: contrato.FechaVencimiento.Format("02/01/2006"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
items = append(items, itemsConsumoUmind(contrato.ClienteID, contrato.FechaVencimiento)...)
|
||||||
datos := DatosPlantilla{
|
datos := DatosPlantilla{
|
||||||
ClienteNombre: contrato.Cliente.Nombre,
|
ClienteNombre: contrato.Cliente.Nombre,
|
||||||
ClienteEmpresa: contrato.Cliente.Empresa,
|
ClienteEmpresa: contrato.Cliente.Empresa,
|
||||||
ClienteEmail: contrato.Cliente.Email,
|
ClienteEmail: contrato.Cliente.Email,
|
||||||
FechaVencimiento: contrato.FechaVencimiento.Format("02/01/2006"),
|
FechaVencimiento: contrato.FechaVencimiento.Format("02/01/2006"),
|
||||||
DiasRestantes: dias,
|
DiasRestantes: dias,
|
||||||
Total: contrato.PrecioAcordado,
|
Total: MontoACobrar(contrato),
|
||||||
Servicios: items,
|
Servicios: items,
|
||||||
EnlacePago: enlacePago,
|
EnlacePago: enlacePago,
|
||||||
}
|
}
|
||||||
@@ -200,6 +201,14 @@ func EnviarNotificacionGrupo(regla *models.NotificacionRegla, cliente *models.Cl
|
|||||||
enlacePago = ObtenerOCrearEnlacePago(&contratos[0], regla.PasarelaEnlace)
|
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{
|
datos := DatosPlantilla{
|
||||||
ClienteNombre: cliente.Nombre,
|
ClienteNombre: cliente.Nombre,
|
||||||
ClienteEmpresa: cliente.Empresa,
|
ClienteEmpresa: cliente.Empresa,
|
||||||
@@ -313,6 +322,51 @@ func EnviarCorreoConfirmacionPago(contratoID uint, pasarela ...string) {
|
|||||||
// gateway puede ser "bold", "dlocal" o "ninguna"/"".
|
// gateway puede ser "bold", "dlocal" o "ninguna"/"".
|
||||||
// Si ya existe un enlace guardado, lo reutiliza (mismo ciclo).
|
// Si ya existe un enlace guardado, lo reutiliza (mismo ciclo).
|
||||||
// Cuando el pago se aprueba, llamar models.LimpiarEnlacePago para el siguiente 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 {
|
func ObtenerOCrearEnlacePago(contrato *models.Contrato, gateway string) string {
|
||||||
// Normalizar gateway vacío → bold por defecto
|
// Normalizar gateway vacío → bold por defecto
|
||||||
if gateway == "" {
|
if gateway == "" {
|
||||||
@@ -351,7 +405,7 @@ func obtenerEnlacePaypal(contrato *models.Contrato) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
referencia := fmt.Sprintf("contrato-%d", contrato.ID)
|
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 {
|
if err != nil {
|
||||||
log.Printf("[PAYPAL] error creando orden para contrato %d: %v", contrato.ID, err)
|
log.Printf("[PAYPAL] error creando orden para contrato %d: %v", contrato.ID, err)
|
||||||
return ""
|
return ""
|
||||||
@@ -390,7 +444,7 @@ func obtenerEnlaceBold(contrato *models.Contrato) string {
|
|||||||
AmountType: "CLOSE",
|
AmountType: "CLOSE",
|
||||||
Amount: BoldAmountField{
|
Amount: BoldAmountField{
|
||||||
Currency: monedaDeContrato(contrato),
|
Currency: monedaDeContrato(contrato),
|
||||||
TotalAmount: int64(math.Round(contrato.PrecioAcordado)),
|
TotalAmount: int64(math.Round(MontoACobrar(contrato))),
|
||||||
},
|
},
|
||||||
Description: desc,
|
Description: desc,
|
||||||
Reference: fmt.Sprintf("contrato-%d", contrato.ID),
|
Reference: fmt.Sprintf("contrato-%d", contrato.ID),
|
||||||
@@ -425,7 +479,7 @@ func obtenerEnlaceDlocal(contrato *models.Contrato) string {
|
|||||||
referencia := fmt.Sprintf("contrato-%d", contrato.ID)
|
referencia := fmt.Sprintf("contrato-%d", contrato.ID)
|
||||||
pagoReq := PagoRequest{
|
pagoReq := PagoRequest{
|
||||||
Currency: monedaDeContrato(contrato),
|
Currency: monedaDeContrato(contrato),
|
||||||
Amount: contrato.PrecioAcordado,
|
Amount: MontoACobrar(contrato),
|
||||||
OrderID: referencia,
|
OrderID: referencia,
|
||||||
Description: desc,
|
Description: desc,
|
||||||
// Las URLs de retorno y notificación son las del panel, no las de la API
|
// Las URLs de retorno y notificación son las del panel, no las de la API
|
||||||
|
|||||||
Reference in New Issue
Block a user