308 lines
11 KiB
Go
308 lines
11 KiB
Go
package models
|
|
|
|
import (
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Contrato struct {
|
|
gorm.Model
|
|
ClienteID uint `json:"cliente_id" gorm:"column:cliente_id"`
|
|
Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
|
|
Servicios []Servicio `json:"servicios" gorm:"many2many:contrato_servicios"`
|
|
FechaInicio time.Time `json:"fecha_inicio" gorm:"column:fecha_inicio"`
|
|
FechaVencimiento time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"`
|
|
PrecioAcordado float64 `json:"precio_acordado" gorm:"column:precio_acordado"`
|
|
Moneda string `json:"moneda" gorm:"column:moneda;default:'COP'"`
|
|
Estado string `json:"estado" gorm:"column:estado;default:'activo'"` // activo | vencido | cancelado | renovado
|
|
AutoRenovar bool `json:"auto_renovar" gorm:"column:auto_renovar;default:false"`
|
|
Notas string `json:"notas" gorm:"column:notas"`
|
|
// Enlace de pago Bold: único por ciclo de pago.
|
|
// Se reutiliza en múltiples notificaciones del mismo ciclo.
|
|
// Se anula (vacía) cuando SALE_APPROVED llega y se registra el pago.
|
|
EnlacePago string `json:"enlace_pago" gorm:"column:enlace_pago;type:text"`
|
|
EnlacePagoLinkID string `json:"enlace_pago_link_id" gorm:"column:enlace_pago_link_id;type:varchar(64)"`
|
|
// Confirmación de pago
|
|
PagoConfirmado bool `json:"pago_confirmado" gorm:"column:pago_confirmado;default:false"`
|
|
FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"`
|
|
}
|
|
|
|
func (Contrato) TableName() string { return "contratos" }
|
|
|
|
func GetAllContratos(limit, offset int, search, estado string) ([]Contrato, int64, error) {
|
|
var items []Contrato
|
|
var total int64
|
|
db := app.Http.Database.DB.Model(&Contrato{}).
|
|
Preload("Cliente").Preload("Servicios")
|
|
if search != "" {
|
|
db = db.Joins("JOIN clientes ON clientes.id = contratos.cliente_id").
|
|
Where("clientes.nombre ILIKE ? OR clientes.empresa ILIKE ?",
|
|
"%"+search+"%", "%"+search+"%")
|
|
}
|
|
if estado != "" {
|
|
db = db.Where("contratos.estado = ?", estado)
|
|
}
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err := db.Order("contratos.fecha_vencimiento ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func GetContratoByID(id uint) (*Contrato, error) {
|
|
var item Contrato
|
|
if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios").First(&item, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
// GetUltimoContratoByCliente devuelve el contrato más reciente de un cliente
|
|
func GetUltimoContratoByCliente(clienteID uint) (*Contrato, error) {
|
|
var item Contrato
|
|
if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios").
|
|
Where("cliente_id = ?", clienteID).
|
|
Order("id DESC").First(&item).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
// GetContratosByCliente devuelve contratos de un cliente específico
|
|
func GetContratosByCliente(clienteID uint) ([]Contrato, error) {
|
|
var items []Contrato
|
|
if err := app.Http.Database.DB.Preload("Servicios").
|
|
Where("cliente_id = ?", clienteID).
|
|
Order("fecha_vencimiento ASC").Find(&items).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// GetContratosProximosVencer retorna contratos activos que vencen exactamente en N días
|
|
func GetContratosProximosVencer(diasAntes int) ([]Contrato, error) {
|
|
var items []Contrato
|
|
target := time.Now().AddDate(0, 0, diasAntes).UTC()
|
|
startOfDay := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.UTC)
|
|
endOfDay := startOfDay.Add(24 * time.Hour)
|
|
|
|
if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios").
|
|
Where("estado = 'activo' AND fecha_vencimiento >= ? AND fecha_vencimiento < ?", startOfDay, endOfDay).
|
|
Find(&items).Error; err != nil {
|
|
log.Printf("Error getting contratos proximos: %v", err)
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// GetContratosYaVencidos retorna contratos cuya fecha de vencimiento es hoy o anterior
|
|
// y su estado sigue siendo 'activo' (aún no han sido marcados como vencidos)
|
|
func GetContratosYaVencidos() ([]Contrato, error) {
|
|
var items []Contrato
|
|
hoy := time.Now().UTC()
|
|
startOfDay := time.Date(hoy.Year(), hoy.Month(), hoy.Day(), 0, 0, 0, 0, time.UTC)
|
|
|
|
if err := app.Http.Database.DB.Preload("Cliente").Preload("Servicios").
|
|
Where("estado = 'activo' AND fecha_vencimiento < ?", startOfDay).
|
|
Find(&items).Error; err != nil {
|
|
log.Printf("Error getting contratos vencidos: %v", err)
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// GetContratosConEnlacePendiente devuelve contratos que tienen un enlace de pago Bold generado
|
|
// pero cuyo pago aún no ha sido confirmado. Usado para polling periódico de la API de Bold.
|
|
func GetContratosConEnlacePendiente() ([]Contrato, error) {
|
|
var items []Contrato
|
|
if err := app.Http.Database.DB.Preload("Cliente").
|
|
Where("enlace_pago_link_id != '' AND pago_confirmado = false AND deleted_at IS NULL").
|
|
Find(&items).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// syncContratoServicios sincroniza la tabla join contrato_servicios usando SQL directo
|
|
// para evitar que GORM intente hacer upsert de los servicios existentes.
|
|
func syncContratoServicios(contratoID uint, servicioIDs []uint) error {
|
|
db := app.Http.Database.DB
|
|
if err := db.Exec("DELETE FROM contrato_servicios WHERE contrato_id = ?", contratoID).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, sid := range servicioIDs {
|
|
if err := db.Exec(
|
|
"INSERT INTO contrato_servicios (contrato_id, servicio_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
|
contratoID, sid,
|
|
).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func CreateContrato(c Contrato, servicioIDs []uint) error {
|
|
db := app.Http.Database.DB
|
|
if err := db.Omit("Servicios.*").Create(&c).Error; err != nil {
|
|
return err
|
|
}
|
|
return syncContratoServicios(c.ID, servicioIDs)
|
|
}
|
|
|
|
func UpdateContrato(c Contrato, servicioIDs []uint) error {
|
|
db := app.Http.Database.DB
|
|
if err := db.Model(&Contrato{}).Where("id = ?", c.ID).Updates(map[string]interface{}{
|
|
"cliente_id": c.ClienteID,
|
|
"fecha_inicio": c.FechaInicio,
|
|
"fecha_vencimiento": c.FechaVencimiento,
|
|
"precio_acordado": c.PrecioAcordado,
|
|
"moneda": c.Moneda,
|
|
"estado": c.Estado,
|
|
"auto_renovar": c.AutoRenovar,
|
|
"notas": c.Notas,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
if servicioIDs != nil {
|
|
return syncContratoServicios(c.ID, servicioIDs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func DeleteContrato(id uint) error {
|
|
db := app.Http.Database.DB
|
|
var c Contrato
|
|
if err := db.First(&c, id).Error; err != nil {
|
|
return err
|
|
}
|
|
db.Exec("DELETE FROM contrato_servicios WHERE contrato_id = ?", id)
|
|
return db.Delete(&c).Error
|
|
}
|
|
|
|
// GuardarEnlacePago persiste el enlace de pago Bold en el contrato.
|
|
func GuardarEnlacePago(contratoID uint, linkID, url string) error {
|
|
return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{
|
|
"enlace_pago": url,
|
|
"enlace_pago_link_id": linkID,
|
|
}).Error
|
|
}
|
|
|
|
// LimpiarEnlacePago borra el enlace vigente cuando se registra un pago aprobado,
|
|
// para que la próxima notificación genere un link nuevo.
|
|
func LimpiarEnlacePago(contratoID uint) error {
|
|
return app.Http.Database.DB.Model(&Contrato{}).Where("id = ?", contratoID).Updates(map[string]interface{}{
|
|
"enlace_pago": "",
|
|
"enlace_pago_link_id": "",
|
|
}).Error
|
|
}
|
|
|
|
// periodicidadAMeses convierte el string de periodicidad en número de meses.
|
|
func periodicidadAMeses(p string) int {
|
|
switch p {
|
|
case "mensual":
|
|
return 1
|
|
case "trimestral":
|
|
return 3
|
|
case "semestral":
|
|
return 6
|
|
case "anual":
|
|
return 12
|
|
default:
|
|
return 12
|
|
}
|
|
}
|
|
|
|
// MarcarContratoPagado marca el contrato como pagado y renueva la fecha de vencimiento.
|
|
// Retorna (true, nil) si el contrato pasó de no-pagado a pagado en esta llamada.
|
|
// Retorna (false, nil) si ya estaba pagado (idempotente).
|
|
// WHERE pago_confirmado = false garantiza atomicidad ante llamadas concurrentes.
|
|
func MarcarContratoPagado(contratoID uint) (bool, error) {
|
|
now := time.Now()
|
|
|
|
// Cargar contrato con servicios para calcular nueva fecha de vencimiento
|
|
var c Contrato
|
|
if err := app.Http.Database.DB.Preload("Servicios").First(&c, contratoID).Error; err != nil {
|
|
log.Printf("[PAGO] No se pudo cargar contrato %d para renovar: %v", contratoID, err)
|
|
// Fallback: solo marcar pago sin renovar fecha
|
|
result := app.Http.Database.DB.Model(&Contrato{}).
|
|
Where("id = ? AND pago_confirmado = false", contratoID).
|
|
Updates(map[string]interface{}{
|
|
"pago_confirmado": true,
|
|
"fecha_pago": now,
|
|
"enlace_pago": "",
|
|
"enlace_pago_link_id": "",
|
|
})
|
|
return result.RowsAffected > 0, result.Error
|
|
}
|
|
|
|
// Ya estaba pagado — nada que hacer
|
|
if c.PagoConfirmado {
|
|
return false, nil
|
|
}
|
|
|
|
updates := map[string]interface{}{
|
|
"pago_confirmado": true,
|
|
"fecha_pago": now,
|
|
"enlace_pago": "",
|
|
"enlace_pago_link_id": "",
|
|
"estado": "activo",
|
|
}
|
|
|
|
// Buscar el servicio renovable con mayor período para calcular nueva fecha
|
|
meses := 0
|
|
for _, s := range c.Servicios {
|
|
if s.Tipo == "renovable" {
|
|
if m := periodicidadAMeses(s.Periodicidad); m > meses {
|
|
meses = m
|
|
}
|
|
}
|
|
}
|
|
if meses > 0 {
|
|
// Extender desde la fecha de vencimiento actual; si ya venció, extender desde hoy
|
|
base := c.FechaVencimiento
|
|
if base.Before(now) {
|
|
base = now
|
|
}
|
|
nuevaFecha := base.AddDate(0, meses, 0)
|
|
updates["fecha_vencimiento"] = nuevaFecha
|
|
log.Printf("[PAGO] Contrato %d renovado: %s → %s (%d meses)",
|
|
contratoID,
|
|
c.FechaVencimiento.Format("02/01/2006"),
|
|
nuevaFecha.Format("02/01/2006"),
|
|
meses,
|
|
)
|
|
}
|
|
|
|
result := app.Http.Database.DB.Model(&Contrato{}).
|
|
Where("id = ? AND pago_confirmado = false", contratoID).
|
|
Updates(updates)
|
|
return result.RowsAffected > 0, result.Error
|
|
}
|
|
|
|
// GetEstadoPago devuelve si el contrato tiene pago confirmado (para polling desde el frontend).
|
|
func GetEstadoPago(contratoID uint) (bool, *time.Time, error) {
|
|
var c Contrato
|
|
if err := app.Http.Database.DB.Select("pago_confirmado", "fecha_pago").First(&c, contratoID).Error; err != nil {
|
|
return false, nil, err
|
|
}
|
|
return c.PagoConfirmado, c.FechaPago, nil
|
|
}
|
|
|
|
// GetContratoParaVerificacion devuelve los campos necesarios para verificar el pago directamente
|
|
// con la pasarela (pago_confirmado, enlace_pago_link_id). Preload de Cliente para poder usar el email.
|
|
func GetContratoParaVerificacion(contratoID uint) (*Contrato, error) {
|
|
var c Contrato
|
|
if err := app.Http.Database.DB.
|
|
Preload("Cliente").
|
|
Select("contratos.id", "contratos.pago_confirmado", "contratos.fecha_pago", "contratos.enlace_pago_link_id", "contratos.cliente_id").
|
|
First(&c, contratoID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &c, nil
|
|
}
|