Saltar al contenido principal

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).

  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/compartido/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, 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 mismo requestId recupera el cobro original y su recibo, sin renovar otra vez.

Payment.date y la fecha de su movimiento son fechas contables: el alta usa el inicio local y la renovación usa hoy a medianoche. createdAt conserva el instante de creación y el recibo lo usa como paidAt. La confirmación posterior registra su instante en confirmedAt y actualiza date. No se debe deducir el orden de cobros del mismo día únicamente de date.

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 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​

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ódigoRol en el manual de usuarioPuede
gerenteGerente / dueñoReportes, finanzas, planes, usuarios, confirmar pagos, anular, archivar, bitácora, backup y pruebas de integraciones.
recepcionRecepció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 en GymInfo.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 genera https://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 usa ContentSid ni ContentVariables para 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.twilioTestPhone está 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/emit construye y valida localmente documentos 01/03 con modo="SIMULACION"; estadoMh es null.
  • No existe firmador, transmisor, consulta ni acuse real del MH. Los sellos que empiezan con SIMULADO no tienen validez externa.
  • POST /api/dte/:id/invalidacion registra un evento local separado. Sólo soporta rescisión (tipoAnulacion=2) y nunca transmite al Ministerio.
  • La ruta legado /api/dte/:id/nota-credito responde 501 y 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 Sale y 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 excludeShortPlans excluye 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.