@jggalviz1 desarrollador · full-stackSaaS multi-tenant en producción

Cómo diseñé y construí Medisys

SaaS médico multi-tenant y suite fiscal para Venezuela

Una plataforma que digitaliza la operación completa de una clínica —agenda, cobros, expedientes, facturación y contabilidad— en un mercado donde la tasa oficial cambia a diario, las pasarelas internacionales no operan y cada factura debe cumplir con el SENIAT. Este es el recorrido técnico del proyecto: las decisiones de arquitectura, sus compromisos y los problemas difíciles que resolví.

Rol
Arquitectura, backend, frontend y datos
Alcance
Producto completo: 3 módulos de gestión, portales y Super Admin · 49 rutas
Restricción
Equipo unipersonal · infraestructura serverless
44.3k
líneas TS/TSX

212 archivos en src/

22
migraciones SQL

esquema versionado de 16 tablas

64
políticas RLS

aislamiento por clínica y por rol

49
rutas App Router

32 páginas + 17 route handlers

20
módulos use server

mutaciones sin API REST intermedia

5 × 21
roles × permisos

RBAC calculado desde una única fuente

  • Monolito modular, no microservicios

    Un despliegue en Vercel con Server Components y Server Actions: menos superficie operativa, mismos límites de dominio.

  • Seguridad en la base de datos

    64 políticas RLS aíslan cada clínica y cada rol: un fallo en la interfaz no puede filtrar datos de otro tenant.

  • Concurrencia con reloj

    Locks de 15 minutos sobre los turnos: el cupo se libera solo si el paciente no completa el pago a tiempo.

  • Cumplimiento fiscal automatizado

    IVA 16 %, IGTF 3 % solo en divisas, doble despliegue USD/VES y numeración fiscal: cálculo determinista y auditable, sin IA en runtime.

01El reto

Un sector que todavía opera con papel, teléfono y Excel

Medisys no nació como un ejercicio de arquitectura: nació de tres dolores concretos de las clínicas venezolanas. Entenderlos fue lo que definió cada decisión técnica posterior.

La agenda vive en un cuaderno y en WhatsApp

Dos secretarias, un teléfono y una hoja de cálculo por sede. Los turnos se anotan a lápiz y se tachan cuando el paciente confirma por mensaje.

Impacto: Cupos duplicados y horas de consultorio vacías: cada cita fantasma es ingreso perdido y un paciente que no vuelve.

Cobrar en un país sin pasarelas de pago

Las pasarelas internacionales tardan meses en aprobar una cuenta; en la práctica se cobra con Pago Móvil, Zelle, efectivo y punto de venta, verificando capturas de pantalla enviadas por el paciente.

Impacto: Precios fijados en dólares y cobros en bolívares a la tasa del día: sin una tasa confiable, la conciliación de caja se vuelve una discusión diaria.

Facturación que la ley exige y el Excel no puede dar

IVA del 16 % con la exención de los servicios médicos, IGTF del 3 % cuando el pago llega en divisas, numeración de formas libres con correlativo y número de control, y desglose en bolívares y dólares.

Impacto: Emitir una factura inválida o no declarar el IGTF expone a la clínica a sanciones: no es un problema de diseño, es de cumplimiento.

Restricciones de diseño

  • Equipo unipersonal: cualquier componente que no pueda mantener una sola persona es deuda técnica disfrazada de arquitectura.
  • Sin servidores propios: el presupuesto inicial no admite clústeres, colas ni contenedores que administrar.
  • Tráfico impredecible: una campaña en redes satura el agendamiento en minutos y luego vuelve a la calma.
  • Recepción con internet irregular: el panel debe abrir rápido en laptops viejas y en pantallas pequeñas.
  • El BCV no publica API: su portal bloquea clientes sin User-Agent de navegador, así que la tasa solo puede obtenerse con scraping tolerante a fallos.
  • Datos de salud: aislamiento estricto entre clínicas y mínimo privilegio desde el primer commit, no como mejora posterior.

De los dolores a los requisitos

