diff --git a/Dockerfile b/Dockerfile index dc25426..6025c29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,13 @@ COPY . . RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o apiv2 . +# Compilar agente para múltiples plataformas +RUN cd agent && go mod download && \ + mkdir -p /app/agent/dist && \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/agent/dist/usite-agent-linux-amd64 . && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o /app/agent/dist/usite-agent-linux-arm64 . && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -ldflags="-s -w" -o /app/agent/dist/usite-agent-linux-arm . + # ────────────────────────────────────────── # Stage 3: Imagen final mínima # ────────────────────────────────────────── diff --git a/main.go b/main.go index 5ea273f..03b2b0e 100755 --- a/main.go +++ b/main.go @@ -91,6 +91,17 @@ func main() { &models.Servidor{}, &models.ConxDb{}, &models.ProvServidor{}, + // WebSMS (LabsMobile) + &models.WebSmsConfig{}, + &models.WebSmsLog{}, + &models.WebSmsWebhookLog{}, + // Contabilidad + &models.Cuenta{}, + &models.Entidad{}, + &models.Transaccion{}, + &models.CuentaCobro{}, + &models.CuentaPagar{}, + &models.ConsolidadoMensual{}, ) // Seed automático (idempotente) de módulos del sistema migrations.SeedRenovaciones() @@ -105,6 +116,9 @@ func main() { migrations.SeedNotifDefaults() migrations.SeedShield() migrations.SeedPartnerRecursos() + models.SeedContabilidad() + migrations.SeedContabilidadMenu() + migrations.SeedWebSms() // Iniciar cron de vencimientos services.IniciarCron() defer services.DetenerCron() diff --git a/migrations/migrate.go b/migrations/migrate.go index 2fc9d5f..182a513 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -912,3 +912,90 @@ func SeedPartnerRecursos() { } log.Println("[SEED] Seed de Partner Recursos completado.") } + +// SeedContabilidadMenu crea el módulo "Contabilidad" y sus submódulos en el menú lateral. +func SeedContabilidadMenu() { + db := app.Http.Database.DB + var modulo models.Modules + result := db.Where("title = ?", "Contabilidad").First(&modulo) + if result.Error != nil { + modulo = models.Modules{ + Title: "Contabilidad", + Description: "Gestión contable: ingresos, egresos, cuentas por cobrar/pagar", + ModifiedAt: time.Now(), + } + if err := db.Create(&modulo).Error; err != nil { + log.Printf("[SEED] Error creando módulo Contabilidad: %v", err) + return + } + log.Printf("[SEED] Módulo 'Contabilidad' creado con ID %d", modulo.ID) + } else { + log.Printf("[SEED] Módulo 'Contabilidad' ya existe (ID %d)", modulo.ID) + } + entries := []struct{ title, desc, url string }{ + {"Dashboard", "Resumen mensual", "/app/contabilidad"}, + {"Transacciones", "Ingresos y egresos", "/app/contabilidad/transacciones"}, + {"Categorías", "Plan de cuentas", "/app/contabilidad/cuentas"}, + {"Entidades", "Clientes y proveedores", "/app/contabilidad/entidades"}, + {"Cuentas por cobrar", "Pendientes de cobro", "/app/contabilidad/cuentas-cobro"}, + {"Cuentas por pagar", "Pendientes de pago", "/app/contabilidad/cuentas-pagar"}, + } + var insertados []models.Submodules + for _, e := range entries { + var sub models.Submodules + if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil { + sub = models.Submodules{ + Title: e.title, Description: e.desc, Url: e.url, + ModuleId: modulo.ID, ModifiedAt: time.Now(), + } + if err := db.Create(&sub).Error; err != nil { + log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err) + continue + } + } else if sub.ModuleId != modulo.ID { + db.Model(&sub).Update("module_id", modulo.ID) + } + insertados = append(insertados, sub) + } + var roles []models.Roles + db.Find(&roles) + for _, rol := range roles { + db.Model(&rol).Association("Submodules").Append(&insertados) + } + log.Println("[SEED] Seed de Contabilidad completado.") +} + +// SeedWebSms crea el submódulo de WebSMS bajo "Integraciones". +func SeedWebSms() { + db := app.Http.Database.DB + var modulo models.Modules + if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil { + log.Println("[SEED] Módulo 'Integraciones' no encontrado, se creará") + modulo = models.Modules{ + Title: "Integraciones", Description: "Conexión con servicios externos", + ModifiedAt: time.Now(), + } + db.Create(&modulo) + } + url := "/app/websms" + var sub models.Submodules + if err := db.Where("url = ?", url).First(&sub).Error; err != nil { + sub = models.Submodules{ + Title: "WebSMS", Description: "Notificaciones SMS vía LabsMobile", + Url: url, ModuleId: modulo.ID, ModifiedAt: time.Now(), + } + if err := db.Create(&sub).Error; err != nil { + log.Printf("[SEED] Error creando submódulo WebSMS: %v", err) + return + } + log.Printf("[SEED] Submódulo 'WebSMS' creado") + } else if sub.ModuleId != modulo.ID { + db.Model(&sub).Update("module_id", modulo.ID) + } + var roles []models.Roles + db.Find(&roles) + for _, rol := range roles { + db.Model(&rol).Association("Submodules").Append(&[]models.Submodules{sub}) + } + log.Println("[SEED] Seed de WebSMS completado.") +} diff --git a/pkg/models/contabilidad.go b/pkg/models/contabilidad.go new file mode 100644 index 0000000..48dd364 --- /dev/null +++ b/pkg/models/contabilidad.go @@ -0,0 +1,504 @@ +package models + +import ( + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// ─── Cuenta (plan de cuentas / categoría contable) ─────────────────────────── + +type Cuenta struct { + gorm.Model + Codigo string `json:"codigo" gorm:"column:codigo;size:20"` + Nombre string `json:"nombre" gorm:"column:nombre;size:255;not null"` + Tipo string `json:"tipo" gorm:"column:tipo;size:20;not null"` // ingreso | egreso + Color string `json:"color" gorm:"column:color;size:7"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` +} + +func (Cuenta) TableName() string { return "contab_cuentas" } + +// ─── Entidad (cliente, proveedor, empresa, persona) ────────────────────────── + +type Entidad struct { + gorm.Model + Nombre string `json:"nombre" gorm:"column:nombre;size:255;not null"` + Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // cliente | proveedor | ambos + Documento string `json:"documento" gorm:"column:documento;size:50"` + Email string `json:"email" gorm:"column:email;size:255"` + Telefono string `json:"telefono" gorm:"column:telefono;size:50"` + Contacto string `json:"contacto" gorm:"column:contacto;size:255"` + Notas string `json:"notas" gorm:"column:notas;type:text"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` +} + +func (Entidad) TableName() string { return "contab_entidades" } + +// ─── Transaccion (ingreso / egreso) ────────────────────────────────────────── + +type Transaccion struct { + gorm.Model + Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"` + Tipo string `json:"tipo" gorm:"column:tipo;size:10;not null"` // ingreso | egreso + Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` + Valor float64 `json:"valor" gorm:"column:valor;not null"` + CuentaID *uint `json:"cuenta_id" gorm:"column:cuenta_id;index"` + Cuenta *Cuenta `json:"cuenta" gorm:"foreignKey:CuentaID"` + EntidadID *uint `json:"entidad_id" gorm:"column:entidad_id;index"` + Entidad *Entidad `json:"entidad" gorm:"foreignKey:EntidadID"` + FormaPago string `json:"forma_pago" gorm:"column:forma_pago;size:30"` // transferencia | efectivo | tarjeta | cheque | otro + Estado string `json:"estado" gorm:"column:estado;size:20;default:'registrada'"` + Notas string `json:"notas" gorm:"column:notas;type:text"` +} + +func (Transaccion) TableName() string { return "contab_transacciones" } + +// ─── CuentaCobro (cuentas por cobrar) ──────────────────────────────────────── + +type CuentaCobro struct { + gorm.Model + EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"` + Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"` + Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"` + Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` + Valor float64 `json:"valor" gorm:"column:valor;not null"` + Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial + FechaVencimiento *time.Time `json:"fecha_vencimiento" gorm:"column:fecha_vencimiento"` + FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"` + TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"` + Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"` + Notas string `json:"notas" gorm:"column:notas;type:text"` +} + +func (CuentaCobro) TableName() string { return "contab_cuentas_cobro" } + +// ─── CuentaPagar (cuentas por pagar) ───────────────────────────────────────── + +type CuentaPagar struct { + gorm.Model + EntidadID uint `json:"entidad_id" gorm:"column:entidad_id;index;not null"` + Entidad Entidad `json:"entidad" gorm:"foreignKey:EntidadID"` + Fecha time.Time `json:"fecha" gorm:"column:fecha;not null"` + Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` + Valor float64 `json:"valor" gorm:"column:valor;not null"` + Vencimiento *time.Time `json:"vencimiento" gorm:"column:vencimiento"` + Estado string `json:"estado" gorm:"column:estado;size:20;default:'pendiente'"` // pendiente | pagado | parcial + FechaPago *time.Time `json:"fecha_pago" gorm:"column:fecha_pago"` + TransaccionID *uint `json:"transaccion_id" gorm:"column:transaccion_id"` + Transaccion *Transaccion `json:"transaccion" gorm:"foreignKey:TransaccionID"` + Notas string `json:"notas" gorm:"column:notas;type:text"` +} + +func (CuentaPagar) TableName() string { return "contab_cuentas_pagar" } + +// ─── ConsolidadoMensual ────────────────────────────────────────────────────── + +type ConsolidadoMensual struct { + gorm.Model + Anio int `json:"anio" gorm:"column:anio;not null"` + Mes int `json:"mes" gorm:"column:mes;not null"` + TotalIngresos float64 `json:"total_ingresos" gorm:"column:total_ingresos;default:0"` + TotalEgresos float64 `json:"total_egresos" gorm:"column:total_egresos;default:0"` + TotalRetenciones float64 `json:"total_retenciones" gorm:"column:total_retenciones;default:0"` + Resultado float64 `json:"resultado" gorm:"column:resultado;default:0"` +} + +func (ConsolidadoMensual) TableName() string { return "contab_consolidado" } + +// ============================================================================= +// ─── CRUD: Cuenta ─────────────────────────────────────────────────────────── +// ============================================================================= + +func GetAllCuentas(limit, offset int, search string) ([]Cuenta, int64, error) { + var items []Cuenta + var total int64 + db := app.Http.Database.DB.Model(&Cuenta{}) + if search != "" { + db = db.Where("nombre ILIKE ? OR codigo ILIKE ?", "%"+search+"%", "%"+search+"%") + } + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("tipo ASC, nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func GetAllCuentasSelect() ([]Cuenta, error) { + var items []Cuenta + if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func CreateCuenta(c *Cuenta) error { + return app.Http.Database.DB.Create(c).Error +} + +func UpdateCuenta(c *Cuenta) error { + return app.Http.Database.DB.Model(&Cuenta{}).Where("id = ?", c.ID).Updates(map[string]interface{}{ + "codigo": c.Codigo, + "nombre": c.Nombre, + "tipo": c.Tipo, + "color": c.Color, + "activo": c.Activo, + }).Error +} + +func DeleteCuenta(id uint) error { + return app.Http.Database.DB.Delete(&Cuenta{}, id).Error +} + +// ============================================================================= +// ─── CRUD: Entidad ────────────────────────────────────────────────────────── +// ============================================================================= + +func GetAllEntidades(limit, offset int, search string) ([]Entidad, int64, error) { + var items []Entidad + var total int64 + db := app.Http.Database.DB.Model(&Entidad{}) + if search != "" { + db = db.Where("nombre ILIKE ? OR documento ILIKE ? OR email ILIKE ?", + "%"+search+"%", "%"+search+"%", "%"+search+"%") + } + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("nombre ASC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func GetAllEntidadesSelect() ([]Entidad, error) { + var items []Entidad + if err := app.Http.Database.DB.Where("activo = ?", true).Order("nombre ASC").Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func CreateEntidad(e *Entidad) error { + return app.Http.Database.DB.Create(e).Error +} + +func UpdateEntidad(e *Entidad) error { + return app.Http.Database.DB.Model(&Entidad{}).Where("id = ?", e.ID).Updates(map[string]interface{}{ + "nombre": e.Nombre, + "tipo": e.Tipo, + "documento": e.Documento, + "email": e.Email, + "telefono": e.Telefono, + "contacto": e.Contacto, + "notas": e.Notas, + "activo": e.Activo, + }).Error +} + +func DeleteEntidad(id uint) error { + return app.Http.Database.DB.Delete(&Entidad{}, id).Error +} + +// ============================================================================= +// ─── CRUD: Transaccion ────────────────────────────────────────────────────── +// ============================================================================= + +func GetAllTransacciones(limit, offset int, search string, filtroTipo string, mes int, anio int) ([]Transaccion, int64, error) { + var items []Transaccion + var total int64 + db := app.Http.Database.DB.Model(&Transaccion{}).Preload("Cuenta").Preload("Entidad") + if search != "" { + db = db.Where("descripcion ILIKE ?", "%"+search+"%") + } + if filtroTipo != "" { + db = db.Where("tipo = ?", filtroTipo) + } + if mes > 0 && anio > 0 { + db = db.Where("EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", mes, anio) + } + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("fecha DESC, created_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func CreateTransaccion(t *Transaccion) error { + return app.Http.Database.DB.Create(t).Error +} + +func UpdateTransaccion(t *Transaccion) error { + return app.Http.Database.DB.Model(&Transaccion{}).Where("id = ?", t.ID).Updates(map[string]interface{}{ + "fecha": t.Fecha, + "tipo": t.Tipo, + "descripcion": t.Descripcion, + "valor": t.Valor, + "cuenta_id": t.CuentaID, + "entidad_id": t.EntidadID, + "forma_pago": t.FormaPago, + "estado": t.Estado, + "notas": t.Notas, + }).Error +} + +func DeleteTransaccion(id uint) error { + return app.Http.Database.DB.Delete(&Transaccion{}, id).Error +} + +// ============================================================================= +// ─── CRUD: CuentaCobro ────────────────────────────────────────────────────── +// ============================================================================= + +func GetAllCuentasCobro(limit, offset int, search string, estado string) ([]CuentaCobro, int64, error) { + var items []CuentaCobro + var total int64 + db := app.Http.Database.DB.Model(&CuentaCobro{}).Preload("Entidad").Preload("Transaccion") + if search != "" { + db = db.Joins("JOIN contab_entidades ON contab_entidades.id = contab_cuentas_cobro.entidad_id"). + Where("contab_entidades.nombre ILIKE ? OR contab_cuentas_cobro.descripcion ILIKE ?", + "%"+search+"%", "%"+search+"%") + } + if estado != "" { + db = db.Where("contab_cuentas_cobro.estado = ?", estado) + } + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("contab_cuentas_cobro.fecha DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func CreateCuentaCobro(cc *CuentaCobro) error { + return app.Http.Database.DB.Create(cc).Error +} + +func UpdateCuentaCobro(cc *CuentaCobro) error { + return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", cc.ID).Updates(map[string]interface{}{ + "entidad_id": cc.EntidadID, + "fecha": cc.Fecha, + "descripcion": cc.Descripcion, + "valor": cc.Valor, + "estado": cc.Estado, + "fecha_vencimiento": cc.FechaVencimiento, + "fecha_pago": cc.FechaPago, + "transaccion_id": cc.TransaccionID, + "notas": cc.Notas, + }).Error +} + +func DeleteCuentaCobro(id uint) error { + return app.Http.Database.DB.Delete(&CuentaCobro{}, id).Error +} + +// ============================================================================= +// ─── CRUD: CuentaPagar ────────────────────────────────────────────────────── +// ============================================================================= + +func GetAllCuentasPagar(limit, offset int, search string, estado string) ([]CuentaPagar, int64, error) { + var items []CuentaPagar + var total int64 + db := app.Http.Database.DB.Model(&CuentaPagar{}).Preload("Entidad").Preload("Transaccion") + if search != "" { + db = db.Joins("JOIN contab_entidades ON contab_entidades.id = contab_cuentas_pagar.entidad_id"). + Where("contab_entidades.nombre ILIKE ? OR contab_cuentas_pagar.descripcion ILIKE ?", + "%"+search+"%", "%"+search+"%") + } + if estado != "" { + db = db.Where("contab_cuentas_pagar.estado = ?", estado) + } + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("contab_cuentas_pagar.fecha DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func CreateCuentaPagar(cp *CuentaPagar) error { + return app.Http.Database.DB.Create(cp).Error +} + +func UpdateCuentaPagar(cp *CuentaPagar) error { + return app.Http.Database.DB.Model(&CuentaPagar{}).Where("id = ?", cp.ID).Updates(map[string]interface{}{ + "entidad_id": cp.EntidadID, + "fecha": cp.Fecha, + "descripcion": cp.Descripcion, + "valor": cp.Valor, + "vencimiento": cp.Vencimiento, + "estado": cp.Estado, + "fecha_pago": cp.FechaPago, + "transaccion_id": cp.TransaccionID, + "notas": cp.Notas, + }).Error +} + +func DeleteCuentaPagar(id uint) error { + return app.Http.Database.DB.Delete(&CuentaPagar{}, id).Error +} + +// ============================================================================= +// ─── Consolidado ──────────────────────────────────────────────────────────── +// ============================================================================= + +func GetConsolidadoMensual(mes, anio int) (*ConsolidadoMensual, error) { + var c ConsolidadoMensual + err := app.Http.Database.DB.Where("mes = ? AND anio = ?", mes, anio).First(&c).Error + if err != nil { + return nil, err + } + return &c, nil +} + +func CalcularYGuardarConsolidado(mes, anio int) (*ConsolidadoMensual, error) { + type SumRow struct { + Total float64 + } + var ingresos SumRow + var egresos SumRow + + app.Http.Database.DB.Model(&Transaccion{}). + Select("COALESCE(SUM(valor),0) as total"). + Where("tipo = ? AND EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", "ingreso", mes, anio). + Scan(&ingresos) + + app.Http.Database.DB.Model(&Transaccion{}). + Select("COALESCE(SUM(valor),0) as total"). + Where("tipo = ? AND EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", "egreso", mes, anio). + Scan(&egresos) + + resultado := ingresos.Total - egresos.Total + + c := &ConsolidadoMensual{ + Anio: anio, + Mes: mes, + TotalIngresos: ingresos.Total, + TotalEgresos: egresos.Total, + TotalRetenciones: 0, + Resultado: resultado, + } + + var existing ConsolidadoMensual + err := app.Http.Database.DB.Where("mes = ? AND anio = ?", mes, anio).First(&existing).Error + if err == nil { + c.ID = existing.ID + app.Http.Database.DB.Model(&ConsolidadoMensual{}).Where("id = ?", c.ID).Updates(map[string]interface{}{ + "total_ingresos": c.TotalIngresos, + "total_egresos": c.TotalEgresos, + "total_retenciones": c.TotalRetenciones, + "resultado": c.Resultado, + }) + } else { + app.Http.Database.DB.Create(c) + } + + return c, nil +} + +func ListConsolidados(anio int) ([]ConsolidadoMensual, error) { + var items []ConsolidadoMensual + db := app.Http.Database.DB.Model(&ConsolidadoMensual{}).Order("anio DESC, mes DESC") + if anio > 0 { + db = db.Where("anio = ?", anio) + } + if err := db.Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +// ─── Datos para dashboard ──────────────────────────────────────────────────── + +type DashboardData struct { + Mes int `json:"mes"` + Anio int `json:"anio"` + TotalIngresos float64 `json:"total_ingresos"` + TotalEgresos float64 `json:"total_egresos"` + Resultado float64 `json:"resultado"` + CantTransacciones int64 `json:"cant_transacciones"` + PendientesCobro float64 `json:"pendientes_cobro"` + PendientesPago float64 `json:"pendientes_pago"` + Transacciones []Transaccion `json:"transacciones"` +} + +func GetDashboardData(mes, anio int) (*DashboardData, error) { + d := &DashboardData{Mes: mes, Anio: anio} + + app.Http.Database.DB.Model(&Transaccion{}). + Select("COALESCE(SUM(valor),0) as total"). + Where("tipo = ? AND EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", "ingreso", mes, anio). + Scan(&d.TotalIngresos) + + app.Http.Database.DB.Model(&Transaccion{}). + Select("COALESCE(SUM(valor),0) as total"). + Where("tipo = ? AND EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", "egreso", mes, anio). + Scan(&d.TotalEgresos) + + d.Resultado = d.TotalIngresos - d.TotalEgresos + + app.Http.Database.DB.Model(&Transaccion{}). + Where("EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", mes, anio). + Count(&d.CantTransacciones) + + app.Http.Database.DB.Model(&CuentaCobro{}). + Select("COALESCE(SUM(valor),0) as total"). + Where("estado = ?", "pendiente"). + Scan(&d.PendientesCobro) + + app.Http.Database.DB.Model(&CuentaPagar{}). + Select("COALESCE(SUM(valor),0) as total"). + Where("estado = ?", "pendiente"). + Scan(&d.PendientesPago) + + app.Http.Database.DB.Model(&Transaccion{}). + Preload("Cuenta").Preload("Entidad"). + Where("EXTRACT(MONTH FROM fecha) = ? AND EXTRACT(YEAR FROM fecha) = ?", mes, anio). + Order("fecha DESC"). + Limit(10). + Find(&d.Transacciones) + + return d, nil +} + +func SeedContabilidad() { + cuentas := []Cuenta{ + {Codigo: "ING-FAC", Nombre: "Facturación", Tipo: "ingreso", Color: "#22c55e"}, + {Codigo: "ING-OTR", Nombre: "Otros ingresos", Tipo: "ingreso", Color: "#16a34a"}, + {Codigo: "EGR-HOS", Nombre: "Hosting/Servidores", Tipo: "egreso", Color: "#ef4444"}, + {Codigo: "EGR-DOM", Nombre: "Dominios", Tipo: "egreso", Color: "#dc2626"}, + {Codigo: "EGR-SRV", Nombre: "Servicios", Tipo: "egreso", Color: "#f97316"}, + {Codigo: "EGR-GRAL", Nombre: "Gastos generales", Tipo: "egreso", Color: "#eab308"}, + {Codigo: "EGR-NOM", Nombre: "Nómina", Tipo: "egreso", Color: "#a855f7"}, + {Codigo: "EGR-IMPU", Nombre: "Impuestos", Tipo: "egreso", Color: "#6366f1"}, + {Codigo: "EGR-MKT", Nombre: "Marketing", Tipo: "egreso", Color: "#ec4899"}, + } + for _, c := range cuentas { + var existing Cuenta + if app.Http.Database.DB.Where("codigo = ?", c.Codigo).First(&existing).Error != nil { + app.Http.Database.DB.Create(&c) + } + } + + entidades := []Entidad{ + {Nombre: "ANDREMER", Tipo: "cliente"}, + {Nombre: "GIAF SAS", Tipo: "cliente"}, + {Nombre: "TECZONE", Tipo: "cliente"}, + {Nombre: "DOCUXER", Tipo: "cliente"}, + {Nombre: "FELIPE", Tipo: "cliente"}, + {Nombre: "NATALIA", Tipo: "cliente"}, + } + for _, e := range entidades { + var existing Entidad + if app.Http.Database.DB.Where("nombre = ?", e.Nombre).First(&existing).Error != nil { + app.Http.Database.DB.Create(&e) + } + } +} diff --git a/pkg/models/websms_config.go b/pkg/models/websms_config.go new file mode 100644 index 0000000..d440622 --- /dev/null +++ b/pkg/models/websms_config.go @@ -0,0 +1,92 @@ +package models + +import ( + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +type WebSmsConfig struct { + gorm.Model + Username string `json:"username" gorm:"column:username;size:255;not null"` + ApiToken string `json:"api_token" gorm:"column:api_token;type:text;not null"` + Sender string `json:"sender" gorm:"column:sender;size:30"` + Notas string `json:"notas" gorm:"column:notas;type:text"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` +} + +func (WebSmsConfig) TableName() string { return "websms_config" } + +func GetWebSmsConfig() (*WebSmsConfig, error) { + var item WebSmsConfig + if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func SaveWebSmsConfig(s WebSmsConfig) error { + app.Http.Database.DB.Model(&WebSmsConfig{}). + Where("activo = ?", true). + Update("activo", false) + s.Activo = true + if s.ID > 0 { + return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{ + "username": s.Username, + "api_token": s.ApiToken, + "sender": s.Sender, + "notas": s.Notas, + "activo": true, + }).Error + } + return app.Http.Database.DB.Create(&s).Error +} + +// ─── Log de SMS enviados ──────────────────────────────────────────────────── + +type WebSmsLog struct { + gorm.Model + Para string `json:"para" gorm:"column:para;size:30;index"` + Mensaje string `json:"mensaje" gorm:"column:mensaje;type:text"` + Status string `json:"status" gorm:"column:status;size:20"` + MsgID string `json:"msg_id" gorm:"column:msg_id;size:100"` + Error string `json:"error" gorm:"column:error;type:text"` +} + +func (WebSmsLog) TableName() string { return "websms_log" } + +func CreateWebSmsLog(entry *WebSmsLog) error { + return app.Http.Database.DB.Create(entry).Error +} + +func GetWebSmsLogs(limit int) ([]WebSmsLog, error) { + var logs []WebSmsLog + if err := app.Http.Database.DB.Order("id DESC").Limit(limit).Find(&logs).Error; err != nil { + return nil, err + } + return logs, nil +} + +// ─── Webhook ACK log ──────────────────────────────────────────────────────── + +type WebSmsWebhookLog struct { + gorm.Model + Tipo string `json:"tipo" gorm:"column:tipo;size:20;index"` // delivery | click | incoming + MsgID string `json:"msg_id" gorm:"column:msg_id;size:100;index"` + Para string `json:"para" gorm:"column:para;size:30"` + Status string `json:"status" gorm:"column:status;size:30"` + Raw string `json:"raw" gorm:"column:raw;type:text"` +} + +func (WebSmsWebhookLog) TableName() string { return "websms_webhook_log" } + +func CreateWebSmsWebhookLog(entry *WebSmsWebhookLog) error { + return app.Http.Database.DB.Create(entry).Error +} + +func GetWebSmsWebhookLogs(limit int) ([]WebSmsWebhookLog, error) { + var logs []WebSmsWebhookLog + if err := app.Http.Database.DB.Order("id DESC").Limit(limit).Find(&logs).Error; err != nil { + return nil, err + } + return logs, nil +} diff --git a/pkg/services/websms_service.go b/pkg/services/websms_service.go new file mode 100644 index 0000000..c1da869 --- /dev/null +++ b/pkg/services/websms_service.go @@ -0,0 +1,101 @@ +package services + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +const websmsAPIBase = "https://websms.labsmobile.com/SY0204/api" + +type WebSmsRequest struct { + Message string `json:"message"` + TPOA string `json:"tpoa,omitempty"` + Recipient []WebSmsRecipient `json:"recipient"` +} + +type WebSmsRecipient struct { + MSISDN string `json:"msisdn"` +} + +type WebSmsResponse struct { + Code string `json:"code"` + Message string `json:"message"` + ID string `json:"id"` +} + +type WebSmsAckPayload struct { + ID string `json:"id"` + Reference string `json:"reference"` + Status string `json:"status"` + Msisdn string `json:"msisdn"` + Substatus string `json:"substatus"` + Timestamp string `json:"timestamp"` +} + +type WebSmsClickPayload struct { + ID string `json:"id"` + Reference string `json:"reference"` + Msisdn string `json:"msisdn"` + URL string `json:"url"` + Timestamp string `json:"timestamp"` +} + +type WebSmsIncomingPayload struct { + ID string `json:"id"` + Msisdn string `json:"msisdn"` + Message string `json:"message"` + Shortcode string `json:"shortcode"` + Timestamp string `json:"timestamp"` +} + +func SendWebSms(cfg *models.WebSmsConfig, para, mensaje string) (*WebSmsResponse, error) { + auth := base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.ApiToken)) + + req := WebSmsRequest{ + Message: mensaje, + Recipient: []WebSmsRecipient{{MSISDN: para}}, + } + if cfg.Sender != "" { + req.TPOA = cfg.Sender + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("websms: marshal: %w", err) + } + + httpReq, err := http.NewRequest("POST", websmsAPIBase, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "Basic "+auth) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("websms: http: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf("websms API status %d: %s", resp.StatusCode, string(respBody)) + } + + var result WebSmsResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("websms: unmarshal: %w", err) + } + + return &result, nil +} diff --git a/resources/views/contabilidad/contabilidad.html b/resources/views/contabilidad/contabilidad.html new file mode 100644 index 0000000..7029fab --- /dev/null +++ b/resources/views/contabilidad/contabilidad.html @@ -0,0 +1,122 @@ +
+ +
+
+

