<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Cuotia</title>
    <description>The latest articles on DEV Community by Cuotia (@cuotiaapp).</description>
    <link>https://dev.to/cuotiaapp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3885019%2F58cde90d-b056-41e0-ae00-dca489dc3cc0.png</url>
      <title>DEV Community: Cuotia</title>
      <link>https://dev.to/cuotiaapp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cuotiaapp"/>
    <language>en</language>
    <item>
      <title>Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3</title>
      <dc:creator>Cuotia</dc:creator>
      <pubDate>Fri, 17 Apr 2026 18:31:36 +0000</pubDate>
      <link>https://dev.to/cuotiaapp/como-construi-un-saas-multi-rubro-para-gestionar-expensas-en-argentina-con-fastapi-vue-3-7p9</link>
      <guid>https://dev.to/cuotiaapp/como-construi-un-saas-multi-rubro-para-gestionar-expensas-en-argentina-con-fastapi-vue-3-7p9</guid>
      <description>&lt;h1&gt;
  
  
  Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3
&lt;/h1&gt;

&lt;p&gt;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 &lt;a href="https://cuotia.com.ar" rel="noopener noreferrer"&gt;Cuotia&lt;/a&gt;, una plataforma completa que hoy usan consorcios, escuelas, gimnasios y clubes en Argentina.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Stack elegido
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Arquitectura general
&lt;/h2&gt;

