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{}).
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user