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

@@ -333,9 +333,24 @@ class SkeenPublicBookingController(http.Controller):
'descripcion': cfg['descripcion'],
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
'anticipo': self._anticipo_publico(cfg),
},
})
def _anticipo_publico(self, cfg):
"""Sub-bloque de anticipo expuesto al portal (sin datos sensibles)."""
a = cfg.get('anticipo') or {}
tipo = a.get('tipo') or 'porcentaje'
return {
'activo': bool(a.get('activo')) and float(a.get('valor') or 0) > 0,
'tipo': tipo,
'valor': float(a.get('valor') or 0),
'moneda': a.get('moneda') or 'MXN',
'horas_para_pagar': int(a.get('horas_para_pagar') or 24),
'reembolsable': bool(a.get('reembolsable', True)),
'texto_aviso': a.get('texto_aviso') or '',
}
# ------------------------------------------------------------------
# 2. Disponibilidad de horarios
# ------------------------------------------------------------------
@@ -400,6 +415,21 @@ class SkeenPublicBookingController(http.Controller):
except Exception as e:
return _error_interno(e)
def _monto_anticipo(self, anticipo, servicio):
"""Monto del anticipo en moneda de la clínica (0 si no aplica)."""
if not isinstance(anticipo, dict) or not anticipo.get('activo'):
return 0.0
try:
valor = float(anticipo.get('valor') or 0)
except (TypeError, ValueError):
return 0.0
if valor <= 0:
return 0.0
if (anticipo.get('tipo') or 'porcentaje') == 'porcentaje':
precio = float(getattr(servicio, 'price', 0) or 0)
return round(precio * valor / 100.0, 2)
return round(valor, 2)
# ------------------------------------------------------------------
# 3. Crear la reserva
# ------------------------------------------------------------------
@@ -523,6 +553,13 @@ class SkeenPublicBookingController(http.Controller):
creado_paciente = True
state = 'pending' if cfg['requiere_confirmacion'] else 'confirmed'
# Anticipo: si está encendido y hay monto, la cita queda RETENIDA
# (state pending) esperando el pago, aunque no se exija confirmación.
anticipo = cfg.get('anticipo') or {}
monto_anticipo = self._monto_anticipo(anticipo, servicio)
con_anticipo = bool(anticipo.get('activo')) and monto_anticipo > 0
if con_anticipo:
state = 'pending'
nota = ORIGEN_MARCA
if notas:
nota = '%s\n%s' % (ORIGEN_MARCA, notas)
@@ -537,6 +574,9 @@ class SkeenPublicBookingController(http.Controller):
'notes': nota,
'booking_source': 'public_portal',
}
if con_anticipo:
cita_vals['anticipo_estado'] = 'pendiente'
cita_vals['anticipo_monto'] = monto_anticipo
if doctor_id:
cita_vals['doctor_id'] = doctor_id
@@ -559,10 +599,30 @@ class SkeenPublicBookingController(http.Controller):
pass
# Hook Fase 2.4: aviso de confirmación por WhatsApp/WACRM.
if cfg['requiere_confirmacion']:
# Con anticipo NO se confirma todavía: se avisa al pagarse.
enlace = None
if con_anticipo:
try:
svc = request.env['skeen.cobro.service'].sudo()
enlace = svc.crear(
'reserva', monto_anticipo,
'Anticipo: %s' % (servicio.name or ''),
ttl_horas=anticipo.get('horas_para_pagar') or 24,
partner=partner, cita=cita,
moneda=anticipo.get('moneda') or 'MXN')
svc.intentar_checkout(enlace)
cita.sudo().message_post(
subject='Anticipo solicitado',
body='Enlace %s por %s %s (vence %s).' % (
enlace.name, enlace.currency_id.name or '',
monto_anticipo, enlace.token_expira),
message_type='comment')
except Exception:
_logger.exception('No se pudo crear el enlace de anticipo')
elif cfg['requiere_confirmacion']:
self._encolar_confirmacion(cita)
return _json({
respuesta = {
'ok': True,
'folio': cita.name,
'fecha': cita.date.strftime('%Y-%m-%d'),
@@ -574,7 +634,21 @@ class SkeenPublicBookingController(http.Controller):
'requiere_confirmacion': cfg['requiere_confirmacion'],
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
'token_de_gestion': self._sign_token(cita),
}, 201)
}
if con_anticipo and enlace:
respuesta['esperando_anticipo'] = True
respuesta['mensaje_anticipo'] = anticipo.get('texto_aviso') or ''
respuesta['anticipo'] = {
'monto': monto_anticipo,
'moneda': anticipo.get('moneda') or 'MXN',
'horas_para_pagar': int(anticipo.get('horas_para_pagar') or 24),
'reembolsable': bool(anticipo.get('reembolsable', True)),
'url': enlace.url,
'token': enlace.token,
'expira': enlace.token_expira,
'proveedor': enlace.proveedor,
}
return _json(respuesta, 201)
except Exception as e:
request.env.cr.rollback()
return _error_interno(e)
@@ -669,6 +743,12 @@ class SkeenPublicBookingController(http.Controller):
cita.write({'cancellation_reason': motivo or 'Cancelada por el cliente en el portal'})
cita.set_state('cancelled', user='Portal en línea',
note=motivo or 'Cancelada en el portal público')
try:
svc = request.env['skeen.cobro.service'].sudo()
svc.reembolsar_anticipo(cita)
svc.cancelar_enlaces_de_cita(cita, 'Cita cancelada en el portal')
except Exception:
_logger.exception('No se pudo resolver el anticipo al cancelar la cita')
try:
cita.sudo().message_post(
subject='Cancelación en línea',