package models import ( "fmt" "log" "strings" "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 ClienteID uint `json:"cliente_id" gorm:"column:cliente_id;index;not null"` Cliente Cliente `json:"cliente" gorm:"foreignKey:ClienteID"` EntidadID *uint `json:"entidad_id" gorm:"column:entidad_id;index"` 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"` // Soporte de la factura de compra (el documento del proveedor), igual que // Factura.Archivo para las facturas de venta. Archivo string `json:"archivo" gorm:"column:archivo"` OriginalName string `json:"original_name" gorm:"column:original_name"` TipoMime string `json:"tipo_mime" gorm:"column:tipo_mime"` } 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) { // Inicializado para que sin filas el JSON sea [] y no null: null obliga // a cada vista a defenderse y hace que "vacío" y "roto" se vean igual. 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) { // Inicializado para que sin filas el JSON sea [] y no null: null obliga // a cada vista a defenderse y hace que "vacío" y "roto" se vean igual. 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) { // Inicializado para que sin filas el JSON sea [] y no null: null obliga // a cada vista a defenderse y hace que "vacío" y "roto" se vean igual. 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) { // Inicializado para que sin filas el JSON sea [] y no null: null obliga // a cada vista a defenderse y hace que "vacío" y "roto" se vean igual. 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 } // GetOrCreateEntidadProveedor busca un proveedor por nombre (sin distinguir // mayúsculas) y lo crea si no existe. Se usa para registrar facturas de compra // sin obligar a dar de alta al proveedor manualmente primero. func GetOrCreateEntidadProveedor(nombre string) (*Entidad, error) { nombre = strings.TrimSpace(nombre) if nombre == "" { return nil, fmt.Errorf("nombre de proveedor requerido") } var e Entidad err := app.Http.Database.DB.Where("LOWER(nombre) = LOWER(?)", nombre).First(&e).Error if err == nil { return &e, nil } e = Entidad{Nombre: nombre, Tipo: "proveedor", Activo: true} if err := app.Http.Database.DB.Create(&e).Error; err != nil { return nil, fmt.Errorf("no se pudo crear el proveedor: %w", err) } return &e, nil } 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) { // Inicializado para que sin filas el JSON sea [] y no null: null obliga // a cada vista a defenderse y hace que "vacío" y "roto" se vean igual. 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 { if err := app.Http.Database.DB.Create(t).Error; err != nil { return err } recomputarConsolidadoDeFecha(t.Fecha) return nil } // UpdateTransaccion actualiza una transacción y recalcula el consolidado mensual // del mes al que queda asociada. Si además cambió de mes, también recalcula el // mes anterior, para que ninguno de los dos quede desincronizado. func UpdateTransaccion(t *Transaccion) error { var anterior Transaccion tieneAnterior := app.Http.Database.DB.First(&anterior, t.ID).Error == nil if err := 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; err != nil { return err } recomputarConsolidadoDeFecha(t.Fecha) if tieneAnterior && !mismoMesYAnio(anterior.Fecha, t.Fecha) { recomputarConsolidadoDeFecha(anterior.Fecha) } return nil } // DeleteTransaccion elimina una transacción y recalcula el consolidado mensual // del mes al que pertenecía, para que no quede con montos que ya no existen. func DeleteTransaccion(id uint) error { var t Transaccion tieneFecha := app.Http.Database.DB.First(&t, id).Error == nil if err := app.Http.Database.DB.Delete(&Transaccion{}, id).Error; err != nil { return err } if tieneFecha { recomputarConsolidadoDeFecha(t.Fecha) } return nil } func mismoMesYAnio(a, b time.Time) bool { return a.Year() == b.Year() && a.Month() == b.Month() } // recomputarConsolidadoDeFecha recalcula (o crea) el ConsolidadoMensual del mes // de la fecha dada. Se ignora el error: el consolidado es derivado y se puede // recalcular de nuevo en cualquier momento desde /contabilidad/consolidado. func recomputarConsolidadoDeFecha(fecha time.Time) { _, _ = CalcularYGuardarConsolidado(int(fecha.Month()), fecha.Year()) } // ============================================================================= // ─── CRUD: CuentaCobro ────────────────────────────────────────────────────── // ============================================================================= func GetAllCuentasCobro(limit, offset int, search string, estado string) ([]CuentaCobro, int64, error) { // Inicializado para que sin filas el JSON sea [] y no null: null obliga // a cada vista a defenderse y hace que "vacío" y "roto" se vean igual. items := []CuentaCobro{} var total int64 db := app.Http.Database.DB.Model(&CuentaCobro{}).Preload("Cliente").Preload("Entidad").Preload("Transaccion") if search != "" { db = db.Joins("JOIN clientes ON clientes.id = contab_cuentas_cobro.cliente_id"). Where("clientes.nombre ILIKE ? OR clientes.empresa ILIKE ? OR contab_cuentas_cobro.descripcion ILIKE ?", "%"+search+"%", "%"+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 SeedBalanceData() { db := app.Http.Database.DB parseDate := func(s string) time.Time { t, _ := time.Parse("2006-01-02 15:04:05", s) return t } getEntidad := func(nombre string) *uint { var e Entidad if err := db.Where("nombre = ?", nombre).First(&e).Error; err == nil { return &e.ID } e = Entidad{Nombre: nombre, Tipo: "cliente", Activo: true} db.Create(&e) return &e.ID } getCuenta := func(codigo string) *uint { var c Cuenta if err := db.Where("codigo = ?", codigo).First(&c).Error; err == nil { return &c.ID } return nil } // ─── FACTURAS → Transacciones (ingresos) ──────────────────────────────── facturas := []struct { factura float64 valor float64 fecha string }{ {54, 450000, "2026-01-01 00:00:00"}, {55, 9000000, "2026-01-01 00:00:00"}, {56, 119000, "2026-01-01 00:00:00"}, {57, 330000, "2026-01-01 00:00:00"}, {58, 1000000, "2026-01-01 00:00:00"}, {59, 1000000, "2026-01-01 00:00:00"}, {60, 900000, "2026-01-01 00:00:00"}, {0, 0, "2026-01-01 00:00:00"}, // ajuste -130000 {61, 580000, "2026-02-01 00:00:00"}, {62, 330000, "2026-02-01 00:00:00"}, {63, 1785000, "2026-02-01 00:00:00"}, {64, 120000, "2026-02-01 00:00:00"}, {65, 350000, "2026-02-01 00:00:00"}, {66, 550000, "2026-02-01 00:00:00"}, {67, 1300000, "2026-02-01 00:00:00"}, {68, 270000, "2026-02-01 00:00:00"}, {69, 32000, "2026-02-01 00:00:00"}, {70, 3153500, "2026-02-01 00:00:00"}, {71, 349000, "2026-03-01 00:00:00"}, {72, 0, "2026-03-01 00:00:00"}, {73, 1380000, "2026-03-01 00:00:00"}, {74, 380000, "2026-03-01 00:00:00"}, {75, 2650000, "2026-04-01 00:00:00"}, {76, 390000, "2026-04-01 00:00:00"}, {77, 2200000, "2026-04-01 00:00:00"}, {78, 4400000, "2026-04-01 00:00:00"}, {79, 142000, "2026-04-01 00:00:00"}, } cuentaIng := getCuenta("ING-FAC") docuxer := getEntidad("DOCUXER") giaf := getEntidad("GIAF SAS") for _, f := range facturas { if f.factura == 0 && f.valor == 0 { // ajuste negativo: nota crédito var existing Transaccion if db.Where("descripcion = ?", "Ajuste/NC ene 2026").First(&existing).Error != nil { db.Create(&Transaccion{ Fecha: parseDate("2026-01-01 00:00:00"), Tipo: "egreso", Descripcion: "Ajuste/NC ene 2026", Valor: 130000, CuentaID: cuentaIng, Estado: "registrada", }) } continue } desc := fmt.Sprintf("Factura #%.0f", f.factura) var existing Transaccion if db.Where("descripcion = ? AND fecha = ?", desc, parseDate(f.fecha)).First(&existing).Error != nil { eid := docuxer if f.factura == 56 { eid = giaf } db.Create(&Transaccion{ Fecha: parseDate(f.fecha), Tipo: "ingreso", Descripcion: desc, Valor: f.valor, CuentaID: cuentaIng, EntidadID: eid, Estado: "registrada", }) } } // ─── IVA → Transacciones (egresos) ────────────────────────────────────── ivas := []struct { valor float64 entidad string desc string }{ {285000, "DOCUXER", "IVA Fact #63"}, {503500, "DOCUXER", "IVA Fact #70"}, {19000, "GIAF SAS", "IVA Fact #56"}, {503500, "DOCUXER", "IVA Fact #75"}, {418000, "DOCUXER", "IVA Fact #77"}, } cuentaImp := getCuenta("EGR-IMPU") for _, iv := range ivas { var existing Transaccion if db.Where("descripcion = ?", iv.desc).First(&existing).Error != nil { eid := getEntidad(iv.entidad) db.Create(&Transaccion{ Fecha: parseDate("2026-01-01 00:00:00"), Tipo: "egreso", Descripcion: iv.desc, Valor: iv.valor, CuentaID: cuentaImp, EntidadID: eid, Estado: "registrada", }) } } // ─── RETENCION → Transacciones (egresos) ──────────────────────────────── rets := []struct { valor float64 fecha string estado string }{ {195000, "2026-03-01 00:00:00", "pagado"}, {257000, "2026-02-01 00:00:00", "pagado"}, {431000, "2026-01-01 00:00:00", "pendiente"}, } for _, r := range rets { desc := fmt.Sprintf("Retención %s", r.fecha[:7]) var existing Transaccion if db.Where("descripcion = ?", desc).First(&existing).Error != nil { db.Create(&Transaccion{ Fecha: parseDate(r.fecha), Tipo: "egreso", Descripcion: desc, Valor: r.valor, CuentaID: cuentaImp, Estado: "registrada", }) } } // ─── CUENTAS DE COBRO (las que te pasan a ti) → CuentasPagar ──────────── cobros := []struct { entidad string valor float64 fecha string }{ {"NATALIA", 2000000, "2026-01-01 00:00:00"}, {"FELIPE", 2000000, "2026-01-01 00:00:00"}, {"CONTADORA", 1780000, "2026-01-01 00:00:00"}, {"FELIPE", 2300000, "2026-02-01 00:00:00"}, {"NATALIA", 2000000, "2026-02-01 00:00:00"}, {"FELIPE", 2300000, "2026-03-01 00:00:00"}, {"NATALIA", 2000000, "2026-03-01 00:00:00"}, {"FELIPE", 520000, "2026-04-01 00:00:00"}, {"ANDREMER", 80000, "2026-04-01 00:00:00"}, {"NATALIA", 2000000, "2026-04-01 00:00:00"}, // FAC POR PAGAR {"TECZONE", 2687849, "2026-01-01 00:00:00"}, } for _, cb := range cobros { eid := getEntidad(cb.entidad) desc := fmt.Sprintf("%s %s", cb.entidad, cb.fecha[:7]) var existing CuentaPagar if db.Where("descripcion = ?", desc).First(&existing).Error != nil { df := parseDate(cb.fecha) db.Create(&CuentaPagar{ EntidadID: *eid, Fecha: df, Descripcion: desc, Valor: cb.valor, Estado: "pendiente", }) } } // ─── CONSOLIDADO POR MES ─────────────────────────────────────────────── consols := []struct { mes int ing, egre, resul float64 }{ {1, 12669000, 8467849, 4201151}, {2, 8470500, 4300000, 4170500}, {3, 2109000, 4300000, -2191000}, {4, 9782000, 2600000, 7182000}, } for _, cs := range consols { var existing ConsolidadoMensual if db.Where("anio = 2026 AND mes = ?", cs.mes).First(&existing).Error != nil { db.Create(&ConsolidadoMensual{ Anio: 2026, Mes: cs.mes, TotalIngresos: cs.ing, TotalEgresos: cs.egre, Resultado: cs.resul, }) } } log.Println("[SEED] Balance data imported from BALANCE.numbers") } func GetCuentaCobroByID(id uint) (*CuentaCobro, error) { var item CuentaCobro if err := app.Http.Database.DB.Preload("Cliente").Preload("Entidad").First(&item, id).Error; err != nil { return nil, err } return &item, nil } func CreateCuentaCobro(cc *CuentaCobro) error { return app.Http.Database.DB.Create(cc).Error } // UpdateCuentaCobro actualiza los campos editables de una cuenta por cobrar. // No toca transaccion_id a propósito: ese vínculo solo lo debe crear // MarcarCuentaCobroPagada, para no perderlo en una edición cualquiera. func UpdateCuentaCobro(cc *CuentaCobro) error { return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", cc.ID).Updates(map[string]interface{}{ "cliente_id": cc.ClienteID, "estado": cc.Estado, "fecha_vencimiento": cc.FechaVencimiento, "fecha_pago": cc.FechaPago, "notas": cc.Notas, }).Error } // MarcarCuentaCobroPagada registra el pago de una cuenta por cobrar: crea la // Transaccion de tipo "ingreso" correspondiente y la vincula. Es idempotente — // si la cuenta ya estaba pagada, no crea una transacción duplicada. func MarcarCuentaCobroPagada(id uint, fechaPago time.Time) error { var cc CuentaCobro if err := app.Http.Database.DB.Preload("Cliente").First(&cc, id).Error; err != nil { return err } if cc.Estado == "pagado" { return nil } desc := fmt.Sprintf("Pago cuenta por cobrar #%d: %s", cc.ID, cc.Descripcion) if cc.Cliente.Nombre != "" { desc = fmt.Sprintf("Pago cuenta por cobrar #%d (%s): %s", cc.ID, cc.Cliente.Nombre, cc.Descripcion) } t := &Transaccion{ Fecha: fechaPago, Tipo: "ingreso", Descripcion: desc, Valor: cc.Valor, } if err := CreateTransaccion(t); err != nil { return fmt.Errorf("no se pudo crear la transacción de pago: %w", err) } return app.Http.Database.DB.Model(&CuentaCobro{}).Where("id = ?", id).Updates(map[string]interface{}{ "estado": "pagado", "fecha_pago": fechaPago, "transaccion_id": t.ID, }).Error } func DeleteCuentaCobro(id uint) error { return app.Http.Database.DB.Delete(&CuentaCobro{}, id).Error } // ============================================================================= // ─── CRUD: CuentaPagar ────────────────────────────────────────────────────── // ============================================================================= func GetCuentaPagarByID(id uint) (*CuentaPagar, error) { var item CuentaPagar if err := app.Http.Database.DB.First(&item, id).Error; err != nil { return nil, err } return &item, nil } func GetAllCuentasPagar(limit, offset int, search string, estado string) ([]CuentaPagar, int64, error) { // Inicializado para que sin filas el JSON sea [] y no null: null obliga // a cada vista a defenderse y hace que "vacío" y "roto" se vean igual. 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 } // MarcarCuentaPagarPagada registra el pago de una cuenta por pagar: crea la // Transaccion de tipo "egreso" correspondiente y la vincula. Es idempotente — // si la cuenta ya estaba pagada, no crea una transacción duplicada. func MarcarCuentaPagarPagada(id uint, fechaPago time.Time) error { var cp CuentaPagar if err := app.Http.Database.DB.Preload("Entidad").First(&cp, id).Error; err != nil { return err } if cp.Estado == "pagado" { return nil } desc := fmt.Sprintf("Pago cuenta por pagar #%d: %s", cp.ID, cp.Descripcion) if cp.Entidad.Nombre != "" { desc = fmt.Sprintf("Pago cuenta por pagar #%d (%s): %s", cp.ID, cp.Entidad.Nombre, cp.Descripcion) } t := &Transaccion{ Fecha: fechaPago, Tipo: "egreso", Descripcion: desc, Valor: cp.Valor, EntidadID: &cp.EntidadID, } if err := CreateTransaccion(t); err != nil { return fmt.Errorf("no se pudo crear la transacción de pago: %w", err) } return app.Http.Database.DB.Model(&CuentaPagar{}).Where("id = ?", id).Updates(map[string]interface{}{ "estado": "pagado", "fecha_pago": fechaPago, "transaccion_id": t.ID, }).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) { // Inicializado para que sin filas el JSON sea [] y no null: null obliga // a cada vista a defenderse y hace que "vacío" y "roto" se vean igual. 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: "DOCUXER", Tipo: "cliente"}, {Nombre: "GIAF SAS", Tipo: "cliente"}, {Nombre: "TECZONE", Tipo: "proveedor"}, {Nombre: "FELIPE", Tipo: "proveedor"}, {Nombre: "NATALIA", Tipo: "proveedor"}, {Nombre: "CONTADORA", Tipo: "proveedor"}, {Nombre: "ANDREMER", Tipo: "proveedor"}, } 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) } } }