Los tres problemas se tradujeron en cuatro requisitos no negociables que gobiernan todo el sistema:

  1. R1 Multi-tenant con aislamiento verificable en la base de datos, no solo en el código de la aplicación.
  2. R2 Una tasa oficial confiable, cacheada y con respaldos en cascada.
  3. R3 Un motor fiscal determinista, auditable y explicable ante el SENIAT.
  4. R4 Una interfaz que una recepcionista use sin manual el primer día.

El criterio que apliqué en todo el proyecto

Cada requisito se resolvió en la capa donde el problema es más barato de resolver: el aislamiento entre clínicas vive en Postgres (RLS), la concurrencia vive en el modelo de datos (locks con expiración), y el cumplimiento fiscal vive en funciones puras sin E/S. Ese reparto es, en mi opinión, la diferencia entre un prototipo y un producto que factura.

02La solución arquitectónica

Tres decisiones que sostienen todo el sistema

Cada decisión se tomó contra una alternativa concreta y con su costo asumido por escrito. Estas son las tres que gobiernan el producto, el mapa de capas resultante y las opciones que evalué y descarté.

Decisión 01

Monolito modular en Next.js 16, sin microservicios

Una sola aplicación desplegada en Vercel con App Router: Server Components para leer datos, Server Actions para escribirlos y módulos de dominio puros en lugar de servicios separados.

Por qué

  • Un despliegue atómico: no hay contratos HTTP entre servicios ni coordinación de versiones entre despliegues.
  • Server Components eliminan el viaje cliente → API → servidor: la página llega renderizada y el panel envía menos JavaScript al navegador.
  • Server Actions removieron la capa REST intermedia: una mutación es una función tipada, no un endpoint que hay que versionar y documentar aparte.
  • Los tipos de la base de datos viajan de extremo a extremo: si una columna cambia, el compilador avisa antes del despliegue.
  • Productividad de una sola persona: interfaz, regla de negocio y validación viven en el mismo módulo y se revisan en un mismo diff.

Compromiso asumido

El despliegue queda acoplado: un fallo en cualquier módulo afecta a todo el producto. Lo compenso manteniendo el dominio en módulos puros sin E/S, tolerando esquemas parcialmente migrados y desplegando cambios pequeños y frecuentes en lugar de lotes trimestrales.

Decisión 02

La seguridad en la base de datos, no solo en el código

Postgres con Row Level Security como frontera principal, más funciones SECURITY DEFINER (is_staff, is_tenant_admin, is_super_admin) que resuelven la pertenencia del usuario a una clínica y su rol.

Por qué

  • 64 políticas sobre 16 tablas: aunque la interfaz tuviera un descuido, la consulta no puede cruzar datos entre clínicas.
  • Las políticas se evalúan contra la identidad del token (auth.uid()), no contra parámetros que el cliente pueda enviar o manipular.
  • El navegador nunca recibe la clave service_role: solo el cron del servidor la usa para persistir la tasa diaria y saltar RLS de forma controlada.
  • Storage con el mismo criterio: lectura pública para los logos de marca y escritura restringida al personal autenticado.
  • Fallo seguro por defecto: sin una política que lo permita, el acceso simplemente no ocurre, sin depender de que alguien recuerde escribir el filtro correcto.

Compromiso asumido

Depurar políticas declarativas es más lento que leer un if en TypeScript y algunas consultas exigen funciones auxiliares. A cambio, el aislamiento entre clientes deja de depender de la disciplina de cada desarrollador.

Decisión 03

El motor de tasas como sistema resiliente, no como una llamada HTTP

Una cascada de cuatro niveles —portal del BCV, APIs alternativas, última tasa persistida y valor de respaldo— que nunca lanza una excepción y siempre informa de dónde salió el número.

Por qué

  • El BCV no publica API y su portal bloquea clientes sin User-Agent de navegador: depender de una única fuente era inaceptable para un sistema que factura.
  • Caché HTTP de una hora en las consultas externas (revalidate: 3600) para no abusar de la fuente ni castigar la latencia del panel.
  • Un cron diario captura la tasa y la persiste con service_role, alimentando el nivel de respaldo de la cascada.
  • El administrador puede fijar la tasa manualmente y consultar el historial de capturas cuando el portal publica tarde.

Compromiso asumido

Puedo servir un dato con horas de antigüedad. Por eso cada importe en la interfaz muestra la fuente y la fecha de captura, y el override manual conserva la última palabra para el operador.

