Saltar al contenido principal

Reglas de negocio

Este documento es la fuente única de verdad del dominio. Cada regla se verificó contra el código; donde la implementación difiere del brief original, se documenta lo que hace el código y se marca la discrepancia.

Membresías y duraciones

Las membresías se definen por duración en días, precio, IVA, color y si son destacadas (prisma/schema.prisma, modelo Membership). Los planes que trae la instalación sembrada (prisma/seed.ts) son:

PlanDuraciónPrecioIVA
Diaria1 día$3No
Semanal7 días$10No
Quincenal15 días$15Sí (13%)
Mensual30 días$25Sí (13%)

:::note Discrepancia: las duraciones no son constantes fijas El brief listaba DIARIA=1d, SEMANAL=7d, QUINCENAL=15d, MENSUAL=30d como reglas fijas. En el código esos valores son datos sembrados por defecto, no constantes. El gerente puede crear, editar o desactivar planes con cualquier duración (POST/PATCH /api/memberships). La duración de cada plan es el campo Membership.duration. :::

El IVA se aplica como factor × 1.13 sobre el precio cuando iva = true, al calcular el monto del primer pago si no se indica un monto explícito (server/routes/members.ts).

Cálculo de vencimiento

Implementado en server/lib/dates.ts y aplicado en altas y renovaciones (server/routes/members.ts).

  1. Medianoche. startDate se normaliza al inicio del día local (startOfLocalDay). endDate = startDate + duración × 24h, también a medianoche. Una membresía comprada a cualquier hora vence a las 00:00 del día siguiente al último día usable. (endDate es exclusivo.)

  2. Domingo cerrado → se mueve a lunes. El gimnasio no abre los domingos. Si el último día usable (endDate − 1 día) cae en domingo, endDate se empuja +1 día para que el cliente recupere ese día (closedSundayShift).

    // server/lib/dates.ts
    export function closedSundayShift(endDate: Date): Date {
    const lastUsable = new Date(endDate);
    lastUsable.setDate(lastUsable.getDate() - 1);
    if (lastUsable.getDay() !== 0) return endDate; // 0 = domingo
    const shifted = new Date(endDate);
    shifted.setDate(shifted.getDate() + 1);
    return shifted;
    }
  3. Renovación. La nueva fecha de inicio es max(endDate actual, hoy) para no perder días vigentes, normalizada a medianoche; luego se vuelve a aplicar la regla de domingo.

Las fechas de Payment y Transaction no se normalizan a medianoche: se guardan al instante exacto para auditoría.

Métodos de pago y confirmación

Métodos válidos: Efectivo, Tarjeta, Transferencia (server/routes/members.ts).

  • Efectivo → confirmado de inmediato. El pago y su transacción nacen con status = "confirmed".
  • Tarjeta / Transferencia → pendiente. Nacen con status = "pending" y esperan que el gerente registre el ID externo del comprobante/voucher para confirmarse (POST /api/payments/:id/confirm). Al confirmar, se sincroniza la transacción de membresía asociada y se dispara el mensaje de WhatsApp correspondiente.

Estados

Conviene distinguir dos cosas que el brief agrupaba como "estados":

Estado del miembro (derivado de endDate)

Calculado en presentación (src/lib/memberStatus.ts), no almacenado:

Etiqueta en la appCondición
Activafaltan más de 3 días para vencer
Por vencervence hoy o dentro de 3 días
Vencidala fecha de vencimiento ya pasó

Además, un miembro puede estar Archivado (Member.archived = true, baja lógica que preserva el historial) y puede tener un pago pendiente (hasPendingPayment, si tiene algún pago en pending).

:::note Discrepancia de nomenclatura El brief listaba los estados como ACTIVO, EXPIRADO, PENDIENTE, ARCHIVADO. En el código las etiquetas reales del miembro son Activa / Por vencer / Vencida; PENDIENTE corresponde al estado de un pago (pending), no del miembro, y ARCHIVADO corresponde al campo archived. Se documenta la nomenclatura real. :::

Estado del pago / transacción

confirmed o pending (ver sección anterior). Las transacciones anuladas se marcan con voidedAt/voidedBy/voidReason y no se borran; se crea una transacción gemela compensatoria (voidOfId). Solo el gerente puede anular.

Roles y permisos

Dos roles (User.role):

Rol en el códigoRol en el manual de usuarioPuede
gerenteGerente / dueñoTodo: reportes, finanzas, planes, usuarios, confirmar pagos, anular, archivar, bitácora, backup, config WhatsApp
recepcionRecepción (empleado)Registrar miembros, cobrar, renovar, editar datos básicos, enviar WhatsApp manual

:::note Discrepancia de nomenclatura El brief hablaba de "EMPLEADO"; el rol real en el código es recepcion. En el manual de usuario se le llama "recepción". :::

Acciones restringidas al gerente (requireGerente): archivar miembros, crear/ editar/eliminar planes, gestionar usuarios, ver la bitácora, respaldar y restaurar.

Teléfonos y WhatsApp

  • Formato: prefijo de país por defecto +503 (El Salvador), configurable en GymInfo.defaultCountryCode. La validación exige al menos 8 dígitos en el número (server/routes/members.ts).

  • Enlaces wa.me/: para mensajes manuales, el frontend genera https://wa.me/<solo-dígitos>?text=<mensaje> (src/lib/memberStatus.ts, waLink).

  • Notificaciones automáticas (Twilio): disparadas por evento (server/lib/waTriggers.ts):

    Evento (trigger)Cuándo
    on_signupAlta confirmada (bienvenida)
    on_paymentRenovación confirmada (recibo)
    before_expiryN días antes de vencer (triggerDays)
    on_expiryEl día del vencimiento
    after_expiryN días después de vencer

    Las plantillas admiten variables: {nombre}, {apellido}, {plan}, {fecha_vencimiento}, {dias_restantes}, {dias_vencido}, {gimnasio}.

  • Planes de 1 día (walk-in): los miembros con plan de duración ≤ 1 día no reciben mensajes automáticos (waTriggers.ts).

  • Modo trial de Twilio: si WAConfig.twilioTestPhone está definido, todos los envíos van a ese número de pruebas en lugar del teléfono real.

Reportes y "PDF"

Reportes ejecutivos por rango de fechas (server/routes/reports.ts): ingresos, gastos, utilidad, serie temporal, crecimiento de miembros y desgloses por plan, categoría de gasto y método de pago. Solo cuenta transacciones confirmadas.

  • La opción excludeShortPlans excluye planes de duración ≤ 7 días (walk-in) para el análisis ejecutivo.

:::note Discrepancia: el "PDF" es impresión del navegador No hay librería de generación de PDF en el servidor. El botón "PDF" abre una ventana imprimible (HTML) y usa window.print() del navegador para Imprimir / Guardar como PDF (src/views/Reports.tsx). También existe exportación a Excel. :::

Almacenamiento y respaldo

  • Una sola base SQLite local (un tenant). Singletons forzados en código: GymInfo, WAConfig, AppSettings.
  • Respaldo por snapshots (VACUUM INTO + rename atómico) a la carpeta de Nextcloud; disparo debounced tras cada mutación exitosa, al arrancar y al cerrar. Restauración staged que se aplica al reiniciar. Ver Despliegue.