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>
868 lines
32 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|