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:
@@ -1921,6 +1921,11 @@ class SkeenFrontendController(http.Controller):
|
||||
'payment_state': cita.payment_state,
|
||||
'price': cita.servicio_price,
|
||||
'amount_paid': cita.amount_paid,
|
||||
# Anticipo de reserva en línea (distinguir "esperando anticipo").
|
||||
'anticipo_estado': cita.anticipo_estado or 'ninguno',
|
||||
'anticipo_estado_label': dict(cita._fields['anticipo_estado'].selection).get(
|
||||
cita.anticipo_estado, cita.anticipo_estado),
|
||||
'anticipo_monto': cita.anticipo_monto or 0.0,
|
||||
# Duración efectiva (propia de la cita o la del servicio)
|
||||
'duration': cita._duracion(),
|
||||
'duration_override': cita.duration or 0,
|
||||
@@ -5423,6 +5428,146 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Cobros y enlaces de pago
|
||||
# ============================================================
|
||||
def _cobro_to_dict(self, e):
|
||||
return {
|
||||
'id': e.id,
|
||||
'nombre': e.name,
|
||||
'origen': e.origen,
|
||||
'origen_label': dict(e._fields['origen'].selection).get(e.origen, e.origen),
|
||||
'estado': e.estado,
|
||||
'estado_label': dict(e._fields['estado'].selection).get(e.estado, e.estado),
|
||||
'proveedor': e.proveedor,
|
||||
'partner_id': e.partner_id.id if e.partner_id else None,
|
||||
'patient': e.partner_id.name if e.partner_id else '',
|
||||
'cita_id': e.cita_id.id if e.cita_id else None,
|
||||
'folio_cita': e.cita_id.name if e.cita_id else '',
|
||||
'venta_id': e.venta_id.id if e.venta_id else None,
|
||||
'concepto': e.concepto or '',
|
||||
'monto': e.monto,
|
||||
'moneda': e.currency_id.name or 'MXN',
|
||||
'creado': e.create_date.strftime('%Y-%m-%d %H:%M') if e.create_date else None,
|
||||
'expira': e.token_expira.strftime('%Y-%m-%d %H:%M') if e.token_expira else None,
|
||||
'fecha_pago': e.fecha_pago.strftime('%Y-%m-%d %H:%M') if e.fecha_pago else None,
|
||||
'recordatorios': e.recordatorios,
|
||||
'url': e.url or '',
|
||||
}
|
||||
|
||||
@http.route('/skeen/frontend/v1/cobros', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('cobros.ver')
|
||||
def list_cobros(self, **kw):
|
||||
"""Listado de enlaces de cobro (pendientes, pagados, expirados)."""
|
||||
try:
|
||||
domain = []
|
||||
if kw.get('estado'):
|
||||
domain.append(('estado', '=', kw.get('estado')))
|
||||
if kw.get('origen'):
|
||||
domain.append(('origen', '=', kw.get('origen')))
|
||||
enlaces = request.env['skeen.cobro.enlace'].sudo().search(
|
||||
domain, order='create_date desc', limit=200)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'cobros': [self._cobro_to_dict(e) for e in enlaces],
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/cobros/<int:enlace_id>/forzar', type='http',
|
||||
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('cobros.forzar')
|
||||
@forbid_roles('marketing')
|
||||
def forzar_cobro(self, enlace_id, **kw):
|
||||
"""Registra el pago de un enlace sin pasar por la pasarela."""
|
||||
try:
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||
if not enlace.exists():
|
||||
return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404)
|
||||
if enlace.estado == 'pagado':
|
||||
return json_response({'status': 'error', 'message': 'El enlace ya está pagado'}, 409)
|
||||
user = current_user(request.env)
|
||||
data = _parse_json_body()
|
||||
pago = request.env['skeen.cobro.service'].sudo().confirmar(
|
||||
enlace, proveedor=(data.get('proveedor') or enlace.proveedor or 'simulado'),
|
||||
referencia=(data.get('referencia') or 'FORZADO'),
|
||||
response='Forzado desde el panel', user=(user.login if user else 'staff'),
|
||||
force=True)
|
||||
if not pago:
|
||||
return json_response({'status': 'error', 'message': 'No se pudo registrar el pago'}, 409)
|
||||
self._auditar('cobros', 'cobro', str(enlace.id),
|
||||
new_value='forzado', entity_label=enlace.concepto or enlace.name,
|
||||
notes='Pago forzado')
|
||||
return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace),
|
||||
'pago': pago.name})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/cobros/<int:enlace_id>/cancelar', type='http',
|
||||
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('cobros.cancelar')
|
||||
def cancelar_cobro(self, enlace_id, **kw):
|
||||
"""Cancela un enlace pendiente y libera la retención (cita/periodo)."""
|
||||
try:
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||
if not enlace.exists():
|
||||
return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404)
|
||||
if enlace.estado != 'pendiente':
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Solo se pueden cancelar enlaces pendientes'}, 409)
|
||||
data = _parse_json_body()
|
||||
user = current_user(request.env)
|
||||
enlace.action_cancelar(motivo=(data.get('motivo') or 'Cancelado desde el panel'),
|
||||
user=(user.login if user else 'staff'))
|
||||
return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/reports/cobros', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('reportes.ver')
|
||||
def report_cobros(self, **kw):
|
||||
"""Reporte de cobros pendientes y expirados (anticipos y membresías)."""
|
||||
try:
|
||||
start = kw.get('start')
|
||||
end = kw.get('end')
|
||||
domain = [('estado', 'in', ('pendiente', 'expirado', 'cancelado'))]
|
||||
if start:
|
||||
domain.append(('create_date', '>=', f'{start} 00:00:00'))
|
||||
if end:
|
||||
domain.append(('create_date', '<=', f'{end} 23:59:59'))
|
||||
enlaces = request.env['skeen.cobro.enlace'].sudo().search(domain,
|
||||
order='estado, token_expira')
|
||||
ahora = fields.Datetime.now()
|
||||
filas = []
|
||||
resumen = {'pendiente': 0.0, 'expirado': 0.0, 'cancelado': 0.0}
|
||||
for e in enlaces:
|
||||
vencido = bool(e.estado == 'pendiente' and e.token_expira and e.token_expira < ahora)
|
||||
filas.append({
|
||||
**self._cobro_to_dict(e),
|
||||
'vencido': vencido,
|
||||
'dias_para_vencer': ((e.token_expira - ahora).days
|
||||
if e.estado == 'pendiente' and e.token_expira else 0),
|
||||
})
|
||||
if e.estado in resumen:
|
||||
resumen[e.estado] += e.monto
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'start': start, 'end': end,
|
||||
'cobros': filas,
|
||||
'total': len(filas),
|
||||
'resumen': resumen,
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Cortes de Caja
|
||||
# ============================================================
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user