src/lib/bcv.ts
export async function getLatestBcvRateDetallada(
  supabase: Client
): Promise<BcvRateInfo> {
  // 1) portal BCV (scraping) → 2) dolarapi → 3) pydolarve
  const enVivo = await obtenerTasaBcvEnVivo()
  if (enVivo) return enVivo

  // 4) última fila guardada en bcv_rates
  const enDb = await leerTasaBcvDb(supabase)
  if (enDb) {
    return { tasa: enDb, fuente: "db", detalle: "bcv_rates (última guardada)" }
  }

  // 5) último recurso: constante de respaldo
  return { tasa: TASA_BCV_FALLBACK, fuente: "fallback", detalle: "respaldo" }
}

La función devuelve tasa + fuente + descripción legible: el nivel del respaldo se conserva hasta la interfaz.

vercel.json
{
  "crons": [
    {
      "path": "/api/cron/bcv-rate",
      "schedule": "0 12 * * *"
    }
  ]
}

El Route Handler valida Authorization: Bearer CRON_SECRET y declara maxDuration = 30.

Cómo se conecta todo

Seis capas con una dirección de dependencia explícita. Cada flecha representa una frontera que puedo revisar, probar o reemplazar de forma independiente.

Mapa de capas

las dependencias fluyen hacia abajo

  1. NIVEL 01

    Clientes

    Tres superficies con necesidades opuestas: el paciente reserva sin crear cuenta, el personal opera la clínica y el operador de la plataforma administra los tenants.

    /[clinicSlug]/reservar/[clinicSlug]/admin/super-admin
  2. NIVEL 02

    Borde y enrutado

    El proxy de Next.js 16 (antes middleware) resuelve la clínica desde el slug, exige sesión y membresía en tenant_users para /[clinicSlug]/admin/*, redirige al login conservando el destino, bloquea /super-admin/* por rol del token y saca del matcher /api/** para que cada Route Handler valide con RLS. La landing y este caso de estudio son rutas estáticas del mismo despliegue.

    proxy.ts (ex-middleware)/:clinicSlug/admin/:path*/super-admin/:path*
  3. NIVEL 03

    Aplicación · Next.js 16 (App Router)

    Server Components leen y renderizan en el servidor; Server Actions mutan el estado sin una capa REST intermedia; los Route Handlers cubren lo que necesita HTTP puro (cron diario, PDF y webhooks).

    RSC20 módulos use serverRoute Handlers
  4. NIVEL 04

    Dominio puro (sin E/S)

    Reglas fiscales, tasas, roles y validaciones en módulos de TypeScript puros: no conocen la base de datos, se importan desde el servidor y desde el navegador, y son fáciles de razonar y revisar.

    fiscal-ve.tsbilling-ve.tsbcv.tsrbac.ts
  5. NIVEL 05

    Datos · Postgres (Supabase)

    16 tablas y 64 políticas RLS: cada consulta se evalúa contra la identidad del usuario y su clínica. Auth y Storage (logos, guías, comprobantes) viven en el mismo proyecto; la clave de servicio nunca llega al navegador.

    Row Level SecurityAuthStorage
  6. NIVEL 06

    Servicios externos

    El portal del BCV y dos APIs de respaldo alimentan la tasa; Vercel Cron ejecuta la captura diaria; los avisos salen por enlaces profundos de WhatsApp para no depender de una API de mensajería.

    bcv.org.vedolarapi.compydolarve.orgVercel Cron

La dirección importa: la aplicación conoce el dominio y la base de datos, el dominio no conoce a la aplicación. Por eso el motor fiscal se puede leer y verificar sin levantar el servidor, y por eso un cambio de alícuota es un cambio de una constante en un archivo.

Alternativas que evalué y descarté

Documentar lo que no se construyó es parte del diseño.