&lt;p&gt;Internet → Cloudflare → Nginx → {&lt;br&gt;
  /api/*     → FastAPI (puerto 8000)&lt;br&gt;
  /*         → Vue SPA (Nginx interno)&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Cuatro contenedores Docker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;db&lt;/code&gt; — PostgreSQL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api&lt;/code&gt; — FastAPI con uvicorn&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frontend&lt;/code&gt; — Nginx sirviendo el build de Vite&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nginx&lt;/code&gt; — reverse proxy + SSL con Certbot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El docker-compose de producción tiene todo en una red interna (&lt;code&gt;app-network&lt;/code&gt;) y solo expone los puertos 80/443 al exterior.&lt;/p&gt;
&lt;h1&gt;
  
  
  El desafío del multi-rubro
&lt;/h1&gt;

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

&lt;p&gt;Un consorcio habla de &lt;em&gt;departamentos&lt;/em&gt;, &lt;em&gt;expensas&lt;/em&gt; y &lt;em&gt;propietarios&lt;/em&gt;. Una escuela habla de &lt;em&gt;alumnos&lt;/em&gt;, &lt;em&gt;aranceles&lt;/em&gt; y &lt;em&gt;grados&lt;/em&gt;. Un gimnasio habla de &lt;em&gt;socios&lt;/em&gt;, &lt;em&gt;mensualidades&lt;/em&gt; y &lt;em&gt;planes&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;La solución: una tabla &lt;code&gt;rubros&lt;/code&gt; en la DB con todos los labels configurables:&lt;/p&gt;

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

&lt;p&gt;Y en el frontend, un Pinia store que carga el vocabulario del rubro del usuario:&lt;/p&gt;

&lt;p&gt;typescript&lt;br&gt;
// stores/vocabulario.ts&lt;br&gt;
const v = computed(() =&amp;gt; ({&lt;br&gt;
  unidad:    vocab.value?.label_unidad    ?? 'Unidad',&lt;br&gt;
  miembro:   vocab.value?.label_miembro  ?? 'Miembro',&lt;br&gt;
  cuota:     vocab.value?.label_cuota    ?? 'Cuota',&lt;br&gt;
  label_grupo: vocab.value?.label_grupo  ?? 'Grupo',&lt;br&gt;
}))&lt;/p&gt;

&lt;p&gt;const esConsorcio = computed(() =&amp;gt;&lt;br&gt;
  vocab.value?.rubro_codigo === 'consorcio'&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;Resultado: el mismo componente &lt;code&gt;DepartamentosView.vue&lt;/code&gt; muestra "Alumnos" o "Departamentos" o "Socios" según quien esté logueado, sin un solo &lt;code&gt;if (rubro === 'escuela')&lt;/code&gt; en el template.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cierre de mes automático
&lt;/h2&gt;

&lt;p&gt;Uno de los features más pedidos: al cerrar el mes, generar automáticamente las deudas de quienes no pagaron.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
@router.post("/{consorcio_id}/cerrar-mes")&lt;br&gt;
async def cerrar_mes(consorcio_id: int, ...):&lt;br&gt;
    departamentos = await get_departamentos(consorcio_id)&lt;br&gt;
    ya_pagaron = []&lt;br&gt;
    ya_tienen_deuda = []&lt;br&gt;
    deudas_generadas = 0&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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),
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Portal de pagos para inquilinos
&lt;/h2&gt;

&lt;p&gt;Cada departamento tiene un &lt;code&gt;public_token&lt;/code&gt; ú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.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
@router.get("/portal/{public_token}")&lt;br&gt;
async def portal_publico(public_token: str, db: AsyncSession = Depends(get_db)):&lt;br&gt;
    result = await db.execute(&lt;br&gt;
        select(Departamento).where(Departamento.public_token == public_token)&lt;br&gt;
    )&lt;br&gt;
    depto = result.scalar_one_or_none()&lt;br&gt;
    if not depto:&lt;br&gt;
        raise HTTPException(404)&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;El flujo de pago con MercadoPago:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Frontend llama a &lt;code&gt;POST /mp/crear-preferencia&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Backend crea la preferencia en la API de MP y devuelve &lt;code&gt;init_point&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Redirigimos al usuario a esa URL&lt;/li&gt;
&lt;li&gt;MP callback a &lt;code&gt;GET /mp/webhook&lt;/code&gt; cuando se aprueba&lt;/li&gt;
&lt;li&gt;Backend registra el pago y envía recibo por email con Resend&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Importación masiva desde Excel
&lt;/h2&gt;

&lt;p&gt;Para la incorporación inicial de datos, construí un endpoint que acepta un &lt;code&gt;.xlsx&lt;/code&gt; y hace upsert por número de unidad:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
@router.post("/{consorcio_id}/import/departamentos")&lt;br&gt;
async def importar_departamentos(&lt;br&gt;
    consorcio_id: int,&lt;br&gt;
    file: UploadFile = File(...),&lt;br&gt;
    db: AsyncSession = Depends(get_db),&lt;br&gt;
):&lt;br&gt;
    content = await file.read()&lt;br&gt;
    wb = load_workbook(filename=BytesIO(content), read_only=True, data_only=True)&lt;br&gt;
    ws = wb.active&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;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".&lt;/p&gt;
&lt;h2&gt;
  
  
  Freemium y feature flags
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;python&lt;/p&gt;
&lt;h1&gt;
  
  
  En cada endpoint premium
&lt;/h1&gt;

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

&lt;p&gt;En el frontend, un store de features que bloquea la UI antes de que llegue al backend:&lt;/p&gt;

&lt;p&gt;typescript&lt;br&gt;
const featuresStore = useFeaturesStore()&lt;/p&gt;

&lt;p&gt;// En el template&lt;br&gt;
&amp;lt;button &lt;br&gt;
  &lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;="featuresStore.tiene('tiene_mp_integrado') ? abrirConfig() : irAPlanes()"&lt;br&gt;
  :class="!featuresStore.tiene('tiene_mp_integrado') ? 'opacity-50 cursor-not-allowed' : ''"&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Configurar MercadoPago&lt;br&gt;
&lt;/p&gt;


&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Lo que aprendí
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sobre el stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FastAPI + SQLAlchemy async es una combinación excelente para APIs CRUD. La generación automática de OpenAPI docs ahorra horas.&lt;/li&gt;
&lt;li&gt;Vue 3 Composition API con &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; es un placer de usar comparado con Options API.&lt;/li&gt;
&lt;li&gt;Pinia es simple y predecible. No extraño Vuex.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sobre el producto:&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Sobre lanzar:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Empezar con un consorcio real (el propio) antes de monetizar fue clave para entender el flujo real.&lt;/li&gt;
&lt;li&gt;Los primeros usuarios llegaron por boca a boca en grupos de WhatsApp de administradores.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Estado actual y próximos pasos
&lt;/h2&gt;

&lt;p&gt;La plataforma está live en &lt;a href="https://cuotia.com.ar" rel="noopener noreferrer"&gt;cuotia.com.ar&lt;/a&gt; con plan gratuito sin tarjeta de crédito.&lt;/p&gt;

&lt;p&gt;Lo que viene:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Stack completo: FastAPI · SQLAlchemy · PostgreSQL · Vue 3 · Vite · TailwindCSS · Pinia · Docker · Nginx · MercadoPago · Resend&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: &lt;code&gt;python&lt;/code&gt; &lt;code&gt;vue&lt;/code&gt; &lt;code&gt;fastapi&lt;/code&gt; &lt;code&gt;saas&lt;/code&gt; &lt;code&gt;argentina&lt;/code&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;/docs&lt;/code&gt; sin esfuerzo y async nativo que necesitaba para las consultas concurrentes.&lt;/p&gt;

</description>
      <category>python</category>
      <category>vue</category>
      <category>fastapi</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
