Skip to content

📙 Clase 35 — 🏆 Proyecto final: Gestor de Gastos

Fase 6 · Especialización: Apps de escritorio 🎯 · Proyecto integrador ⬅️ Volver al índice de clases

🎯 La misión

Construir —tú, de cero— una app de escritorio completa y distribuible: un Gestor de Gastos Personales. Usa TODO el curso. Esta clase es la guía por etapas (modo guiado, como tus ejercicios: qué construir + pistas, sin solución — el código es tuyo 💪).

🖼️ Qué vas a construir

┌─────────────────────────────────────────────┐
│  💰 Mis Gastos                    [⚙️] [📤] │
│  ┌───────────────────────────────────────┐  │
│  │ Concepto [________] Monto [____]      │  │
│  │ Categoría [Comida ▼]     [+ Agregar]  │  │
│  └───────────────────────────────────────┘  │
│  🔎 [buscar…]        Mes: [Julio ▼]         │
│  ┌───────────────────────────────────────┐  │
│  │ 11/07  Almuerzo      Comida    S/25 🗑 │  │
│  │ 10/07  Taxi          Transporte S/15 🗑 │  │
│  │ …                                      │  │
│  └───────────────────────────────────────┘  │
│  Total del mes: S/ 480    [os por categoría]│
└─────────────────────────────────────────────┘

Funcionalidad mínima (v1.0): agregar gasto (concepto, monto, categoría, fecha automática) · listarlos ordenados por fecha · buscar por texto · eliminar con confirmación · total del mes · exportar a CSV · los datos persisten en SQLite · empaquetada como .app.

🗺️ Las 7 etapas (cada una usa lo que ya sabes)

Etapa 1 — Esqueleto y arquitectura (Clases 11, 18, 29)

  • Estructura: main.py, ui.py, logica.py, datos.py.
  • class App(ctk.CTk) con título, tamaño y un label "Hola gastos" para verificar arranque.
  • Hito: python main.py abre la ventana.

Etapa 2 — La base de datos (Clases 30, 31)

  • En datos.py: conectar() con la tabla gastos(id AUTOINCREMENT, concepto TEXT NOT NULL, monto REAL, categoria TEXT, fecha TEXT).
  • Funciones CRUD: insertar_gasto(con, dict), obtener_gastos(con, filtro=""), eliminar_gasto(con, id).
  • 💡 Pista: BD en Path.home() / ".mis_gastos" / "gastos.db" desde YA (Clases 22 y 34).
  • Hito: desde la terminal (sin GUI) insertas y lees gastos.

Etapa 3 — El formulario (Clases 08, 09, 12)

  • Entries de concepto y monto + CTkOptionMenu de categorías + botón Agregar.
  • En logica.py: validar_gasto(concepto, monto) -> tuple[bool, str] (monto numérico y > 0 — atrapa el ValueError del float()).
  • Fecha automática: datetime.now().isoformat() (Clase 22).
  • Hito: agregar un gasto desde la GUI y verlo en la BD.

Etapa 4 — La lista viva (Clases 02, 07, 31)

  • _pintar() que consulta la BD y crea una fila-widget por gasto (concepto, monto, 🗑).
  • Búsqueda en vivo (bind("<KeyRelease>")LIKE).
  • Eliminar con askyesno (Clase 32) y repintar desde la BD.
  • Hito: buscar "taxi" filtra; borrar pide confirmación.

Etapa 5 — Resumen y orden (Clases 24, 31)

  • Label de total del mes: SELECT SUM(monto) WHERE fecha LIKE '2026-07%'.
  • Desplegable "ordenar por": fecha / monto / categoría (ORDER BY o sorted(key=)).
  • 🎁 Reto extra: ventana CTkToplevel con totales por categoría (GROUP BY categoria).
  • Hito: el total cambia al agregar/borrar.

Etapa 6 — Exportar (Clases 21, 32)

  • Botón 📤 → asksaveasfilenamecsv.DictWriter con los gastos visibles.
  • showinfo al terminar; maneja la cancelación ("").
  • Hito: el CSV abre en Excel/Numbers con sus columnas.

Etapa 7 — Pulir y empaquetar (Clases 26, 28, 34)

  • Type hints en logica.py y datos.py (Clase 27).
  • pyinstaller --windowed --name "MisGastos" --collect-all customtkinter main.py.
  • Pruébala como app: agrega gastos, ciérrala, reábrela.
  • Hito final: 🏆 una app de doble clic, con BD, hecha 100% por ti.