Contabilidad

+

Consolidado mensual de ingresos y egresos

+
+
+ + +
+
+ + +
+
+

Ingresos

+

+
+
+

Egresos

+

+
+
+

Resultado

+

+
+
+

Transacciones

+

+
+
+ + +
+
+
+

Por cobrar

+ Ver todas +
+

+
+
+
+

Por pagar

+ Ver todas +
+

+
+
+ + +
+
+

Últimas transacciones

+ Ver todas +
+
+ + + + + + + + + + + + + + + +
FechaTipoDescripciónCategoríaEntidadValor
+
+
+ +
+ + diff --git a/resources/views/contabilidad/cuentas.html b/resources/views/contabilidad/cuentas.html new file mode 100644 index 0000000..07916bb --- /dev/null +++ b/resources/views/contabilidad/cuentas.html @@ -0,0 +1,150 @@ +
+ +
+
+

Categorías contables

+

Plan de cuentas para clasificar transacciones

+
+ +
+ +
+ + + + + + + + + + + + + + + +
CódigoNombreTipoColorActivoAcciones
+
+ + +
+
+

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+
+ + +
+
+
+
+ +
+
+

¿Eliminar categoría?

+
+ + +
+
+
+ +
+ + diff --git a/resources/views/contabilidad/cuentas_cobro.html b/resources/views/contabilidad/cuentas_cobro.html new file mode 100644 index 0000000..024c964 --- /dev/null +++ b/resources/views/contabilidad/cuentas_cobro.html @@ -0,0 +1,194 @@ +
+ +
+
+

