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 @@ +
Consolidado mensual de ingresos y egresos
+Ingresos
+ +Egresos
+ +Resultado
+ +Transacciones
+ +| Fecha | +Tipo | +Descripción | +Categoría | +Entidad | +Valor | +
|---|---|---|---|---|---|
| + | + + | ++ | + | + | + |
| Sin transacciones este mes | |||||
Plan de cuentas para clasificar transacciones
+| Código | +Nombre | +Tipo | +Color | +Activo | +Acciones | +
|---|---|---|---|---|---|
| Cargando... | |||||
| + | + + + | ++ + | ++ | + Activo + Inactivo + | +
+
+
+
+
+ |
+
Facturas y montos pendientes de cobro
+| Fecha | +Entidad | +Descripción | +Valor | +Vence | +Estado | +Acciones | +
|---|---|---|---|---|---|---|
| Cargando... | ||||||
| + | + | + | + | + | + + | +
+
+
+
+
+ |
+
Facturas y montos pendientes de pago
+| Fecha | +Entidad | +Descripción | +Valor | +Vence | +Estado | +Acciones | +
|---|---|---|---|---|---|---|
| Cargando... | ||||||
| + | + | + | + | + | + + | +
+
+
+
+
+ |
+
Clientes, proveedores y empresas
+| Nombre | +Tipo | +Documento | +Teléfono | +Contacto | +Activo | +Acciones | +|
|---|---|---|---|---|---|---|---|
| Cargando... | |||||||
| + | + | + | + | + | + | ActivoInactivo | +
+
+
+
+
+ |
+
Registro de ingresos y egresos
+| Fecha | +Tipo | +Descripción | +Categoría | +Entidad | +Valor | +Pago | +Acciones | +
|---|---|---|---|---|---|---|---|
| Cargando... | |||||||
| + | + + | ++ | + + + | ++ | + | + |
+
+
+
+
+ |
+
Esta acción no se puede deshacer.
+Integración de SMS para notificaciones
+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
+ | Fecha | +Para | +Mensaje | +Estado | +ID | +
|---|---|---|---|---|
| + | + | + | + + | ++ |
| Sin envíos aún | ||||