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:
| Plan | Duración | Precio | IVA |
|---|---|---|---|
| Diaria | 1 día | $3 | No |
| Semanal | 7 días | $10 | No |
| Quincenal | 15 días | $15 | Sí (13%) |
| Mensual | 30 días | $25 | Sí (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).
-
Medianoche.
startDatese 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. (endDatees exclusivo.) -
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,endDatese empuja +1 día para que el cliente recupere ese día (closedSundayShift).// server/lib/dates.tsexport function closedSundayShift(endDate: Date): Date {const lastUsable = new Date(endDate);lastUsable.setDate(lastUsable.getDate() - 1);if (lastUsable.getDay() !== 0) return endDate; // 0 = domingoconst shifted = new Date(endDate);shifted.setDate(shifted.getDate() + 1);return shifted;} -
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
PaymentyTransactionno 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 app | Condición |
|---|---|
| Activa | faltan más de 3 días para vencer |
| Por vencer | vence hoy o dentro de 3 días |
| Vencida | la 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ódigo | Rol en el manual de usuario | Puede |
|---|---|---|
gerente | Gerente / dueño | Todo: reportes, finanzas, planes, usuarios, confirmar pagos, anular, archivar, bitácora, backup, config WhatsApp |
recepcion | Recepció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 enGymInfo.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 generahttps://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.twilioTestPhoneestá 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
excludeShortPlansexcluye 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.