📋 Checklist de calidad (el sello "pro")

  • [ ] Cero global; estado en self · [ ] logica.py/datos.py no importan customtkinter
  • [ ] Todo SQL con placeholders · [ ] Escrituras dentro de with con:
  • [ ] Acciones destructivas confirman · [ ] Errores → mensajes amables (nunca traceback)
  • [ ] Datos en Path.home() · [ ] requirements.txt actualizado

💡 Método: una etapa por sesión de estudio. Si te atascas, cada etapa te dice QUÉ clase repasar. Los tropiezos van a 06-Errores — serán tus mejores apuntes.

🔐 Solución de referencia (v1.0 mínima)

⚠️ Última puerta: este proyecto es tu examen final — el valor está en pelearlo. Abre cada archivo solo cuando tu versión de esa capa ya funcione (o lleves >1 sesión atascado en la misma etapa). Verificada en tu .venv: logica y datos pasan pruebas automáticas sin GUI; la vista compila e importa. Cubre las etapas 1–4 y 6 + total del mes (los retos de la etapa 5 y el empaquetado quedan para ti).

Ver solución · logica.py
python
"""Reglas puras: sin widgets, sin SQL."""

def validar_gasto(concepto: str, monto_texto: str) -> tuple[bool, str]:
    if not concepto.strip():
        return False, "El concepto está vacío"
    try:
        monto = float(monto_texto)
    except ValueError:
        return False, "El monto debe ser un número"
    if monto <= 0:
        return False, "El monto debe ser mayor a 0"
    return True, ""
Ver solución · datos.py
python
"""Capa de datos: lo único que sabe SQL."""
import sqlite3
from pathlib import Path

RUTA_BD = Path.home() / ".mis_gastos" / "gastos.db"

def conectar(ruta=RUTA_BD):
    ruta.parent.mkdir(exist_ok=True)
    con = sqlite3.connect(ruta)
    con.row_factory = sqlite3.Row
    con.execute("""CREATE TABLE IF NOT EXISTS gastos (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        concepto TEXT NOT NULL,
        monto REAL NOT NULL,
        categoria TEXT,
        fecha TEXT)""")
    return con

def insertar_gasto(con, gasto: dict) -> int:
    with con:
        cur = con.execute(
            "INSERT INTO gastos (concepto, monto, categoria, fecha) "
            "VALUES (:concepto, :monto, :categoria, :fecha)", gasto)
    return cur.lastrowid

def obtener_gastos(con, filtro: str = "") -> list:
    return con.execute(
        "SELECT * FROM gastos WHERE concepto LIKE ? ORDER BY fecha DESC",
        (f"%{filtro}%",)).fetchall()

def eliminar_gasto(con, id: int) -> bool:
    with con:
        return con.execute("DELETE FROM gastos WHERE id = ?", (id,)).rowcount > 0

def total_del_mes(con, anio_mes: str) -> float:
    fila = con.execute(
        "SELECT COALESCE(SUM(monto), 0) FROM gastos WHERE fecha LIKE ?",
        (f"{anio_mes}%",)).fetchone()
    return fila[0]
Ver solución · ui.py
python
"""Vista: widgets y callbacks; delega en logica y datos."""
from datetime import datetime
from tkinter import messagebox, filedialog
import csv
import customtkinter as ctk
import logica
import datos

CATEGORIAS = ["Comida", "Transporte", "Casa", "Ocio", "Otros"]

