Files
soft_usite/pkg/models/contabilidad.go
T
Lizandro GuarnizoandClaude Sonnet 5 351454c018 fix(contabilidad): "sin datos" ya no se ve igual que "se rompió"
En /app/contabilidad/cuentas-cobro la petición respondía bien pero la tabla
quedaba en blanco, sin nada que dijera qué había pasado.

Probando la vista real con axios simulado quedó claro que el problema no
era el renderizado: con datos pinta bien, y que falle /clientes/select
tampoco la rompe. Lo que faltaba era distinguir el caso vacío.

Dos arreglos, uno en cada punta:

- Las 8 consultas de contabilidad declaraban `var items []T`, que sin filas
  se serializa como null y no como []. La vista hacía x-for sobre null y no
  pintaba nada; peor, con null se colaba además una fila basura.

- Ninguna de las 5 tablas tenía estado vacío. Ahora dicen "No hay ...
  registradas", así que cero filas se lee como cero filas.

El x-for además queda blindado con (items || []) para no depender de que
todos los endpoints devuelvan siempre una lista.

Verificado con la vista real en tres escenarios: con datos pinta la fila,
vacío y null muestran el mensaje y ninguna fila de más.

Si la pantalla sigue sin traer registros después de esto, ya no es la
vista: es que la consulta no encuentra filas, y el mensaje lo va a decir.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:47:24 -05:00

868 lines
32 KiB
Go

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)
}
}
}