Cuentas por cobrar

+

Facturas y montos pendientes de cobro

+
+ +
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + +
FechaEntidadDescripciónValorVenceEstadoAcciones
+
+ +
+ Total: +
+ + + +
+
+ + +
+
+

Nuevo cobro pendiente

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+
+ + +
+
+
+
+ + +
+
+

Marcar como pagado

+
+ + +
+ + +
+
+
+
+ +
+
+

¿Eliminar?

+
+ + +
+
+
+ +
+ + diff --git a/resources/views/contabilidad/cuentas_pagar.html b/resources/views/contabilidad/cuentas_pagar.html new file mode 100644 index 0000000..2ab7bc9 --- /dev/null +++ b/resources/views/contabilidad/cuentas_pagar.html @@ -0,0 +1,194 @@ +
+ +
+
+

Cuentas por pagar

+

Facturas y montos pendientes de pago

+
+ +
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + +
FechaEntidadDescripciónValorVenceEstadoAcciones
+
+ +
+ Total: +
+ + + +
+
+ + +
+
+

Nuevo pendiente de pago

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+
+ + +
+
+
+
+ + +
+
+

Marcar como pagado

+
+ + +
+ + +
+
+
+
+ +
+
+

¿Eliminar?

+
+ + +
+
+
+ +
+ + diff --git a/resources/views/contabilidad/entidades.html b/resources/views/contabilidad/entidades.html new file mode 100644 index 0000000..0800793 --- /dev/null +++ b/resources/views/contabilidad/entidades.html @@ -0,0 +1,169 @@ +
+ +
+
+