class App(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("💰 Mis Gastos")
        self.geometry("520x600")
        self.con = datos.conectar()
        self._crear_widgets()
        self._pintar()

    def _crear_widgets(self):
        form = ctk.CTkFrame(self)
        form.pack(fill="x", padx=12, pady=12)
        self.e_concepto = ctk.CTkEntry(form, placeholder_text="Concepto")
        self.e_concepto.pack(side="left", expand=True, fill="x", padx=6, pady=8)
        self.e_monto = ctk.CTkEntry(form, placeholder_text="Monto", width=80)
        self.e_monto.pack(side="left", padx=6)
        self.m_categoria = ctk.CTkOptionMenu(form, values=CATEGORIAS, width=110)
        self.m_categoria.pack(side="left", padx=6)
        ctk.CTkButton(form, text="+ Agregar", width=90,
                      command=self._agregar).pack(side="left", padx=6)

        barra = ctk.CTkFrame(self)
        barra.pack(fill="x", padx=12)
        self.buscador = ctk.CTkEntry(barra, placeholder_text="🔎 Buscar…")
        self.buscador.pack(side="left", expand=True, fill="x", padx=6, pady=6)
        self.buscador.bind("<KeyRelease>", lambda e: self._pintar())   # búsqueda en vivo
        ctk.CTkButton(barra, text="📤 CSV", width=80,
                      command=self._exportar).pack(side="left", padx=6)

        self.lista = ctk.CTkScrollableFrame(self)
        self.lista.pack(fill="both", expand=True, padx=12, pady=12)
        self.status = ctk.CTkLabel(self, text="")
        self.status.pack(pady=(0, 10))

    def _agregar(self):
        ok, msg = logica.validar_gasto(self.e_concepto.get(), self.e_monto.get())
        if not ok:
            self.status.configure(text=f"⚠️ {msg}")
            return
        datos.insertar_gasto(self.con, {
            "concepto": self.e_concepto.get().strip(),
            "monto": float(self.e_monto.get()),
            "categoria": self.m_categoria.get(),
            "fecha": datetime.now().strftime("%Y-%m-%d"),
        })
        self.e_concepto.delete(0, "end")
        self.e_monto.delete(0, "end")
        self._pintar()

    def _eliminar(self, id):
        if not messagebox.askyesno("Confirmar", "¿Eliminar este gasto?"):
            return
        datos.eliminar_gasto(self.con, id)
        self._pintar()

    def _exportar(self):
        ruta = filedialog.asksaveasfilename(defaultextension=".csv",
                                            filetypes=[("CSV (Excel)", "*.csv")])
        if not ruta:                       # canceló
            return
        gastos = datos.obtener_gastos(self.con, self.buscador.get())
        with open(ruta, "w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            writer.writerow(["fecha", "concepto", "categoria", "monto"])
            for g in gastos:
                writer.writerow([g["fecha"], g["concepto"], g["categoria"], g["monto"]])
        messagebox.showinfo("Listo", f"Exportados {len(gastos)} gastos")

    def _pintar(self):
        for w in self.lista.winfo_children():
            w.destroy()
        for g in datos.obtener_gastos(self.con, self.buscador.get()):
            fila = ctk.CTkFrame(self.lista)
            texto = f'{g["fecha"]}  {g["concepto"]}  ·  {g["categoria"]}  ·  S/{g["monto"]:.2f}'
            ctk.CTkLabel(fila, text=texto).pack(side="left", padx=6)
            ctk.CTkButton(fila, text="🗑", width=30,
                          command=lambda id=g["id"]: self._eliminar(id)).pack(side="right", padx=4)
            fila.pack(fill="x", pady=2)
        mes = datetime.now().strftime("%Y-%m")
        self.status.configure(text=f"Total del mes: S/ {datos.total_del_mes(self.con, mes):.2f}")
Ver solución · main.py
python
from ui import App

if __name__ == "__main__":
    App().mainloop()

🧪 Para la entrevista: al terminar tendrás un proyecto que demuestra: POO, arquitectura en capas, SQL parametrizado, manejo de errores, persistencia y distribución. Es exactamente el tipo de proyecto que se muestra en un portafolio.

🚀 ¿Y después? (rutas de crecimiento)

Siguiente pasoQué agrega
Gráficos de gastosmatplotlib embebido en CTk
Múltiples monedasla API de la Clase 23 + threading (Clase 33)
Respaldo en la nubesubir el .db / sincronizar vía API
Testspytest sobre logica.py y datos.py (¡ya son testeables!)
Otra app del mundo realinventario, citas, biblioteca… mismo esqueleto

📎 Apuntes relacionados

  • El índice con TODAS las clases → 00-Indice
  • Tus ejercicios previos (el camino hasta aquí) → 01 · 02 · 03

🎓 Fin de la ruta

Cuando termines este proyecto, habrás recorrido las 6 fases completas. Avísame y armamos el 90-Resumen/ del curso + tu siguiente ruta (gráficos, tests, o la especialización que elijas).