Reglas de negocio
Referencia del comportamiento implementado. Debe contrastarse con las rutas y pruebas del módulo: una descripción histórica no sustituye la verificación del código vigente. Revisión de cobros, ventas e inventario: 20 de septiembre de 2026.
Membresías y duraciones
Las membresías se definen por duración en días, precio, color y si son
destacadas (prisma/schema.prisma, modelo Membership). El catálogo es
configurable: un plan llamado «Mensual» de 30 días suma 30 días, no un mes
calendario. Los nombres y precios de la demo no son reglas del gimnasio.
:::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 servidor calcula el importe desde el catálogo y la configuración del gimnasio
(server/compartido/pricing.ts): sin contribución de IVA cobra el precio;
si esContribuyenteIva=true agrega 13%, salvo que ivaIncluded=true, cuando el
precio ya lo incluye. No depende de una casilla IVA por plan. Se redondea a
centavos y se ignoran importes de alta/renovación enviados por el cliente.
Esta es la regla del software, no una certificación fiscal.
Cálculo de vencimiento
Implementado en server/compartido/dates.ts y aplicado en altas y renovaciones
(server/miembros/rutas-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/compartido/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, startISO solicitado, hoy)a medianoche local; si no se solicita inicio se usa hoy. Así no pierde días vigentes ni renueva periodos pasados. Luego se aplica la regla de domingo. La lectura del vencimiento y su actualización comparten transacción: intenciones distintas acumulan días. El mismorequestIdrecupera el cobro original y su recibo, sin renovar otra vez.
Payment.datey la fecha de su movimiento son fechas contables: el alta usa el inicio local y la renovación usa hoy a medianoche.createdAtconserva el instante de creación y el recibo lo usa comopaidAt. La confirmación posterior registra su instante enconfirmedAty actualizadate. No se debe deducir el orden de cobros del mismo día únicamente dedate.
Cada renovación guarda prevStartDate sólo si el inicio vigente era confiable.
Al anular, el vencimiento se restaura únicamente cuando el pago todavía era
dueño del periodo actual; el inicio se restaura desde ese snapshot sólo si no
hay un predecesor anulado que rompa la relación. En los demás casos se conserva
el valor interno y Member.startDateUncertain=true, que la interfaz presenta
como «Inicio no disponible».
Métodos de pago y confirmación
Métodos válidos: Efectivo, Tarjeta, Transferencia
(server/miembros/rutas-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. Existe un trigger experimental de WhatsApp, pero permanece desactivado para operación real.
Ventas y movimientos manuales
Las ventas nuevas guardan una identidad Sale y sus líneas Transaction, cada
una con cantidad e importe histórico. requestId identifica una intención:
repetir el mismo intento recupera el resultado original; cambiar la intención
con ese identificador devuelve conflicto. El precio se decide en el servidor
desde el catálogo vigente y el IVA del gimnasio; el total suma centavos enteros.
Tarjeta/transferencia no descuentan inventario hasta confirmarse. Confirmar una línea de una venta nueva confirma toda la compra con una sola referencia, fecha y operación atómica de stock. Si algún producto no está disponible, no se confirma parcialmente. Los movimientos históricos no se agrupan por similitud.
La anulación individual se conserva para movimientos aislados. En una Sale, el
gerente puede anular la compra completa (compensación por línea y devolución de
stock) o registrar una devolución parcial con cantidades, motivo y método. Una
devolución a Crédito a favor exige customerId, crea saldo local y no mueve
dinero en un banco. El original nunca se borra y los movimientos de inventario
son inmutables.
Los movimientos manuales también admiten identidad de intención y validan
existencia/tipo de categoría. Los clientes antiguos que no envían requestId
conservan compatibilidad, pero no tienen la garantía durable de reintento de las
nuevas entradas UI.
Cada reposición o corrección de conteo exige una nota. El servidor comprueba el stock resultante dentro de la transacción y rechaza cualquier operación que lo deje negativo.
Los formularios congelan los datos mientras guardan y conservan el intento tras una respuesta incierta. Cerrar el formulario no promete conservar su borrador: se advierte revisar Dinero antes de iniciar otro cobro.
Estados
Conviene distinguir dos cosas que el brief agrupaba como "estados":
Estado del miembro (derivado de endDate)
Calculado en presentación (src/features/miembros/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
Un pago puede estar confirmed, pending o voided. 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 | Reportes, finanzas, planes, usuarios, confirmar pagos, anular, archivar, bitácora, backup y pruebas de integraciones. |
recepcion | Recepción (empleado) | Registrar miembros, cobrar, renovar, editar datos básicos y abrir enlaces wa.me manuales. |
:::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 experimental
-
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/miembros/rutas-members.ts). -
Enlaces
wa.me/: para mensajes manuales, el frontend generahttps://wa.me/<solo-dígitos>?text=<mensaje>(src/features/miembros/memberStatus.ts,waLink). -
Motor experimental de Twilio: el código contiene disparadores por evento (
server/whatsapp/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 locales admiten variables:
{nombre},{apellido},{plan},{fecha_vencimiento},{dias_restantes},{dias_vencido},{gimnasio}. El backend envía body libre; todavía no usaContentSidniContentVariablespara plantillas aprobadas. -
Planes de 1 día (walk-in): el motor experimental omite a los miembros con plan de duración ≤ 1 día (
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. -
Estado operativo:
twilioConnected=false; el rediseño no expone el disparo manual del cron. Para producción faltan sender registrado, plantillas aprobadas, callback HTTPS público y E2E real. -
Consentimiento: el modelo actual no conserva opt-in/opt-out verificable del miembro. Por eso, aunque las credenciales sean válidas, el envío automático sigue bloqueado para producción.
Facturas (DTE)
POST /api/dte/emitconstruye y valida localmente documentos01/03conmodo="SIMULACION";estadoMhesnull.- No existe firmador, transmisor, consulta ni acuse real del MH. Los sellos que
empiezan con
SIMULADOno tienen validez externa. POST /api/dte/:id/invalidacionregistra un evento local separado. Sólo soporta rescisión (tipoAnulacion=2) y nunca transmite al Ministerio.- La ruta legado
/api/dte/:id/nota-creditoresponde501y no muta datos. - Un DTE o evento invalidado queda visible para trazabilidad; el reporte fiscal local lo presenta como anulado y lo excluye de los totales fiscales.
- Una confirmación conjunta de productos no genera una factura fiscal conjunta.
La compra local sí queda agrupada en una
Saley su comprobante local reúne todos los artículos; el builder fiscal sigue partiendo de una transacción. La factura de toda la compra y las validaciones productivas de receptor quedan para la integración con Hacienda, por decisión expresa del usuario el 14 de septiembre de 2026.
Reportes y "PDF"
Reportes ejecutivos por rango de fechas (server/reportes/rutas-reports.ts): ingresos,
gastos, utilidad, serie temporal y desgloses por plan, categoría de gasto y
método de pago. Sólo cuenta transacciones confirmadas.
- La opción
excludeShortPlansexcluye planes de duración ≤ 7 días (walk-in) para el análisis ejecutivo.
:::note El PDF se genera en el cliente
El frontend usa jsPDF (src/features/reportes/reportPdf.ts) y ofrece una
vista previa antes de guardar. No hay un servicio de PDF en el servidor.
También existe exportación tabular.
:::
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.