DEV Community

Cover image for Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3
Cuotia
Cuotia

Posted on

Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3

Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3

Hace unos meses decidí resolver un problema que tenía mi propio consorcio: llevar el control de expensas en planillas de Excel, con pagos en efectivo sin recibo, y sin forma de saber quién debía qué. Lo que empezó como un proyecto de fin de semana terminó siendo Cuotia, una plataforma completa que hoy usan consorcios, escuelas, gimnasios y clubes en Argentina.

En este artículo cuento cómo está construida, qué decisiones técnicas tomé y qué aprendí en el camino.

Stack elegido

Backend: FastAPI (Python) + SQLAlchemy async + PostgreSQL 16
Frontend: Vue 3 + Vite + TailwindCSS + Pinia
Deploy: Docker Compose + Nginx en VPS Contabo
Pagos: MercadoPago API + Resend (emails)

No usé Next.js ni Nuxt porque quería separación clara entre API y frontend. FastAPI me da validación automática con Pydantic, docs en /docs sin esfuerzo y async nativo que necesitaba para las consultas concurrentes.

Arquitectura general

Internet → Cloudflare → Nginx → {
/api/* → FastAPI (puerto 8000)
/* → Vue SPA (Nginx interno)
}

Cuatro contenedores Docker:

  • db — PostgreSQL
  • api — FastAPI con uvicorn
  • frontend — Nginx sirviendo el build de Vite
  • nginx — reverse proxy + SSL con Certbot

El docker-compose de producción tiene todo en una red interna (app-network) y solo expone los puertos 80/443 al exterior.

El desafío del multi-rubro

El feature más interesante técnicamente fue hacer que la misma plataforma sirva para rubros completamente distintos sin duplicar código.

Un consorcio habla de departamentos, expensas y propietarios. Una escuela habla de alumnos, aranceles y grados. Un gimnasio habla de socios, mensualidades y planes.

La solución: una tabla rubros en la DB con todos los labels configurables:

python
class Rubro(Base):
tablename = "rubros"
id = Column(Integer, primary_key=True)
codigo = Column(String(30), unique=True) # "consorcio", "escuela", "gimnasio"
nombre = Column(String(100))
label_unidad = Column(String(50)) # "Departamento" / "Alumno" / "Socio"
label_miembro = Column(String(50)) # "Inquilino" / "Padre/Tutor" / "Socio"
label_cuota = Column(String(50)) # "Expensa" / "Arancel" / "Mensualidad"
label_grupo = Column(String(50)) # "Piso" / "Grado" / "Turno"
tiene_piso = Column(Boolean)
tiene_coeficiente = Column(Boolean)

Y en el frontend, un Pinia store que carga el vocabulario del rubro del usuario:

typescript
// stores/vocabulario.ts
const v = computed(() => ({
unidad: vocab.value?.label_unidad ?? 'Unidad',
miembro: vocab.value?.label_miembro ?? 'Miembro',
cuota: vocab.value?.label_cuota ?? 'Cuota',
label_grupo: vocab.value?.label_grupo ?? 'Grupo',
}))

const esConsorcio = computed(() =>
vocab.value?.rubro_codigo === 'consorcio'
)

Resultado: el mismo componente DepartamentosView.vue muestra "Alumnos" o "Departamentos" o "Socios" según quien esté logueado, sin un solo if (rubro === 'escuela') en el template.

Cierre de mes automático

Uno de los features más pedidos: al cerrar el mes, generar automáticamente las deudas de quienes no pagaron.

python
@router.post("/{consorcio_id}/cerrar-mes")
async def cerrar_mes(consorcio_id: int, ...):
departamentos = await get_departamentos(consorcio_id)
ya_pagaron = []
ya_tienen_deuda = []
deudas_generadas = 0

for depto in departamentos:
    pago = await get_pago_periodo(depto.id, periodo)

    if pago:
        ya_pagaron.append(depto.numero)
        continue

    deuda_existente = await get_deuda_activa(depto.id, periodo)
    if deuda_existente:
        ya_tienen_deuda.append(depto.numero)
        continue

    # Calcular monto con coeficiente
    monto = consorcio.expensa_mensual * depto.coeficiente
    db.add(Deuda(
        departamento_id=depto.id,
        periodo=periodo,
        monto_original=monto,
        monto_actual=monto,
    ))
    deudas_generadas += 1

await db.commit()
return {
    "deudas_generadas": deudas_generadas,
    "ya_pagaron": len(ya_pagaron),
    "ya_tienen_deuda": len(ya_tienen_deuda),
}
Enter fullscreen mode Exit fullscreen mode

El bug más difícil acá: al principio mezclaba "ya pagaron" con "ya tienen deuda" en un solo contador. Llevaba a mensajes como "0 deudas generadas, 15 ya pagaron" cuando en realidad 14 ya pagaron y 1 ya tenía deuda de antes. Separar los casos fue simple pero tomó un rato entender por qué los números no cerraban.

Portal de pagos para inquilinos

Cada departamento tiene un public_token único. Con ese token, el inquilino accede a una vista pública donde ve su estado de cuenta y puede pagar con MercadoPago, sin necesidad de crear cuenta.

python
@router.get("/portal/{public_token}")
async def portal_publico(public_token: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Departamento).where(Departamento.public_token == public_token)
)
depto = result.scalar_one_or_none()
if not depto:
raise HTTPException(404)

deudas = await get_deudas_activas(depto.id)
pagos = await get_pagos_recientes(depto.id)

return {
    "departamento": depto.numero,
    "deudas": deudas,
    "pagos": pagos,
    "puede_pagar_online": bool(depto.consorcio.mp_access_token)
}
Enter fullscreen mode Exit fullscreen mode

El flujo de pago con MercadoPago:

  1. Frontend llama a POST /mp/crear-preferencia
  2. Backend crea la preferencia en la API de MP y devuelve init_point
  3. Redirigimos al usuario a esa URL
  4. MP callback a GET /mp/webhook cuando se aprueba
  5. Backend registra el pago y envía recibo por email con Resend

Importación masiva desde Excel

Para la incorporación inicial de datos, construí un endpoint que acepta un .xlsx y hace upsert por número de unidad:

python
@router.post("/{consorcio_id}/import/departamentos")
async def importar_departamentos(
consorcio_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
):
content = await file.read()
wb = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)
ws = wb.active

existing = {d.numero: d for d in await get_departamentos(consorcio_id)}
created, updated, errors = 0, 0, []

for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
    numero = str(row[0]).strip() if row[0] else None
    if not numero:
        continue
    if numero in existing:
        # actualizar
        updated += 1
    else:
        db.add(Departamento(consorcio_id=consorcio_id, numero=numero, ...))
        created += 1

await db.commit()
return {"creados": created, "actualizados": updated, "errores": errors}
Enter fullscreen mode Exit fullscreen mode

Y lo interesante: la plantilla descargable se adapta al rubro. Una escuela descarga una plantilla que dice "grado" en vez de "piso" y "alumno" en vez de "inquilino".

Freemium y feature flags

El modelo de negocio es freemium: plan gratuito con límite de 30 unidades, planes pagos con funcionalidades adicionales. Implementé un sistema de feature flags por plan:

python

En cada endpoint premium

@router.post("/{consorcio_id}/gastos")
async def crear_gasto(consorcio_id: int, ...):
plan = await get_plan(consorcio_id)
if not plan.tiene_extraordinarias:
raise HTTPException(403, "Funcionalidad no disponible en tu plan")

En el frontend, un store de features que bloquea la UI antes de que llegue al backend:

typescript
const featuresStore = useFeaturesStore()

// En el template
<button
@click="featuresStore.tiene('tiene_mp_integrado') ? abrirConfig() : irAPlanes()"
:class="!featuresStore.tiene('tiene_mp_integrado') ? 'opacity-50 cursor-not-allowed' : ''"

Configurar MercadoPago

Lo que aprendí

Sobre el stack:

  • FastAPI + SQLAlchemy async es una combinación excelente para APIs CRUD. La generación automática de OpenAPI docs ahorra horas.
  • Vue 3 Composition API con <script setup> es un placer de usar comparado con Options API.
  • Pinia es simple y predecible. No extraño Vuex.

Sobre el producto:

  • El vocabulario multi-rubro fue la decisión más inteligente. Hubiera necesitado 5 productos separados sin eso.
  • Los administradores de consorcios no son técnicos. El wizard de configuración inicial redujo drásticamente los mensajes de soporte.
  • El cierre de mes automático es el feature más usado. La gente literalmente esperaba hacer eso manual cada mes.

Sobre lanzar:

  • Empezar con un consorcio real (el propio) antes de monetizar fue clave para entender el flujo real.
  • Los primeros usuarios llegaron por boca a boca en grupos de WhatsApp de administradores.

Estado actual y próximos pasos

La plataforma está live en cuotia.com.ar con plan gratuito sin tarjeta de crédito.

Lo que viene:

  • Recordatorios automáticos por email para deudas vencidas
  • App mobile (PWA primero, nativa si hay demanda)
  • Migración de SQL manual a Alembic migrations (deuda técnica real)
  • API pública para integraciones con sistemas contables

Si estás construyendo algo similar o tenés preguntas sobre el stack, dejá un comentario. Y si administrás un consorcio, escuela o gimnasio en Argentina, probalo gratis.

Stack completo: FastAPI · SQLAlchemy · PostgreSQL · Vue 3 · Vite · TailwindCSS · Pinia · Docker · Nginx · MercadoPago · Resend

Tags: python vue fastapi saas argentina

No usé Next.js ni Nuxt porque quería separación clara entre API y frontend. FastAPI me da validación automática con Pydantic, docs en /docs sin esfuerzo y async nativo que necesitaba para las consultas concurrentes.

Top comments (0)