Alternativa consideradaPor qué noQué hice en su lugar
Microservicios por móduloSobrecarga operativa y latencia entre servicios sin un equipo que los opere: el cuello de botella era el producto, no el cómputo.Monolito modular con módulos puros como frontera. Separar un módulo después es una decisión reversible.
Una base de datos (o un esquema) por clínicaMultiplicar conexiones y migraciones para cada tenant cuesta más de operar que aislar con políticas declarativas.Multi-tenant por fila: tenant_id en las tablas + 64 políticas RLS y funciones de pertenencia.
API REST propia con estado global en el clienteCada endpoint duplicaba validación, contratos y manejo de errores, y el cliente acumulaba estado que el servidor ya conocía.Server Actions tipadas que devuelven ActionResult con códigos accionables; el servidor sigue siendo la única fuente de verdad.
Calcular impuestos con un modelo de lenguajeUn impuesto no se estima: se calcula. Hacía falta determinismo, reproducibilidad y poder explicar cada bolívar ante una fiscalización.Funciones puras con las alícuotas como constantes (IVA 16 %, IGTF 3 %) y redondeo explícito a dos decimales.
Cron en un servicio externo (terceros o CI)Otra credencial que rotar, otra cuota que vigilar y otro panel donde buscar el porqué de un fallo.Vercel Cron apuntando a un Route Handler propio, autenticado con CRON_SECRET y con maxDuration declarado.
Guardar la tasa en una variable global del procesoEn un entorno serverless no existe memoria compartida confiable entre invocaciones: el valor se perdería sin avisar.Persistencia en la tabla bcv_rates + caché HTTP de una hora en las consultas a la fuente.
03Retos técnicos complejos

Tres problemas que separan un CRUD de un producto

Un sistema de citas parece un formulario hasta que dos personas reservan el mismo turno en paralelo; una factura parece una multiplicación hasta que tres normas tienen condiciones de aplicación distintas. Estos son los problemas que resolví, con el código y las decisiones que los sostienen.

Reto 01 · Concurrencia

Que dos pacientes no compren el mismo turno

El asistente de reserva es anónimo: cualquiera con el enlace puede tomar un turno sin crear cuenta. Eso significa que dos personas pueden estar pagando la misma hora en paralelo, y que la primera puede abandonar el proceso después de haber apartado el cupo. La solución no podía ser un bloqueo permanente —condenaría los turnos de los pacientes que cambian de opinión— ni un índice único en la base —no sabe medir el tiempo transcurrido—.

  1. 01

    El turno se bloquea, no se vende

    Al elegir el turno, el servidor crea la cita en estado 'pendiente' con lock_expira_en = ahora + 15 minutos. El cupo queda reservado para ese paciente.

  2. 02

    Quince minutos para pagar

    La pantalla corre un temporizador visible. Si el paciente registra el pago (Pago Móvil con comprobante o pago en caja), la cita avanza con la referencia y queda pendiente de validación.

  3. 03

    El cupo se libera solo

    Si el bloqueo vence o el paciente retrocede en el asistente, releaseLockedSlot marca la cita como 'expirada' o 'cancelada' y el turno vuelve a ofrecerse sin que nadie intervenga.

Ciclo de vida del bloqueo

El estado de la cita es el que decide si el turno está ocupado. No hay banderas paralelas que puedan quedar desincronizadas.

  • pendiente (lock vigente)Bloquea el turno

    Un paciente está pagando en este momento la cita que reservó.

  • pendiente (lock vencido)No bloquea: se ofrece de nuevo

    El temporizador terminó y nadie completó el pago.

  • pendiente_validacionBloquea el turno

    El paciente reportó un Pago Móvil con referencia y comprobante.

  • pago_en_recepcionBloquea el turno

    El paciente pagará en caja el día de la consulta.

  • cancelada / expiradaNo bloquea

    Liberada de forma explícita por el paciente o por vencimiento del bloqueo.

src/app/actions/booking.ts
const expiresAtMs = Date.now() + LOCK_DURATION_MINUTES * 60_000 // 15 min
const lockExpiraEn = new Date(expiresAtMs).toISOString()

const payloadBase = {
  tenant_id: doctorRes.data.tenant_id,
  doctor_id: doctorId,
  patient_id: patientId ?? null,
  fecha_hora: combineToISO(date, horaReferencial),
  estado: "pendiente",
  lock_expira_en: lockExpiraEn,
}

La cita nace bloqueada: el estado 'pendiente' con fecha de expiración es el propio mecanismo de reserva.

src/app/actions/booking.ts
// El cupo solo está ocupado si el bloqueo sigue vigente
if (cita.estado === "pendiente" && cita.lock_expira_en) {
  return new Date(cita.lock_expira_en).getTime() > ahora
}

