bloque 1: anticipo al reservar en linea y cobro automatico de membresias con enlace de pago (modulo skeen_cobros: enlaces con token firmado, webhooks de Stripe/MercadoPago, modo simulado para pruebas, pagina publica de pago, expiracion automatica y reembolso, reintentos de cobro, permisos y reporte); apagado por defecto

This commit is contained in:
2026-10-11 23:54:47 +00:00
parent 5fc0d2a780
commit 6a6c2a928b
35 changed files with 2841 additions and 32 deletions

View File

@@ -184,12 +184,29 @@ RESERVAS_ONLINE_DEFAULT = {
'cancelar_hasta_horas': 24,
'mensaje_confirmacion': '',
'telefono_contacto': '',
'anticipo': {
'activo': False,
'tipo': 'porcentaje',
'valor': 0.0,
'moneda': 'MXN',
'horas_para_pagar': 24,
'reembolsable': True,
'texto_aviso': '',
# Extensión propia: sin credenciales de pasarela el enlace nace
# `simulado`; confirmarlo solo se permite si esta llave está encendida
# (nunca se simula un pago en producción con el default apagado).
'permitir_simulado': False,
},
}
# Rangos razonables de validación del PUT (días / horas).
RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX = 1, 365
RESERVAS_MIN_ANTICIPACION_MAX = 168 # 1 semana
RESERVAS_CANCELAR_HASTA_MAX = 168 # 1 semana
ANTICIPO_HORAS_MAX = 720 # 30 días para pagar el anticipo
ANTICIPO_TIPOS = ('porcentaje', 'monto_fijo')
ANTICIPO_MONEDAS = ('MXN', 'USD')
ANTICIPO_TEXTO_MAX = 500
def _int(valor, por_defecto=0):
@@ -199,9 +216,77 @@ def _int(valor, por_defecto=0):
return por_defecto
def normalizar_anticipo(raw):
"""Sub-bloque `anticipo` de reservas en línea, con defaults. Nunca lanza."""
out = dict(RESERVAS_ONLINE_DEFAULT['anticipo'])
if not isinstance(raw, dict):
return out
out['activo'] = _bool(raw.get('activo'), out['activo'])
out['reembolsable'] = _bool(raw.get('reembolsable'), out['reembolsable'])
out['permitir_simulado'] = _bool(raw.get('permitir_simulado'), out['permitir_simulado'])
tipo = str(raw.get('tipo') or '').strip()
out['tipo'] = tipo if tipo in ANTICIPO_TIPOS else out['tipo']
moneda = str(raw.get('moneda') or '').strip().upper()
out['moneda'] = moneda if moneda in ANTICIPO_MONEDAS else out['moneda']
out['valor'] = max(0.0, _num(raw.get('valor'), out['valor']))
out['horas_para_pagar'] = min(max(_int(raw.get('horas_para_pagar'),
out['horas_para_pagar']), 1), ANTICIPO_HORAS_MAX)
out['texto_aviso'] = str(raw.get('texto_aviso') or '')[:ANTICIPO_TEXTO_MAX]
return out
def validar_anticipo(raw):
"""Valida el sub-bloque `anticipo` de un PUT. Devuelve solo las claves
presentes y normalizadas (merge anidado)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de anticipo debe ser un objeto')
out = {}
for k in ('activo', 'reembolsable', 'permitir_simulado'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s del anticipo debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'tipo' in raw:
tipo = str(raw['tipo'] or '').strip()
if tipo not in ANTICIPO_TIPOS:
raise ValueError("El tipo de anticipo debe ser 'porcentaje' o 'monto_fijo'")
out['tipo'] = tipo
if 'moneda' in raw:
moneda = str(raw['moneda'] or '').strip().upper()
if moneda not in ANTICIPO_MONEDAS:
raise ValueError('La moneda del anticipo debe ser MXN o USD')
out['moneda'] = moneda
if 'valor' in raw:
n = _num(raw['valor'], None)
if n is None:
raise ValueError('El valor del anticipo debe ser un número')
if n < 0:
raise ValueError('El valor del anticipo no puede ser negativo')
if (out.get('tipo') or raw.get('tipo') or 'porcentaje') == 'porcentaje' and n > 100:
raise ValueError('El porcentaje del anticipo debe estar entre 0 y 100')
out['valor'] = n
if 'horas_para_pagar' in raw:
if isinstance(raw['horas_para_pagar'], bool) or not isinstance(raw['horas_para_pagar'], int):
raise ValueError('Las horas para pagar deben ser un número entero')
if not (1 <= raw['horas_para_pagar'] <= ANTICIPO_HORAS_MAX):
raise ValueError('Las horas para pagar deben estar entre 1 y %d' % ANTICIPO_HORAS_MAX)
out['horas_para_pagar'] = raw['horas_para_pagar']
if 'texto_aviso' in raw:
if not isinstance(raw['texto_aviso'], str):
raise ValueError('El texto de aviso debe ser texto')
if len(raw['texto_aviso']) > ANTICIPO_TEXTO_MAX:
raise ValueError('El texto de aviso es demasiado largo (máx. %d)' % ANTICIPO_TEXTO_MAX)
out['texto_aviso'] = raw['texto_aviso'].strip()
return out
def normalizar_reservas_online(raw):
"""Bloque `reservas_online` completo, con defaults. Tolerante: nunca lanza."""
out = dict(RESERVAS_ONLINE_DEFAULT)
out['anticipo'] = normalizar_anticipo(
raw.get('anticipo') if isinstance(raw, dict) else None)
if not isinstance(raw, dict):
return out
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
@@ -261,6 +346,10 @@ def validar_reservas_online(raw):
if not (lo <= raw[k] <= hi):
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
out[k] = raw[k]
if 'anticipo' in raw:
sub = validar_anticipo(raw['anticipo'])
if sub is not None:
out['anticipo'] = sub
return out
@@ -268,6 +357,11 @@ def get_reservas_online(env):
"""Bloque de reservas en línea vigente (copia). Fuente única para la API."""
return normalizar_reservas_online(get_settings(env).get('reservas_online'))
def get_anticipo(env):
"""Sub-bloque `anticipo` vigente (copia). Apagado por defecto."""
return get_reservas_online(env)['anticipo']
# ---------------------------------------------------------------------------
# Recordatorios y notificaciones automáticas de citas.
#
@@ -328,6 +422,11 @@ NOTIFICACIONES_DEFAULT = {
'Hola {nombre}, tu plan {plan} se renueva el {fecha} ({periodo}) por '
'{monto}. Tienes {sesiones} sesiones este periodo. {sucursal}.'
),
# Aviso con el ENLACE DE PAGO del periodo (cobro automático de membresías).
'plantilla_cobro_membresia': (
'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. '
'Paga {monto} desde este enlace: {enlace}'
),
'whatsapp_numero': '',
'email_remitente': '',
}
@@ -339,7 +438,8 @@ NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
'plan', 'membresia', 'monto', 'sesiones', 'periodo')
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
'plantilla_cumpleanos', 'plantilla_post_visita',
'plantilla_formulario', 'plantilla_renovacion_membresia')
'plantilla_formulario', 'plantilla_renovacion_membresia',
'plantilla_cobro_membresia')
NOTIF_HORAS_MAX = 168 # 1 semana
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
@@ -516,6 +616,8 @@ def get_notificaciones(env):
# permitir_pausa_pago: permite pausar la membresía por falta de pago
# generar_cargo_al_renovar: el cron genera el adeudo del periodo
# dias_gracia: días tras el vencimiento antes de marcarla vencida
# cobro_automatico: {activo, dias_antes, metodo, reintentos_dias,
# cancelar_si_no_paga, dias_gracia, permitir_simulado}
# ---------------------------------------------------------------------------
MEMBRESIAS_DEFAULT = {
'activo': False,
@@ -527,16 +629,113 @@ MEMBRESIAS_DEFAULT = {
'permitir_pausa_pago': False,
'generar_cargo_al_renovar': True,
'dias_gracia': 5,
'cobro_automatico': {
'activo': False,
'dias_antes': 3,
'metodo': 'enlace',
'reintentos_dias': [1, 3, 7],
'cancelar_si_no_paga': False,
'dias_gracia': 5,
# Extensión propia: permite confirmar enlaces en modo simulado (sin
# pasarela). Apagado por defecto: nunca se cobra de mentiras.
'permitir_simulado': False,
},
}
MEMBRESIAS_DIAS_MAX = 365 # tope del límite de congelamiento por año
MEMBRESIAS_AVISO_MAX = 365 # tope de días de aviso de renovación
COBRO_METODOS = ('enlace', 'tarjeta_guardada')
COBRO_DIAS_ANTES_MAX = 365
COBRO_REINTENTO_MAX = 365
def _dias_lista(valor, por_defecto=None):
"""Lista de días (enteros 0-365, sin repetir, ordenada). Tolerante."""
default = list(por_defecto or MEMBRESIAS_DEFAULT['cobro_automatico']['reintentos_dias'])
if not isinstance(valor, (list, tuple)):
return default
limpios = []
for v in valor:
if isinstance(v, bool):
continue
try:
n = int(v)
except (TypeError, ValueError):
continue
if 0 <= n <= COBRO_REINTENTO_MAX and n not in limpios:
limpios.append(n)
return sorted(limpios) if limpios else default
def normalizar_cobro_automatico(raw):
"""Sub-bloque `cobro_automatico` de membresías, con defaults. Nunca lanza."""
out = dict(MEMBRESIAS_DEFAULT['cobro_automatico'])
out['reintentos_dias'] = list(MEMBRESIAS_DEFAULT['cobro_automatico']['reintentos_dias'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'cancelar_si_no_paga', 'permitir_simulado'):
out[k] = _bool(raw.get(k), out[k])
metodo = str(raw.get('metodo') or '').strip()
out['metodo'] = metodo if metodo in COBRO_METODOS else out['metodo']
out['dias_antes'] = min(max(_int(raw.get('dias_antes'), out['dias_antes']),
0), COBRO_DIAS_ANTES_MAX)
out['dias_gracia'] = min(max(_int(raw.get('dias_gracia'), out['dias_gracia']), 0), 365)
out['reintentos_dias'] = _dias_lista(raw.get('reintentos_dias'),
out['reintentos_dias'])
return out
def validar_cobro_automatico(raw):
"""Valida el sub-bloque `cobro_automatico` de un PUT (merge anidado)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de cobro automático debe ser un objeto')
out = {}
for k in ('activo', 'cancelar_si_no_paga', 'permitir_simulado'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s del cobro automático debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'metodo' in raw:
metodo = str(raw['metodo'] or '').strip()
if metodo not in COBRO_METODOS:
raise ValueError("El método debe ser 'enlace' o 'tarjeta_guardada'")
out['metodo'] = metodo
if 'dias_antes' in raw:
if isinstance(raw['dias_antes'], bool) or not isinstance(raw['dias_antes'], int):
raise ValueError('Los días antes del cobro deben ser un número entero')
if not (0 <= raw['dias_antes'] <= COBRO_DIAS_ANTES_MAX):
raise ValueError('Los días antes deben estar entre 0 y %d' % COBRO_DIAS_ANTES_MAX)
out['dias_antes'] = raw['dias_antes']
if 'dias_gracia' in raw:
if isinstance(raw['dias_gracia'], bool) or not isinstance(raw['dias_gracia'], int):
raise ValueError('Los días de gracia del cobro deben ser un número entero')
if not (0 <= raw['dias_gracia'] <= 365):
raise ValueError('Los días de gracia deben estar entre 0 y 365')
out['dias_gracia'] = raw['dias_gracia']
if 'reintentos_dias' in raw:
valor = raw['reintentos_dias']
if not isinstance(valor, list):
raise ValueError('Los días de reintento deben ser una lista')
dias = []
for v in valor:
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Cada día de reintento debe ser un número entero')
if not (0 <= v <= COBRO_REINTENTO_MAX):
raise ValueError('Los días de reintento deben estar entre 0 y %d' % COBRO_REINTENTO_MAX)
if v not in dias:
dias.append(v)
out['reintentos_dias'] = sorted(dias)
return out
def normalizar_membresias(raw):
"""Bloque `membresias` completo, con defaults. Tolerante: nunca lanza."""
out = dict(MEMBRESIAS_DEFAULT)
out['dias_aviso_renovacion'] = list(MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
out['cobro_automatico'] = normalizar_cobro_automatico(
raw.get('cobro_automatico') if isinstance(raw, dict) else None)
if not isinstance(raw, dict):
return out
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
@@ -604,6 +803,10 @@ def validar_membresias(raw):
if not (0 <= raw['dias_gracia'] <= 365):
raise ValueError('Los días de gracia deben estar entre 0 y 365')
out['dias_gracia'] = raw['dias_gracia']
if 'cobro_automatico' in raw:
sub = validar_cobro_automatico(raw['cobro_automatico'])
if sub is not None:
out['cobro_automatico'] = sub
return out
@@ -611,6 +814,11 @@ def get_membresias(env):
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
return normalizar_membresias(get_settings(env).get('membresias'))
def get_cobro_automatico(env):
"""Sub-bloque `cobro_automatico` de membresías (copia). Apagado por defecto."""
return get_membresias(env)['cobro_automatico']
# ---------------------------------------------------------------------------
# Recursos físicos de la agenda (cabinas, salas, consultorios, equipos).
#
@@ -1283,7 +1491,7 @@ def get_settings(env, aplicar_licencia=True):
'moneda_base': MONEDA_DEFAULT,
'nota_ticket': '',
'impuestos': dict(IMPUESTOS_DEFAULT),
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'reservas_online': normalizar_reservas_online(None),
'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT),
@@ -1330,7 +1538,11 @@ def set_settings(env, vals):
if 'reservas_online' in vals:
bloque = validar_reservas_online(vals['reservas_online'])
if bloque is not None:
actual['reservas_online'] = {**actual['reservas_online'], **bloque}
anterior = actual['reservas_online']
nuevo = {**anterior, **bloque}
if isinstance(bloque.get('anticipo'), dict):
nuevo['anticipo'] = {**anterior.get('anticipo', {}), **bloque['anticipo']}
actual['reservas_online'] = nuevo
if 'notificaciones' in vals:
bloque = validar_notificaciones(vals['notificaciones'])
if bloque is not None:
@@ -1338,7 +1550,12 @@ def set_settings(env, vals):
if 'membresias' in vals:
bloque = validar_membresias(vals['membresias'])
if bloque is not None:
actual['membresias'] = {**actual['membresias'], **bloque}
anterior = actual['membresias']
nuevo = {**anterior, **bloque}
if isinstance(bloque.get('cobro_automatico'), dict):
nuevo['cobro_automatico'] = {
**anterior.get('cobro_automatico', {}), **bloque['cobro_automatico']}
actual['membresias'] = nuevo
if 'recursos' in vals:
bloque = validar_recursos(vals['recursos'])
if bloque is not None: