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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user