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:
3
odoo-addons/skeen_cobros/controllers/__init__.py
Normal file
3
odoo-addons/skeen_cobros/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import public_cobro
|
||||
356
odoo-addons/skeen_cobros/controllers/public_cobro.py
Normal file
356
odoo-addons/skeen_cobros/controllers/public_cobro.py
Normal file
@@ -0,0 +1,356 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Página pública de pago de un enlace de cobro (sin sesión).
|
||||
|
||||
Endpoints anónimos bajo `/skeen/frontend/v1/public/cobro`:
|
||||
|
||||
GET /cobro?token=... datos mínimos y no sensibles del enlace
|
||||
POST /cobro/pagar confirma el pago (simulado o pasarela real)
|
||||
POST /cobro/webhook/stripe notificación de Stripe (firma verificada)
|
||||
POST /cobro/webhook/mercadopago notificación de MercadoPago (secreto/token)
|
||||
|
||||
Reglas de oro:
|
||||
- El token es HMAC firmado con caducidad; sin token válido no se expone nada.
|
||||
- El pago en modo `simulado` SOLO se permite si el ajuste correspondiente
|
||||
(`reservas_online.anticipo.permitir_simulado` o
|
||||
`membresias.cobro_automatico.permitir_simulado`) está encendido. Nunca se
|
||||
simula un pago en producción con la configuración por defecto.
|
||||
- Límite de peticiones por IP y trampa antiautómatas (honeypot) como en el
|
||||
portal de reservas.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
|
||||
from odoo.addons.skeen_citas.clinic_settings import (
|
||||
get_settings as get_clinic_settings,
|
||||
get_anticipo,
|
||||
get_cobro_automatico,
|
||||
brand_dict,
|
||||
)
|
||||
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
|
||||
|
||||
PREFIX = '/skeen/frontend/v1/public/cobro'
|
||||
|
||||
RATE_LIMITS = {'get': (60, 60), 'pagar': (10, 60), 'webhook': (120, 60)}
|
||||
_RATE_LOCK = threading.Lock()
|
||||
_RATE_HITS = {}
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_ERROR_INTERNO = 'No se pudo procesar la solicitud. Intenta de nuevo.'
|
||||
|
||||
|
||||
def _json(data, status=200):
|
||||
return Response(
|
||||
json.dumps(data, default=str),
|
||||
status=status,
|
||||
mimetype='application/json',
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Stripe-Signature',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _client_ip():
|
||||
xff = request.httprequest.headers.get('X-Forwarded-For') or ''
|
||||
if xff:
|
||||
return xff.split(',')[0].strip()
|
||||
return request.httprequest.remote_addr or 'desconocida'
|
||||
|
||||
|
||||
def _rate_limited(bucket):
|
||||
limit, window = RATE_LIMITS.get(bucket, (30, 60))
|
||||
key = (_client_ip(), bucket)
|
||||
ahora = time.time()
|
||||
with _RATE_LOCK:
|
||||
hits = [t for t in _RATE_HITS.get(key, []) if ahora - t < window]
|
||||
if len(hits) >= limit:
|
||||
_RATE_HITS[key] = hits
|
||||
return True
|
||||
hits.append(ahora)
|
||||
_RATE_HITS[key] = hits
|
||||
return False
|
||||
|
||||
|
||||
class SkeenPublicCobroController(http.Controller):
|
||||
"""Página pública de pago de un enlace."""
|
||||
|
||||
def _service(self):
|
||||
return request.env['skeen.cobro.service'].sudo()
|
||||
|
||||
def _settings(self):
|
||||
return get_clinic_settings(request.env)
|
||||
|
||||
def _brand(self):
|
||||
guardado = brand_dict(request.env)
|
||||
out = {
|
||||
'nombre': self._settings().get('nombre') or 'Clínica',
|
||||
'logo_claro': '/brand/logo-blanco.svg',
|
||||
'logo_oscuro': '/brand/logo-negro.svg',
|
||||
'colores': {'primario': '', 'acento': ''},
|
||||
}
|
||||
if isinstance(guardado, dict):
|
||||
if guardado.get('nombre'):
|
||||
out['nombre'] = str(guardado['nombre'])
|
||||
for k in ('logo_claro', 'logo_oscuro'):
|
||||
if guardado.get(k):
|
||||
out[k] = str(guardado[k])
|
||||
colores = guardado.get('colores')
|
||||
if isinstance(colores, dict):
|
||||
for c in ('primario', 'acento'):
|
||||
if colores.get(c):
|
||||
out['colores'][c] = str(colores[c])
|
||||
return out
|
||||
|
||||
def _simulado_permitido(self, enlace):
|
||||
try:
|
||||
if enlace.origen == 'reserva':
|
||||
return bool(get_anticipo(request.env).get('permitir_simulado'))
|
||||
if enlace.origen == 'membresia':
|
||||
return bool(get_cobro_automatico(request.env).get('permitir_simulado'))
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _estado_label(enlace):
|
||||
return dict(enlace._fields['estado'].selection).get(enlace.estado, enlace.estado)
|
||||
|
||||
def _enlace_de_token(self, token):
|
||||
return self._service().verificar_token(token)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Datos del enlace (mínimos, no sensibles)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def cobro_get(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if _rate_limited('get'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||
try:
|
||||
enlace = self._enlace_de_token(kw.get('token'))
|
||||
if not enlace:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Enlace no válido o expirado'}, 404)
|
||||
settings = self._settings()
|
||||
_pack, terms = resolver_glosario(request.env)
|
||||
proveedor = self._service().proveedor_disponible()
|
||||
puede_pagar = (enlace.estado == 'pendiente' and
|
||||
(proveedor != 'simulado' or self._simulado_permitido(enlace)))
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'folio': enlace.name,
|
||||
'concepto': enlace.concepto,
|
||||
'monto': enlace.monto,
|
||||
'moneda': enlace.currency_id.name or 'MXN',
|
||||
'estado': enlace.estado,
|
||||
'estado_label': self._estado_label(enlace),
|
||||
'origen': enlace.origen,
|
||||
'proveedor': enlace.proveedor,
|
||||
'expira': enlace.token_expira,
|
||||
'fecha_pago': enlace.fecha_pago,
|
||||
'paciente': (enlace.partner_id.name or '').split(' ')[0] if enlace.partner_id else '',
|
||||
'puede_pagar': bool(puede_pagar),
|
||||
'simulado': enlace.proveedor == 'simulado',
|
||||
'url_externa': enlace.url_externa or '',
|
||||
'brand': self._brand(),
|
||||
'terms': terms,
|
||||
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('Error consultando enlace de cobro: %s', exc)
|
||||
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Pagar
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/pagar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def cobro_pagar(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if _rate_limited('pagar'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||
try:
|
||||
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||
except Exception:
|
||||
data = {}
|
||||
if str(data.get('hp') or '').strip():
|
||||
return _json({'status': 'error', 'message': 'Solicitud rechazada'}, 400)
|
||||
try:
|
||||
enlace = self._enlace_de_token(data.get('token') or kw.get('token'))
|
||||
if not enlace:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Enlace no válido o expirado'}, 404)
|
||||
if enlace.estado == 'pagado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este enlace ya fue pagado.'}, 409)
|
||||
if enlace.estado == 'expirado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este enlace expiró.'}, 410)
|
||||
if enlace.estado == 'cancelado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este enlace fue cancelado.'}, 409)
|
||||
|
||||
proveedor = self._service().proveedor_disponible()
|
||||
if proveedor != 'simulado':
|
||||
# Camino real: se crea/recupera el checkout de la pasarela.
|
||||
if not enlace.url_externa:
|
||||
self._service().intentar_checkout(enlace)
|
||||
if enlace.url_externa:
|
||||
return _json({'status': 'success', 'requiere_pasarela': True,
|
||||
'url': enlace.url_externa, 'proveedor': proveedor})
|
||||
return _json({'status': 'error',
|
||||
'message': 'La pasarela no está disponible. Contacta a la clínica.'}, 502)
|
||||
|
||||
# Camino simulado (sin pasarela).
|
||||
if not self._simulado_permitido(enlace):
|
||||
return _json({'status': 'error',
|
||||
'message': 'El pago en línea no está disponible. '
|
||||
'Contacta a la clínica.'}, 403)
|
||||
pago = self._service().confirmar(
|
||||
enlace, proveedor='simulado', referencia='SIMULADO-%s' % enlace.id,
|
||||
response='Pago simulado desde la página pública', user='Portal en línea')
|
||||
if not pago:
|
||||
return _json({'status': 'error',
|
||||
'message': 'No se pudo confirmar el pago.'}, 409)
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'ok': True,
|
||||
'estado': 'pagado',
|
||||
'estado_label': self._estado_label(enlace),
|
||||
'comprobante': {
|
||||
'folio': enlace.name,
|
||||
'concepto': enlace.concepto,
|
||||
'monto': enlace.monto,
|
||||
'moneda': enlace.currency_id.name or 'MXN',
|
||||
'fecha': enlace.fecha_pago,
|
||||
'proveedor': 'simulado',
|
||||
'pago': pago.name,
|
||||
},
|
||||
})
|
||||
except Exception as exc:
|
||||
request.env.cr.rollback()
|
||||
_logger.exception('Error pagando enlace de cobro: %s', exc)
|
||||
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Webhooks (notificación de la pasarela)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/webhook/stripe', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def webhook_stripe(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
secret = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'skeen.stripe_webhook_secret')
|
||||
raw = request.httprequest.get_data() or b''
|
||||
if not secret:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Webhook de Stripe no configurado'}, 400)
|
||||
firma = request.httprequest.headers.get('Stripe-Signature') or ''
|
||||
if not self._stripe_firma_valida(raw, firma, secret):
|
||||
return _json({'status': 'error', 'message': 'Firma inválida'}, 400)
|
||||
try:
|
||||
evento = json.loads(raw.decode('utf-8') or '{}')
|
||||
except Exception:
|
||||
return _json({'status': 'error', 'message': 'Cuerpo inválido'}, 400)
|
||||
tipo = evento.get('type') or ''
|
||||
obj = ((evento.get('data') or {}).get('object') or {})
|
||||
if tipo in ('checkout.session.completed', 'payment_intent.succeeded'):
|
||||
meta = obj.get('metadata') or {}
|
||||
enlace_id = int(meta.get('enlace_id') or 0)
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||
if enlace.exists():
|
||||
self._service().confirmar(
|
||||
enlace, proveedor='stripe',
|
||||
referencia=obj.get('id') or obj.get('payment_intent') or '',
|
||||
response=json.dumps(obj)[:2000], user='Webhook Stripe')
|
||||
return _json({'status': 'success'})
|
||||
|
||||
@staticmethod
|
||||
def _stripe_firma_valida(raw, header, secret):
|
||||
partes = {}
|
||||
for item in (header or '').split(','):
|
||||
if '=' in item:
|
||||
k, v = item.split('=', 1)
|
||||
partes.setdefault(k.strip(), v.strip())
|
||||
t = partes.get('t')
|
||||
v1 = partes.get('v1')
|
||||
if not t or not v1:
|
||||
return False
|
||||
firmado = ('%s.%s' % (t, raw.decode('utf-8'))).encode('utf-8')
|
||||
esperada = hmac.new(secret.encode('utf-8'), firmado, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(esperada, v1)
|
||||
|
||||
@http.route(PREFIX + '/webhook/mercadopago', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def webhook_mercadopago(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
token = ICP.get_param('skeen.mercadopago_access_token')
|
||||
secret = ICP.get_param('skeen.mercadopago_webhook_secret')
|
||||
raw = request.httprequest.get_data() or b''
|
||||
try:
|
||||
cuerpo = json.loads(raw.decode('utf-8') or '{}')
|
||||
except Exception:
|
||||
cuerpo = {}
|
||||
data_id = (cuerpo.get('data') or {}).get('id') or request.httprequest.args.get('data.id') or ''
|
||||
tipo = cuerpo.get('type') or request.httprequest.args.get('type') or ''
|
||||
if not token and not secret:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Webhook de MercadoPago no configurado'}, 400)
|
||||
# Verificación por secreto (x-signature) cuando está configurado.
|
||||
if secret:
|
||||
firma = request.httprequest.headers.get('x-signature') or ''
|
||||
if not self._mp_firma_valida(request.httprequest.args.get('data.id') or '', firma, secret):
|
||||
return _json({'status': 'error', 'message': 'Firma inválida'}, 400)
|
||||
elif tipo != 'payment' or not data_id:
|
||||
return _json({'status': 'error', 'message': 'Notificación inválida'}, 400)
|
||||
# Con token, se confirma el pago real consultando la API de MercadoPago.
|
||||
if tipo == 'payment' and data_id and token:
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get('https://api.mercadopago.com/v1/payments/%s' % data_id,
|
||||
headers={'Authorization': 'Bearer %s' % token}, timeout=30)
|
||||
pago = resp.json() if resp.status_code == 200 else {}
|
||||
estado = pago.get('status')
|
||||
ext = (pago.get('metadata') or {}).get('enlace_id')
|
||||
if estado == 'approved' and ext:
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(int(ext))
|
||||
if enlace.exists():
|
||||
self._service().confirmar(
|
||||
enlace, proveedor='mercadopago', referencia=str(data_id),
|
||||
response=json.dumps(pago)[:2000], user='Webhook MercadoPago')
|
||||
except Exception as exc:
|
||||
_logger.warning('Webhook MercadoPago: %s', exc)
|
||||
return _json({'status': 'error', 'message': 'No se pudo verificar'}, 502)
|
||||
return _json({'status': 'success'})
|
||||
|
||||
@staticmethod
|
||||
def _mp_firma_valida(data_id, header, secret):
|
||||
partes = {}
|
||||
for item in (header or '').split(','):
|
||||
if '=' in item:
|
||||
k, v = item.split('=', 1)
|
||||
partes[k.strip()] = v.strip()
|
||||
ts = partes.get('ts')
|
||||
v1 = partes.get('v1')
|
||||
if not ts or not v1:
|
||||
return False
|
||||
manifiesto = 'id:%s;ts:%s;' % (data_id, ts)
|
||||
esperada = hmac.new(secret.encode('utf-8'), manifiesto.encode('utf-8'),
|
||||
hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(esperada, v1)
|
||||
Reference in New Issue
Block a user