// Liberación: 'expirada' si venció, 'cancelada' si el paciente retrocedió
const estado = expirada ? "expirada" : "cancelada"
await supabase.from("appointments")
  .update({ estado })
  .eq("id", appointmentId)
  .eq("estado", "pendiente")

Disponibilidad y liberación: la segunda operación solo aplica si la cita sigue en 'pendiente' (guarda optimista).

La lección de la migración 0003

La primera versión protegía la doble reserva con un índice único sobre la fecha y el turno. Funcionaba hasta que el bloqueo vencía: el índice no sabe medir tiempo, así que los turnos abandonados quedaban muertos para siempre. Eliminar esa restricción fue una decisión incómoda pero correcta: el control pasó al modelo de datos con un estado y una fecha de expiración que sí expresan la regla de negocio.

Reto 02 · Cumplimiento fiscal

Una factura que tres normas exigen a la vez

La factura venezolana no es un recibo con un porcentaje: es un documento con condiciones de aplicación. El IVA depende de si el servicio es gravado o exento, el IGTF depende del medio de pago y el desglose debe mostrarse en bolívares a la tasa del día y en dólares. Traducir cada norma a código fue el trabajo más delicado del proyecto, porque un error aquí no es un bug estético: es una contingencia fiscal para el cliente.

IVA del 16 % con exención médica

La alícuota general es 16 %, pero los servicios médicos directos están exentos (Art. 17, numeral 4 de la Ley de IVA). En el modelo cada servicio del catálogo declara su condición: alicuotaIva(taxable) devuelve 0.16 o 0. El sistema no infiere la condición clínica del servicio; la clínica la declara una vez en el catálogo.

IGTF del 3 % solo cuando el cobro llega en divisas

El impuesto a las grandes transacciones financieras grava los pagos en moneda extranjera. En lugar de asumir una regla global por país, cada método de cobro declara si lo causa: Zelle y efectivo en dólares lo causan; Pago Móvil, transferencia, efectivo en bolívares y punto de venta no.

Doble despliegue y numeración de formas libres

El catálogo se fija en dólares y la factura se emite a la tasa BCV del cobro: cada línea conserva subtotal, IVA y total en ambas monedas, con redondeo explícito a dos decimales para evitar desvíos de coma flotante. La numeración fiscal combina correlativo y número de control, y el IGTF se recalcula cada vez que se registra un cobro.

Reglas por método de cobro

La condición del impuesto vive junto al método que la causa, no en un condicional escondido en la pantalla de cobro.

MétodoMonedaIGTFReferencia
Pago MóvilVESNo aplicaSí (4 a 8 dígitos)
Transferencia (Bs.)VESNo aplicaSí
Efectivo Bs.VESNo aplicaNo
Punto de ventaVESNo aplicaNo
ZelleUSD3 %Sí
Efectivo USDUSD3 %No
src/lib/fiscal-ve.ts
/** Alícuota general del IVA vigente en Venezuela (16 %). */
export const IVA_VENEZUELA = 0.16

/** RIF canónico: `V-12345678-9`. */
export const RIF_REGEX = /^([VEJG])-(\d{8})-(\d)$/

/** Cédula canónica: `V-12345678`. */
export const CEDULA_REGEX = /^([VE])-(\d{6,9})$/

/** 16 % si la línea está gravada; 0 si es un servicio médico exento. */
export function alicuotaIva(taxable: boolean): number {
  return taxable ? IVA_VENEZUELA : 0
}

Funciones puras: mismos resultados en el servidor, en el navegador y en un script de verificación.

src/lib/billing-ve.ts
/** Alícuota del IGTF vigente en Venezuela (3 %). */
export const ALICUOTA_IGTF = 0.03

/** IGTF de un monto en USD (3 %), redondeado a 2 decimales. */
export function calcularIgtf(montoUSD: number): number {
  if (!Number.isFinite(montoUSD) || montoUSD <= 0) return 0
  return redondear2(montoUSD * ALICUOTA_IGTF)
}

export function desglosarCobro(entrada: {
  metodo: PaymentMethod
  montoUSD?: number
  montoVES?: number
  tasa: number
}): DesgloseCobro {
  const info = infoMetodoCobro(entrada.metodo)

  // La base se expresa siempre en USD (moneda de la factura).
  const baseUSD =
    entrada.montoUSD !== undefined && Number.isFinite(entrada.montoUSD)
      ? redondear2(Math.max(0, entrada.montoUSD))
      : aUsd(entrada.montoVES ?? 0, entrada.tasa)

  const igtf = info.aplicaIgtf ? calcularIgtf(baseUSD) : 0
  const total = redondear2(baseUSD + igtf)

  // …se devuelven además los equivalentes en VES a la tasa del cobro.
  return { metodo: entrada.metodo, aplicaIgtf: info.aplicaIgtf,
    baseUSD, baseVES: aVes(baseUSD, entrada.tasa),
    igtfUSD: igtf, igtfVES: aVes(igtf, entrada.tasa),
    totalUSD: total, totalVES: aVes(total, entrada.tasa) }
}

El IGTF no se pregunta en la pantalla de cobro: se deduce del método registrado.

El mismo módulo valida los documentos fiscales

RIF (V, E, J, G), cédula y pasaporte se normalizan a un formato canónico antes de tocar la base de datos, y todas las conversiones pasan por un redondeo a dos decimales para que el total que ve el paciente, el que registra caja y el que imprime la factura sean exactamente el mismo número.

Reto 03 · Seguridad y permisos

Cinco roles, veintiún permisos y varias sedes

En una clínica conviven un administrador, dos recepcionistas, cinco médicos, un contador externo y un operador de la plataforma. Todos usan el mismo panel y ninguno debe ver ni poder cambiar lo mismo. Ese reparto no puede vivir en el componente de turno: se aplica en tres capas, con una única fuente de verdad escrita una sola vez.

Capa de datos · RLS

Las 64 políticas de Postgres verifican pertenencia y rol mediante funciones SECURITY DEFINER (is_staff, is_tenant_admin, is_super_admin). Incluso con un error en el cliente, una consulta no puede leer ni escribir datos de otra clínica.

Capa de servidor · Server Actions

Antes de mutar, cada acción revalida el permiso con el mismo módulo puro que consume la interfaz. El panel pide sus datos por HTTP con la sesión en cookies: el navegador nunca maneja credenciales de servicio.

Capa de interfaz · UI

La navegación se calcula con los permisos efectivos del rol: quien no puede guardar una configuración no ve el formulario, en lugar de descubrir el rechazo al enviarlo. La misma función decide a qué panel entra cada persona al iniciar sesión.

Multi-sede: cada persona se asigna a una o varias sedes (tenant_users.sede_ids) y la facturación usa la sede por defecto de la clínica. La lectura de esas asignaciones tolera entornos donde la migración todavía no se aplicó, así que un despliegue parcial no rompe el acceso.

Matriz real de permisos por rol

Esta tabla no está escrita a mano: se calcula con permisosDeRol(), la misma función que usan los Server Actions y que define los helpers de las políticas RLS. Si el RBAC cambia, esta matriz cambia sola.

PermisoAdministrador21 permisosRecepción10 permisosEspecialista3 permisosMédico3 permisosContador13 permisos
configuracion:leer··
configuracion:escribir····
fiscal:leer···
fiscal:escribir···
tasa:leer··
tasa:escribir···
servicios:leer
servicios:escribir····
sedes:leer··
sedes:escribir····
usuarios:escribir····
cobros:recaudar···
cobros:registrar···
facturacion:leer
facturacion:emitir···
facturacion:anular···
contabilidad:leer··
contabilidad:escribir··
honorarios:leer···
honorarios:escribir···
consulta:atender··

Los roles de la dimensión clínica (recepcion, especialista, medico, contador) se normalizan a uno de los cinco roles soportados, de modo que un dato heredado no pueda conceder permisos por accidente.

04Aprendizajes y escalabilidad

Lo que aprendí y por qué este sistema aguanta crecer

Construir un producto regulado con una sola persona obliga a elegir dónde vive cada responsabilidad. Estas son las conclusiones que aplicaría al siguiente proyecto, sin maquillar lo que aún queda pendiente.

El determinismo es una característica de producto

