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),
}
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)
}
El flujo de pago con MercadoPago:
- Frontend llama a
POST /mp/crear-preferencia - Backend crea la preferencia en la API de MP y devuelve
init_point - Redirigimos al usuario a esa URL
- MP callback a
GET /mp/webhookcuando se aprueba - 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}
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)