Entidades

+

Clientes, proveedores y empresas

+
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + +
NombreTipoDocumentoEmailTeléfonoContactoActivoAcciones
+
+ +
+ Total: +
+ + + +
+
+ + +
+
+

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+
+ + +
+
+
+
+ +
+
+

¿Eliminar entidad?

+
+ + +
+
+
+ +
+ + diff --git a/resources/views/contabilidad/transacciones.html b/resources/views/contabilidad/transacciones.html new file mode 100644 index 0000000..d969125 --- /dev/null +++ b/resources/views/contabilidad/transacciones.html @@ -0,0 +1,244 @@ +
+ +
+
+

Transacciones

+

Registro de ingresos y egresos

+
+ +
+ + +
+ + + +
+ + +
+ + + + + + + + + + + + + + + + + +
FechaTipoDescripciónCategoríaEntidadValorPagoAcciones
+
+ + +
+ Total: +
+ + + +
+
+ + +
+
+

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+
+ + +
+
+
+
+ + +
+
+

¿Eliminar transacción?

+

Esta acción no se puede deshacer.

+
+ + +
+
+
+ +
+ + diff --git a/resources/views/websms_config.html b/resources/views/websms_config.html new file mode 100644 index 0000000..d62f24e --- /dev/null +++ b/resources/views/websms_config.html @@ -0,0 +1,171 @@ +
+ +
+
+

WebSMS (LabsMobile)

+

Integración de SMS para notificaciones

+
+
+ + +
+

Configuración API

+
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+

+
+ +
+
+
+ + +
+

Probar envío

+
+
+
+ + +
+
+ + +
+
+

+
+ +
+
+
+ + +
+

Webhooks

+

Configura estas URLs en el panel de WebSMS para recibir notificaciones:

+
+
+ + https://admin.u-site.app/webhooks/websms/delivery +
+
+ + https://admin.u-site.app/webhooks/websms/click +
+
+ + https://admin.u-site.app/webhooks/websms/incoming +
+
+
+ + +
+

Últimos envíos

+
+ + + + + + + + + + + + + + +
FechaParaMensajeEstadoID
+
+
+ +
+ + diff --git a/rest/controllers/contabilidad_controller.go b/rest/controllers/contabilidad_controller.go new file mode 100644 index 0000000..ee8717e --- /dev/null +++ b/rest/controllers/contabilidad_controller.go @@ -0,0 +1,603 @@ +package controllers + +import ( + "math" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// ─── Render de vistas ──────────────────────────────────────────────────────── + +func ContabilidadIndex(c *fiber.Ctx) error { + return c.Render("contabilidad/contabilidad", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func ContabilidadTransaccionesView(c *fiber.Ctx) error { + return c.Render("contabilidad/transacciones", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func ContabilidadCuentasView(c *fiber.Ctx) error { + return c.Render("contabilidad/cuentas", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func ContabilidadEntidadesView(c *fiber.Ctx) error { + return c.Render("contabilidad/entidades", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func ContabilidadCobroView(c *fiber.Ctx) error { + return c.Render("contabilidad/cuentas_cobro", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +func ContabilidadPagarView(c *fiber.Ctx) error { + return c.Render("contabilidad/cuentas_pagar", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + }, "layouts/main") +} + +// ─── Dashboard ─────────────────────────────────────────────────────────────── + +func ContabilidadDashboard(c *fiber.Ctx) error { + now := time.Now() + mes, _ := strconv.Atoi(c.Query("mes", strconv.Itoa(int(now.Month())))) + anio, _ := strconv.Atoi(c.Query("anio", strconv.Itoa(now.Year()))) + if mes < 1 || mes > 12 { + mes = int(now.Month()) + } + if anio < 2000 { + anio = now.Year() + } + data, err := models.GetDashboardData(mes, anio) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(data) +} + +func ContabilidadConsolidado(ctx *fiber.Ctx) error { + now := time.Now() + mes, _ := strconv.Atoi(ctx.Query("mes", strconv.Itoa(int(now.Month())))) + anio, _ := strconv.Atoi(ctx.Query("anio", strconv.Itoa(now.Year()))) + if mes < 1 || mes > 12 { + mes = int(now.Month()) + } + if anio < 2000 { + anio = now.Year() + } + data, err := models.CalcularYGuardarConsolidado(mes, anio) + if err != nil { + return ctx.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return ctx.JSON(data) +} + +func ContabilidadListConsolidados(c *fiber.Ctx) error { + anio, _ := strconv.Atoi(c.Query("anio", "0")) + items, err := models.ListConsolidados(anio) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(items) +} + +// ============================================================================= +// ─── CRUD: Cuentas ────────────────────────────────────────────────────────── +// ============================================================================= + +func GetCuentas(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + search := c.Query("search", "") + if page < 1 { + page = 1 + } + limit := 50 + offset := (page - 1) * limit + items, total, err := models.GetAllCuentas(limit, offset, search) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +func GetCuentasSelect(c *fiber.Ctx) error { + items, err := models.GetAllCuentasSelect() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(items) +} + +func CreateCuenta(c *fiber.Ctx) error { + var req models.Cuenta + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if req.Nombre == "" { + return c.Status(400).JSON(fiber.Map{"error": "nombre es requerido"}) + } + if req.Tipo == "" { + req.Tipo = "egreso" + } + req.Activo = true + if err := models.CreateCuenta(&req); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(req) +} + +func UpdateCuenta(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var req models.Cuenta + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + req.ID = uint(id) + if err := models.UpdateCuenta(&req); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteCuenta(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteCuenta(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// ============================================================================= +// ─── CRUD: Entidades ──────────────────────────────────────────────────────── +// ============================================================================= + +func GetEntidades(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + search := c.Query("search", "") + if page < 1 { + page = 1 + } + limit := 20 + offset := (page - 1) * limit + items, total, err := models.GetAllEntidades(limit, offset, search) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +func GetEntidadesSelect(c *fiber.Ctx) error { + items, err := models.GetAllEntidadesSelect() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(items) +} + +func CreateEntidad(c *fiber.Ctx) error { + var req models.Entidad + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if req.Nombre == "" { + return c.Status(400).JSON(fiber.Map{"error": "nombre es requerido"}) + } + req.Activo = true + if err := models.CreateEntidad(&req); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(req) +} + +func UpdateEntidad(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + var req models.Entidad + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + req.ID = uint(id) + if err := models.UpdateEntidad(&req); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteEntidad(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteEntidad(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// ============================================================================= +// ─── CRUD: Transacciones ──────────────────────────────────────────────────── +// ============================================================================= + +func GetTransacciones(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + search := c.Query("search", "") + filtroTipo := c.Query("tipo", "") + mes, _ := strconv.Atoi(c.Query("mes", "0")) + anio, _ := strconv.Atoi(c.Query("anio", "0")) + if page < 1 { + page = 1 + } + limit := 30 + offset := (page - 1) * limit + items, total, err := models.GetAllTransacciones(limit, offset, search, filtroTipo, mes, anio) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +func CreateTransaccion(c *fiber.Ctx) error { + type Req struct { + Fecha string `json:"fecha"` + Tipo string `json:"tipo"` + Descripcion string `json:"descripcion"` + Valor float64 `json:"valor"` + CuentaID *uint `json:"cuenta_id"` + EntidadID *uint `json:"entidad_id"` + FormaPago string `json:"forma_pago"` + Estado string `json:"estado"` + Notas string `json:"notas"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if req.Descripcion == "" { + return c.Status(400).JSON(fiber.Map{"error": "descripcion es requerida"}) + } + if req.Valor <= 0 { + return c.Status(400).JSON(fiber.Map{"error": "valor debe ser mayor a 0"}) + } + t := &models.Transaccion{ + Tipo: req.Tipo, + Descripcion: req.Descripcion, + Valor: req.Valor, + CuentaID: req.CuentaID, + EntidadID: req.EntidadID, + FormaPago: req.FormaPago, + Estado: req.Estado, + Notas: req.Notas, + Fecha: time.Now(), + } + if t.Tipo == "" { + t.Tipo = "ingreso" + } + if t.Estado == "" { + t.Estado = "registrada" + } + if req.Fecha != "" { + if parsed, err := time.Parse("2006-01-02", req.Fecha); err == nil { + t.Fecha = parsed + } + } + if err := models.CreateTransaccion(t); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(t) +} + +func UpdateTransaccion(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + type Req struct { + Fecha string `json:"fecha"` + Tipo string `json:"tipo"` + Descripcion string `json:"descripcion"` + Valor float64 `json:"valor"` + CuentaID *uint `json:"cuenta_id"` + EntidadID *uint `json:"entidad_id"` + FormaPago string `json:"forma_pago"` + Estado string `json:"estado"` + Notas string `json:"notas"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + t := &models.Transaccion{ + Tipo: req.Tipo, + Descripcion: req.Descripcion, + Valor: req.Valor, + CuentaID: req.CuentaID, + EntidadID: req.EntidadID, + FormaPago: req.FormaPago, + Estado: req.Estado, + Notas: req.Notas, + Fecha: time.Now(), + } + t.ID = uint(id) + if req.Fecha != "" { + if parsed, err := time.Parse("2006-01-02", req.Fecha); err == nil { + t.Fecha = parsed + } + } + + if err := models.UpdateTransaccion(t); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteTransaccion(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteTransaccion(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// ============================================================================= +// ─── CRUD: Cuentas por Cobrar ────────────────────────────────────────────── +// ============================================================================= + +func GetCuentasCobro(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + search := c.Query("search", "") + estado := c.Query("estado", "") + if page < 1 { + page = 1 + } + limit := 20 + offset := (page - 1) * limit + items, total, err := models.GetAllCuentasCobro(limit, offset, search, estado) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +func CreateCuentaCobro(c *fiber.Ctx) error { + type Req struct { + EntidadID uint `json:"entidad_id"` + Fecha string `json:"fecha"` + Descripcion string `json:"descripcion"` + Valor float64 `json:"valor"` + FechaVencimiento string `json:"fecha_vencimiento"` + Notas string `json:"notas"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if req.EntidadID == 0 { + return c.Status(400).JSON(fiber.Map{"error": "entidad_id es requerido"}) + } + cc := &models.CuentaCobro{ + EntidadID: req.EntidadID, + Descripcion: req.Descripcion, + Valor: req.Valor, + Estado: "pendiente", + Notas: req.Notas, + Fecha: time.Now(), + } + if req.Fecha != "" { + if parsed, err := time.Parse("2006-01-02", req.Fecha); err == nil { + cc.Fecha = parsed + } + } + if req.FechaVencimiento != "" { + if parsed, err := time.Parse("2006-01-02", req.FechaVencimiento); err == nil { + cc.FechaVencimiento = &parsed + } + } + if err := models.CreateCuentaCobro(cc); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(cc) +} + +func UpdateCuentaCobro(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + type Req struct { + Estado string `json:"estado"` + FechaPago string `json:"fecha_pago"` + TransaccionID *uint `json:"transaccion_id"` + Notas string `json:"notas"` + FechaVencimiento string `json:"fecha_vencimiento"` + Descripcion string `json:"descripcion"` + Valor float64 `json:"valor"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + cc := &models.CuentaCobro{} + cc.ID = uint(id) + if req.Estado != "" { + cc.Estado = req.Estado + } + if req.FechaPago != "" { + if parsed, err := time.Parse("2006-01-02", req.FechaPago); err == nil { + cc.FechaPago = &parsed + } + } + cc.TransaccionID = req.TransaccionID + cc.Notas = req.Notas + if err := models.UpdateCuentaCobro(cc); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteCuentaCobro(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteCuentaCobro(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// ============================================================================= +// ─── CRUD: Cuentas por Pagar ──────────────────────────────────────────────── +// ============================================================================= + +func GetCuentasPagar(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + search := c.Query("search", "") + estado := c.Query("estado", "") + if page < 1 { + page = 1 + } + limit := 20 + offset := (page - 1) * limit + items, total, err := models.GetAllCuentasPagar(limit, offset, search, estado) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "items": items, + "total": total, + "totalPages": int(math.Ceil(float64(total) / float64(limit))), + "page": page, + }) +} + +func CreateCuentaPagar(c *fiber.Ctx) error { + type Req struct { + EntidadID uint `json:"entidad_id"` + Fecha string `json:"fecha"` + Descripcion string `json:"descripcion"` + Valor float64 `json:"valor"` + Vencimiento string `json:"vencimiento"` + Notas string `json:"notas"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if req.EntidadID == 0 { + return c.Status(400).JSON(fiber.Map{"error": "entidad_id es requerido"}) + } + cp := &models.CuentaPagar{ + EntidadID: req.EntidadID, + Descripcion: req.Descripcion, + Valor: req.Valor, + Estado: "pendiente", + Notas: req.Notas, + Fecha: time.Now(), + } + if req.Fecha != "" { + if parsed, err := time.Parse("2006-01-02", req.Fecha); err == nil { + cp.Fecha = parsed + } + } + if req.Vencimiento != "" { + if parsed, err := time.Parse("2006-01-02", req.Vencimiento); err == nil { + cp.Vencimiento = &parsed + } + } + if err := models.CreateCuentaPagar(cp); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(201).JSON(cp) +} + +func UpdateCuentaPagar(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + type Req struct { + Estado string `json:"estado"` + FechaPago string `json:"fecha_pago"` + TransaccionID *uint `json:"transaccion_id"` + Notas string `json:"notas"` + } + var req Req + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + cp := &models.CuentaPagar{} + cp.ID = uint(id) + if req.Estado != "" { + cp.Estado = req.Estado + } + if req.FechaPago != "" { + if parsed, err := time.Parse("2006-01-02", req.FechaPago); err == nil { + cp.FechaPago = &parsed + } + } + cp.TransaccionID = req.TransaccionID + cp.Notas = req.Notas + if err := models.UpdateCuentaPagar(cp); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +func DeleteCuentaPagar(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 32) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "ID inválido"}) + } + if err := models.DeleteCuentaPagar(uint(id)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} diff --git a/rest/controllers/websms_controller.go b/rest/controllers/websms_controller.go new file mode 100644 index 0000000..ab11b88 --- /dev/null +++ b/rest/controllers/websms_controller.go @@ -0,0 +1,163 @@ +package controllers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +// ─── Vistas ────────────────────────────────────────────────────────────────── + +func WebSmsConfigPage(c *fiber.Ctx) error { + cfg, _ := models.GetWebSmsConfig() + return c.Render("websms_config", fiber.Map{ + "user": c.Locals("user"), + "modules": c.Locals("userModules"), + "cfg": cfg, + }, "layouts/main") +} + +// ─── API ───────────────────────────────────────────────────────────────────── + +func GetWebSmsConfig(c *fiber.Ctx) error { + cfg, err := models.GetWebSmsConfig() + if err != nil { + return c.JSON(fiber.Map{"data": nil}) + } + return c.JSON(fiber.Map{"data": cfg}) +} + +func SaveWebSmsConfig(c *fiber.Ctx) error { + type body struct { + ID uint `json:"id"` + Username string `json:"username"` + ApiToken string `json:"api_token"` + Sender string `json:"sender"` + Notas string `json:"notas"` + } + var b body + if err := c.BodyParser(&b); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "body inválido"}) + } + if b.Username == "" { + return c.Status(400).JSON(fiber.Map{"error": "username es requerido"}) + } + if b.ApiToken == "" { + return c.Status(400).JSON(fiber.Map{"error": "api_token es requerido"}) + } + + cfg := models.WebSmsConfig{ + Username: b.Username, + ApiToken: b.ApiToken, + Sender: b.Sender, + Notas: b.Notas, + } + cfg.ID = b.ID + + if err := models.SaveWebSmsConfig(cfg); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Configuración WebSMS guardada"}) +} + +func TestWebSms(c *fiber.Ctx) error { + type body struct { + Para string `json:"para"` + Mensaje string `json:"mensaje"` + } + var b body + if err := c.BodyParser(&b); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "body inválido"}) + } + if b.Para == "" { + return c.Status(400).JSON(fiber.Map{"error": "número de destino requerido"}) + } + if b.Mensaje == "" { + b.Mensaje = "[TEST] Notificación desde U-site vía WebSMS" + } + + cfg, err := models.GetWebSmsConfig() + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "WebSMS no configurado"}) + } + + resp, err := services.SendWebSms(cfg, b.Para, b.Mensaje) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + models.CreateWebSmsLog(&models.WebSmsLog{ + Para: b.Para, + Mensaje: b.Mensaje, + Status: resp.Code, + MsgID: resp.ID, + }) + + return c.JSON(fiber.Map{"ok": true, "response": resp}) +} + +func GetWebSmsLogs(c *fiber.Ctx) error { + logs, err := models.GetWebSmsLogs(50) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(logs) +} + +func GetWebSmsWebhookLogs(c *fiber.Ctx) error { + logs, err := models.GetWebSmsWebhookLogs(50) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(logs) +} + +// ─── Webhooks entrantes ───────────────────────────────────────────────────── + +func WebSmsDeliveryWebhook(c *fiber.Ctx) error { + var payload services.WebSmsAckPayload + if err := c.BodyParser(&payload); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "body inválido"}) + } + raw := c.Body() + models.CreateWebSmsWebhookLog(&models.WebSmsWebhookLog{ + Tipo: "delivery", + MsgID: payload.ID, + Para: payload.Msisdn, + Status: payload.Status, + Raw: string(raw), + }) + return c.SendStatus(200) +} + +func WebSmsClickWebhook(c *fiber.Ctx) error { + var payload services.WebSmsClickPayload + if err := c.BodyParser(&payload); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "body inválido"}) + } + raw := c.Body() + models.CreateWebSmsWebhookLog(&models.WebSmsWebhookLog{ + Tipo: "click", + MsgID: payload.ID, + Para: payload.Msisdn, + Status: "clicked", + Raw: string(raw), + }) + return c.SendStatus(200) +} + +func WebSmsIncomingWebhook(c *fiber.Ctx) error { + var payload services.WebSmsIncomingPayload + if err := c.BodyParser(&payload); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "body inválido"}) + } + raw := c.Body() + models.CreateWebSmsWebhookLog(&models.WebSmsWebhookLog{ + Tipo: "incoming", + MsgID: payload.ID, + Para: payload.Msisdn, + Status: "received", + Raw: string(raw), + }) + return c.SendStatus(200) +} diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go index bc2724a..449f50a 100755 --- a/rest/routes/publicas.go +++ b/rest/routes/publicas.go @@ -37,6 +37,11 @@ func RutasPublicas(web fiber.Router) { // Configurar en el bot: POST https://api.telegram.org/bot{TOKEN}/setWebhook?url={HOST}/webhooks/telegram-portal web.Post("/webhooks/telegram-portal", controllers.TelegramPortalWebhook) + // ─── WebSMS (LabsMobile) — ACK de entrega, clics y mensajes entrantes ── + web.Post("/webhooks/websms/delivery", controllers.WebSmsDeliveryWebhook) + web.Post("/webhooks/websms/click", controllers.WebSmsClickWebhook) + web.Post("/webhooks/websms/incoming", controllers.WebSmsIncomingWebhook) + // ─── Agente de monitoreo de servidores ──────────────────────────────────── // El agente instalado en cada servidor reporta métricas aquí (sin sesión, auth por token). web.Post("/agent/heartbeat", controllers.AgentHeartbeat) diff --git a/rest/routes/user.go b/rest/routes/user.go index 2f77cbe..e20a137 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -453,6 +453,14 @@ func UserRoutes(app fiber.Router) { protected.Put("/mis-notifs/:id/leida", controllers.MarcarNotifLeida) protected.Post("/mis-notifs/marcar-todas", controllers.MarcarTodasLeidas) + // ─── WebSMS (LabsMobile) ─────────────────────────────────────────────────── + protected.Get("/websms", middlewares.MenuMiddleware, controllers.WebSmsConfigPage) + protected.Get("/websms/config", controllers.GetWebSmsConfig) + protected.Post("/websms/save", controllers.SaveWebSmsConfig) + protected.Post("/websms/test", controllers.TestWebSms) + protected.Get("/websms/logs", controllers.GetWebSmsLogs) + protected.Get("/websms/webhook-logs", controllers.GetWebSmsWebhookLogs) + // ─── Planes dLocal (gestión desde panel protegido) ──────────────────────── protected.Get("/dlocal/planes", apiControllers.SeePlanes) protected.Post("/dlocal/planes", apiControllers.CreatePlan) diff --git a/seed_minio.sql b/seed_minio.sql new file mode 100644 index 0000000..2cee74c --- /dev/null +++ b/seed_minio.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- Seed: Migrar de Alibaba OSS a MinIO S3 +-- Ejecutar en la base de datos PostgreSQL de producción: +-- psql -h HOST -U usite -d usite -f seed_minio.sql +-- ============================================================ + +BEGIN; + +-- Desactivar configuraciones Alibaba activas +UPDATE oss_api +SET is_active = false, + notes = COALESCE(notes, '') || ' [desactivado ' || NOW() || ']' +WHERE provider = 'alibaba' + AND is_active = true; + +-- Insertar nueva configuración MinIO S3 +INSERT INTO oss_api (name, provider, endpoint, access_key_id, access_key_secret, bucket_name, region, is_active, public_url, notes, created_at, updated_at) +SELECT 'MinIO - vcardv4', + 's3', + 'http://135.181.2.237:9000', + 'admin', + 'lsE6TAihW98iaXKDWG+6gGLYAFdyp2iC', + 'vcard-usite', + 'us-east-1', + true, + NULL, + 'Migrado desde Alibaba OSS el ' || NOW(), + NOW(), + NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM oss_api + WHERE provider = 's3' + AND endpoint = 'http://135.181.2.237:9000' + AND deleted_at IS NULL +); + +-- Si no se insertó (ya existe), activarla y actualizar credenciales +UPDATE oss_api +SET is_active = true, + access_key_id = 'admin', + access_key_secret = 'lsE6TAihW98iaXKDWG+6gGLYAFdyp2iC', + bucket_name = 'vcard-usite', + region = 'us-east-1', + notes = 'Actualizado el ' || NOW(), + updated_at = NOW() +WHERE provider = 's3' + AND endpoint = 'http://135.181.2.237:9000' + AND deleted_at IS NULL + AND ( + is_active = false + OR access_key_id != 'admin' + OR access_key_secret != 'lsE6TAihW98iaXKDWG+6gGLYAFdyp2iC' + ); + +COMMIT; + +-- Verificación +SELECT id, name, provider, endpoint, bucket_name, is_active, notes +FROM oss_api +WHERE deleted_at IS NULL +ORDER BY id DESC;