El camino crítico —cotizar, cobrar, facturar— no consulta ningún modelo de lenguaje: usa constantes, funciones puras y redondeo explícito. Un impuesto no se estima, se calcula. Eso hace el resultado reproducible, explicable ante una fiscalización y trivial de ejecutar. La automatización vive en los bordes (capturar la tasa, disparar el cron, avisar al paciente), donde un fallo se degrada sin corromper un número.

La frontera correcta ahorra más código que cualquier abstracción

Poner el aislamiento entre clínicas en RLS, y no en cada consulta, eliminó una clase completa de errores: ya no hay filtro que olvidar. La regla que me llevo: si el olvido es posible, el diseño está mal.

Tolerancia a esquemas parcialmente migrados

El código detecta la ausencia de columnas y reintenta con un payload reducido, así que la aplicación funciona aunque una migración aún no se haya aplicado en el entorno de destino. Permite desplegar aplicación y esquema a ritmos distintos sin congelar el producto.

Un módulo puro es documentación ejecutable

Los archivos de dominio (fiscal-ve, billing-ve, bcv, rbac) no importan Supabase ni React. Eso permite leer el cumplimiento fiscal de un país en un archivo, revisar el impacto de un cambio de alícuota en un diff pequeño y razonar el cálculo sin levantar el servidor.

Lo que todavía no está

Digo yo lo que falta antes que un revisor: el aforo por turno está declarado como pendiente en el código (hoy el control es por bloqueo de 15 minutos, no por cupo máximo), los avisos usan enlaces de WhatsApp en lugar de la API oficial de mensajería, y falta una batería de pruebas end-to-end del flujo completo de facturación.

Cómo escala este diseño

  • Cada clínica es una fila, no una base de datos: incorporar un tenant nuevo no agrega operación ni migraciones replicadas.
  • El aislamiento escala con el motor de Postgres, no con la cantidad de condiciones escritas en la aplicación.
  • Los planes comerciales limitan especialistas y uso desde una única fuente, así que empaquetar o vender distinto no toca la lógica de agenda.
  • La normativa vive solo en los módulos de país: soportar otro país es escribir un módulo hermano, no mantener un fork del producto.
  • Siguiente tramo técnico: cola de trabajos con reintentos para el cron, particionado de la tabla de auditoría, registro de la fuente de tasa usada en cada cobro y pruebas de contrato sobre los cálculos fiscales.

Si volviera a empezar mañana

Mantendría el monolito modular y la seguridad en la base de datos, adelantaría las pruebas de los cálculos fiscales al primer sprint —no son código aburrido, son el corazón del producto— y diseñaría el aforo por turno desde el modelo de datos, en lugar de dejarlo como una tarea pendiente bien documentada. El resto del recorrido lo volvería a hacer igual: pocas piezas, cada una en la capa donde el problema es más barato de resolver.

05Contacto

¿Construimos el próximo producto con este nivel de criterio?

Estoy disponible para roles full-stack o de backend en equipos de producto, con especial interés en dominios donde la normativa local, los pagos y los datos sensibles son el verdadero problema de ingeniería. El código de Medisys está abierto: revísalo y escríbeme con la pregunta que quieras.

Respondo personalmente cada mensajeDocumentación y código en español e inglés
  • Arquitectura sin sobre-ingeniería

    Capacidad de elegir el diseño más simple que resuelve el problema —y de documentar qué se descartó y por qué— con un monolito modular tipado y desplegado en un solo lugar.

  • Seguridad y datos

    Aislamiento multi-tenant con Row Level Security, RBAC de tres capas y multi-sede: el acceso se decide en el motor de la base de datos, no por convención ni por disciplina.

  • Dominios regulados

    Traducción de normativa fiscal a funciones puras y auditables (IVA, IGTF, doble moneda, numeración) y de la ausencia de pasarelas locales a flujos de cobro reales.

  • Rendimiento y operación

    Server Components para servir menos JavaScript, caché en las fuentes externas, cron con credencial propia y un motor de tasas que degrada con elegancia en lugar de fallar.

¿Prefieres empezar por lo difícil?

La sección de retos técnicos explica el bloqueo de 15 minutos, el cálculo condicional del IGTF y la matriz de permisos. Si vas a evaluar el código, ese es el mejor lugar para empezar a leerlo críticamente.

Ir a los retos técnicos