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

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Cobros (enlaces de pago)',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Enlaces de cobro reutilizables: anticipos de reservas en línea y cobro automático de membresías',
'description': """
Modelo único de ENLACE DE COBRO (`skeen.cobro.enlace`) reutilizable por
anticipos de reservas en línea, cobro automático de membresías y ventas.
- Token HMAC firmado con caducidad y estado (pendiente/pagado/expirado/
cancelado).
- Proveedor: Stripe, MercadoPago o `simulado` cuando no hay credenciales
de pasarela (permite probar el flujo completo sin dinero real ni llaves).
- Endpoints públicos seguros para consultar y pagar el enlace, con límite
de peticiones y trampa antiautómatas.
- Webhooks de Stripe y MercadoPago que verifican la firma/secreto.
- Al confirmarse el pago se crea un `skeen.pago` real ligado a la cita y
a la venta, para que aparezca en cortes de caja y reportes.
Todo el módulo está APAGADO por defecto: los sub-bloques `anticipo` y
`cobro_automatico` nacen con `activo=false`, así que una instancia sin
configurar se comporta EXACTAMENTE como antes.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base', 'skeen_citas', 'skeen_pagos', 'skeen_ventas'],
'data': [
'security/ir.model.access.csv',
'data/cobro_sequence.xml',
'data/cobros_cron.xml',
'views/cobro_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import public_cobro

View 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)

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_skeen_cobro_enlace" model="ir.sequence">
<field name="name">Enlaces de cobro SKEEN</field>
<field name="code">skeen.cobro.enlace</field>
<field name="prefix">COBRO-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Expiración de enlaces de cobro. Marca expirados los enlaces vencidos
y aplica su consecuencia: cancela la cita retenida (libera el
horario) o avisa al periodo de la membresía. Con los ajustes por
defecto (anticipo apagado / cobro_automatico apagado) no hay
enlaces pendientes y no hace nada. -->
<record id="cron_cobros_expirar" model="ir.cron">
<field name="name">SKEEN Cobros: expirar enlaces y liberar retenciones</field>
<field name="model_id" ref="model_skeen_cobro_service"/>
<field name="state">code</field>
<field name="code">model._cron_tick()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import cobro_enlace

View File

@@ -0,0 +1,624 @@
# -*- coding: utf-8 -*-
"""Enlaces de cobro reutilizables (anticipos y membresías).
Un ENLACE DE COBRO es una promesa de pago con un token firmado y caducidad.
Lo usan dos flujos:
- `reserva`: anticipo de una reserva creada en el portal público. La cita
queda retenida (`anticipo_estado='pendiente'`) hasta que se paga; si el
enlace expira, el cron cancela la cita y libera el horario.
- `membresia`: cobro automático del periodo de una membresía. Al pagarse, el
periodo pasa a `pagado` y la membresía se renueva.
Regla de oro: NADA de esto se activa solo. El anticipo vive dentro de
`reservas_online.anticipo` y el cobro de membresías dentro de
`membresias.cobro_automatico`; ambos con `activo=false` por defecto.
Al confirmarse un pago se crea un `skeen.pago` REAL (state='completed', con
`payment_date`) ligado a la cita y a la venta, de modo que el corte de caja y
los reportes lo ven como cualquier otro cobro.
"""
import base64
import hashlib
import hmac
import json
import logging
import secrets
import time
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
ORIGENES = [
('reserva', 'Reserva en línea'),
('membresia', 'Membresía'),
('venta', 'Venta'),
]
ESTADOS = [
('pendiente', 'Pendiente'),
('pagado', 'Pagado'),
('expirado', 'Expirado'),
('cancelado', 'Cancelado'),
]
PROVEEDORES = [
('simulado', 'Simulado (sin pasarela)'),
('stripe', 'Stripe'),
('mercadopago', 'MercadoPago'),
]
SECRET_KEY = 'skeen.frontend.secret'
TOKEN_KIND = 'cobro'
TTL_DEFAULT_HORAS = 24
TTL_MAX_HORAS = 720 # 30 días
# Método de `skeen.pago` con el que se registra cada proveedor. El simulado se
# registra como tarjeta (es un cobro en línea ficticio) para el corte de caja.
METODO_PAGO = {
'stripe': 'stripe',
'mercadopago': 'mercadopago',
'simulado': 'stripe',
}
class SkeenCobroEnlace(models.Model):
_name = 'skeen.cobro.enlace'
_description = 'Enlace de cobro'
_order = 'create_date desc, id desc'
name = fields.Char(string='Folio', readonly=True, copy=False, index=True)
origen = fields.Selection(ORIGENES, string='Origen', required=True, index=True)
estado = fields.Selection(ESTADOS, string='Estado', default='pendiente',
required=True, index=True)
proveedor = fields.Selection(PROVEEDORES, string='Proveedor', default='simulado',
required=True)
# Documentos ligados
partner_id = fields.Many2one('res.partner', string='Paciente', index=True,
ondelete='set null')
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
ondelete='set null')
venta_id = fields.Many2one('skeen.venta', string='Venta', index=True,
ondelete='set null')
pago_id = fields.Many2one('skeen.pago', string='Pago', ondelete='set null')
# Monto y concepto
concepto = fields.Char(string='Concepto', required=True)
monto = fields.Float(string='Monto', required=True, default=0.0)
currency_id = fields.Many2one(
'res.currency', string='Moneda',
default=lambda self: self.env.ref('base.MXN', raise_if_not_found=False))
# Token y vigencia
token = fields.Char(string='Token', index=True, copy=False)
token_expira = fields.Datetime(string='Vence el', index=True)
url = fields.Char(string='URL de pago')
url_externa = fields.Char(string='URL en la pasarela')
# Respuesta del proveedor
provider_reference = fields.Char(string='Referencia de la pasarela')
provider_response = fields.Text(string='Respuesta de la pasarela')
# Fechas
fecha_pago = fields.Datetime(string='Pagado el', readonly=True, copy=False)
enviado_at = fields.Datetime(string='Enviado el', copy=False)
ultimo_envio = fields.Datetime(string='Último aviso', copy=False)
recordatorios = fields.Integer(string='Avisos enviados', default=0)
notas = fields.Text(string='Notas')
log_ids = fields.One2many('skeen.cobro.enlace.log', 'enlace_id',
string='Bitácora')
_sql_constraints = [
('token_uniq', 'unique(token)', 'El token del enlace de cobro debe ser único.'),
]
def _log(self, evento, detalle='', user=''):
"""Registra un evento en la bitácora. Nunca tumba la operación."""
try:
self.env['skeen.cobro.enlace.log'].sudo().create({
'enlace_id': self.id,
'evento': evento,
'detalle': detalle or '',
'user_name': user or '',
})
except Exception:
_logger.exception('No se pudo registrar la bitácora del enlace %s', self.id)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
vals['name'] = self.env['ir.sequence'].next_by_code(
'skeen.cobro.enlace') or 'COBRO-001'
return super().create(vals_list)
def action_cancelar(self, motivo='', user=''):
"""Cancela el enlace pendiente y libera la retención asociada."""
svc = self.env['skeen.cobro.service']
for enlace in self:
if enlace.estado != 'pendiente':
continue
enlace.write({'estado': 'cancelado'})
enlace._log('cancelado', motivo or 'Cancelado manualmente', user=user)
try:
svc._on_expirado(enlace)
except Exception:
_logger.exception('No se pudo liberar la retención del enlace %s', enlace.id)
return True
class SkeenCobroEnlaceLog(models.Model):
_name = 'skeen.cobro.enlace.log'
_description = 'Bitácora de enlace de cobro'
_order = 'create_date desc, id desc'
enlace_id = fields.Many2one('skeen.cobro.enlace', string='Enlace',
required=True, ondelete='cascade', index=True)
evento = fields.Char(string='Evento')
detalle = fields.Text(string='Detalle')
user_name = fields.Char(string='Usuario')
create_date = fields.Datetime(string='Fecha', readonly=True)
class SkeenCobroService(models.AbstractModel):
"""Servicio único de enlaces de cobro (crear, firmar, confirmar, expirar)."""
_name = 'skeen.cobro.service'
_description = 'Servicio de enlaces de cobro'
# ------------------------------------------------------------------
# Token firmado
# ------------------------------------------------------------------
@api.model
def _secreto(self):
ICP = self.env['ir.config_parameter'].sudo()
secret = ICP.get_param(SECRET_KEY)
if not secret:
secret = secrets.token_urlsafe(48)
ICP.set_param(SECRET_KEY, secret)
return secret
@api.model
def _b64(self, raw):
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
@api.model
def _b64d(self, texto):
return base64.urlsafe_b64decode(texto + '=' * (-len(texto) % 4))
@api.model
def firmar_token(self, enlace):
payload = {'eid': enlace.id, 'kind': TOKEN_KIND,
'exp': int(time.time()) + int(
(enlace.token_expira - fields.Datetime.now()).total_seconds())}
body = self._b64(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
sig = hmac.new(self._secreto().encode('utf-8'), body.encode('ascii'),
hashlib.sha256).hexdigest()
return '%s.%s' % (body, sig)
@api.model
def verificar_token(self, token):
"""Devuelve el enlace del token o None si es inválido/expirado/manipulado."""
if not token or '.' not in str(token):
return None
try:
body, sig = str(token).rsplit('.', 1)
esperada = hmac.new(self._secreto().encode('utf-8'), body.encode('ascii'),
hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, esperada):
return None
payload = json.loads(self._b64d(body).decode('utf-8'))
except Exception:
return None
if payload.get('kind') != TOKEN_KIND:
return None
if int(payload.get('exp', 0)) < int(time.time()):
return None
enlace = self.env['skeen.cobro.enlace'].sudo().browse(int(payload.get('eid') or 0))
if not enlace.exists():
return None
# El token guardado debe coincidir (defensa en profundidad).
if enlace.token and not hmac.compare_digest(str(enlace.token), str(token)):
return None
return enlace
# ------------------------------------------------------------------
# Proveedor / URL
# ------------------------------------------------------------------
@api.model
def proveedor_disponible(self):
"""'stripe' | 'mercadopago' | 'simulado' según las credenciales ICP.
Sin llaves configuradas devuelve 'simulado': el enlace se puede probar
por el camino explícito de simulación (nunca cobra dinero real).
"""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('skeen.stripe_secret_key'):
return 'stripe'
if ICP.get_param('skeen.mercadopago_access_token'):
return 'mercadopago'
return 'simulado'
@api.model
def url_publica(self):
from odoo.addons.skeen_citas.clinic_settings import url_publica
base = url_publica(self.env, fallback='')
if not base:
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url') or ''
return (base or '').rstrip('/')
@api.model
def url_pago_token(self, token):
return '%s/pagar/%s' % (self.url_publica(), token)
@api.model
def _currency(self, moneda):
codigo = (moneda or 'MXN').upper()
cur = self.env['res.currency'].sudo().search([('name', '=', codigo)], limit=1)
if cur:
return cur
return self.env.ref('base.MXN', raise_if_not_found=False) or self.env['res.currency'].sudo().search([], limit=1)
# ------------------------------------------------------------------
# Crear
# ------------------------------------------------------------------
@api.model
def crear(self, origen, monto, concepto, ttl_horas=None, partner=None,
cita=None, moneda='MXN', notas=''):
"""Crea un enlace de cobro pendiente con su token y URL.
No intenta crear el checkout real (eso lo hace `intentar_checkout`),
para que la reserva no dependa de la pasarela.
"""
monto = round(float(monto or 0.0), 2)
horas = int(ttl_horas or TTL_DEFAULT_HORAS)
horas = min(max(horas, 1), TTL_MAX_HORAS)
partner = partner or (cita.partner_id if cita else None)
enlace = self.env['skeen.cobro.enlace'].sudo().create({
'origen': origen,
'estado': 'pendiente',
'proveedor': self.proveedor_disponible(),
'partner_id': partner.id if partner else False,
'cita_id': cita.id if cita else False,
'concepto': (concepto or '')[:200],
'monto': monto,
'currency_id': self._currency(moneda).id,
'notas': notas or '',
'token_expira': fields.Datetime.now() + timedelta(hours=horas),
})
token = self.firmar_token(enlace)
enlace.write({'token': token, 'url': self.url_pago_token(token)})
enlace._log('creado', 'Enlace creado por %s (%s %s)' % (
origen, enlace.currency_id.name or '', monto))
return enlace
@api.model
def intentar_checkout(self, enlace):
"""Crea la sesión en la pasarela real. Sin credenciales no hace nada.
Devuelve True si quedó una URL externa usable. Nunca lanza hacia el
llamador (la reserva/cobro no debe caerse por la pasarela).
"""
enlace = enlace.exists()
if not enlace or enlace.proveedor == 'simulado':
return False
try:
if enlace.proveedor == 'stripe':
return self._checkout_stripe(enlace)
if enlace.proveedor == 'mercadopago':
return self._checkout_mercadopago(enlace)
except Exception as exc:
_logger.warning('No se pudo crear el checkout %s: %s', enlace.proveedor, exc)
enlace.write({'provider_response': str(exc)[:2000]})
return False
@api.model
def _checkout_stripe(self, enlace):
import requests
key = self.env['ir.config_parameter'].sudo().get_param('skeen.stripe_secret_key')
if not key:
return False
base = self.url_publica()
data = {
'mode': 'payment',
'payment_method_types[]': 'card',
'line_items[0][price_data][currency]': (enlace.currency_id.name or 'MXN').lower(),
'line_items[0][price_data][product_data][name]': enlace.concepto or 'Cobro',
'line_items[0][price_data][unit_amount]': int(round(enlace.monto * 100)),
'line_items[0][quantity]': 1,
'success_url': '%s/pagar/%s?estado=pagado' % (base, enlace.token),
'cancel_url': '%s/pagar/%s?estado=cancelado' % (base, enlace.token),
'metadata[enlace_id]': str(enlace.id),
}
resp = requests.post('https://api.stripe.com/v1/checkout/sessions',
headers={'Authorization': 'Bearer %s' % key}, data=data, timeout=30)
if resp.status_code != 200:
enlace.write({'provider_response': resp.text[:2000]})
return False
sesion = resp.json()
enlace.write({'url_externa': sesion.get('url'), 'provider_reference': sesion.get('id'),
'provider_response': json.dumps(sesion)[:2000]})
return bool(sesion.get('url'))
@api.model
def _checkout_mercadopago(self, enlace):
import requests
token = self.env['ir.config_parameter'].sudo().get_param('skeen.mercadopago_access_token')
if not token:
return False
base = self.url_publica()
data = {
'items': [{'title': enlace.concepto or 'Cobro', 'quantity': 1,
'unit_price': float(enlace.monto)}],
'back_urls': {'success': '%s/pagar/%s?estado=pagado' % (base, enlace.token),
'pending': '%s/pagar/%s' % (base, enlace.token),
'failure': '%s/pagar/%s?estado=cancelado' % (base, enlace.token)},
'notification_url': '%s/skeen/frontend/v1/public/cobro/webhook/mercadopago' % base,
'external_reference': enlace.token,
'metadata': {'enlace_id': enlace.id},
}
resp = requests.post('https://api.mercadopago.com/checkout/preferences',
headers={'Authorization': 'Bearer %s' % token}, json=data, timeout=30)
if resp.status_code not in (200, 201):
enlace.write({'provider_response': resp.text[:2000]})
return False
pref = resp.json()
enlace.write({'url_externa': pref.get('init_point'), 'provider_reference': pref.get('id'),
'provider_response': json.dumps(pref)[:2000]})
return bool(pref.get('init_point'))
# ------------------------------------------------------------------
# Confirmar pago
# ------------------------------------------------------------------
@api.model
def confirmar(self, enlace, proveedor=None, referencia='', monto=None,
response='', user='', force=False):
"""Marca el enlace como pagado y registra el pago real.
Idempotente: un enlace ya pagado NO se vuelve a cobrar (devuelve False).
Un enlace expirado/cancelado solo se puede forzar con `force=True`
(camino de la pantalla de personal, nunca del portal público).
"""
enlace = enlace.exists()
if not enlace:
return False
if enlace.estado == 'pagado':
return False
if enlace.estado in ('expirado', 'cancelado') and not force:
return False
monto = round(float(monto), 2) if monto not in (None, '') else enlace.monto
proveedor = proveedor or enlace.proveedor
pago = self._registrar_pago(enlace, proveedor, monto, referencia, response)
enlace.write({
'estado': 'pagado',
'fecha_pago': fields.Datetime.now(),
'pago_id': pago.id,
'proveedor': proveedor,
'provider_reference': referencia or enlace.provider_reference,
'provider_response': (response or enlace.provider_response or '')[:2000],
})
enlace._log('pagado', 'Pago %s por %s (%s)%s' % (
pago.name, monto, proveedor, ' · forzado' if force else ''), user=user)
try:
self._post_pago(enlace, pago)
except Exception:
_logger.exception('No se pudo aplicar el post-pago del enlace %s', enlace.id)
return pago
@api.model
def _registrar_pago(self, enlace, proveedor, monto, referencia, response):
"""Crea la venta (si falta) y el `skeen.pago` completado."""
venta = enlace.venta_id
if not venta:
venta = self._crear_venta(enlace)
metodo = METODO_PAGO.get(proveedor, 'stripe')
pago = self.env['skeen.pago'].sudo().create({
'partner_id': enlace.partner_id.id,
'cita_id': enlace.cita_id.id if enlace.cita_id else False,
'venta_id': venta.id if venta else False,
'amount': monto,
'currency_id': enlace.currency_id.id,
'payment_method': metodo,
'state': 'completed',
'payment_date': fields.Datetime.now(),
'provider_reference': referencia or False,
'provider_response': (response or '')[:2000] or False,
})
if venta:
pagado = (venta.amount_paid or 0.0) + monto
venta.write({
'amount_paid': pagado,
'state': 'paid' if pagado + 1e-6 >= venta.total else 'partial',
})
enlace.write({'venta_id': venta.id if venta else False})
return pago
@api.model
def _crear_venta(self, enlace):
"""Venta del anticipo/concepto. Se liga a la cita cuando existe.
La línea de `skeen.venta.line` EXIGE un servicio o un artículo. Si el
enlace no tiene servicio asociado (caso raro de origen 'venta') no se
crea venta: el pago igual queda registrado en el corte de caja.
"""
cita = enlace.cita_id
servicio = cita.servicio_id if cita else False
if not servicio:
return self.env['skeen.venta']
vals = {
'partner_id': enlace.partner_id.id,
'date': fields.Date.today(),
'notes': _('Cobro en línea %s') % (enlace.name or ''),
'line_ids': [(0, 0, {
'service_id': servicio.id,
'description': enlace.concepto or 'Cobro en línea',
'quantity': 1.0,
'price_unit': enlace.monto,
})],
}
if cita:
vals['cita_id'] = cita.id
venta = self.env['skeen.venta'].sudo().create(vals)
venta.action_confirm()
return venta
# ------------------------------------------------------------------
# Post-pago por origen
# ------------------------------------------------------------------
@api.model
def _post_pago(self, enlace, pago):
if enlace.origen == 'reserva' and enlace.cita_id:
self._confirmar_reserva(enlace, pago)
elif enlace.origen == 'membresia':
self._confirmar_membresia(enlace, pago)
elif enlace.origen == 'venta' and enlace.venta_id:
pass
@api.model
def _confirmar_reserva(self, enlace, pago):
"""Confirma la cita retenida y refleja el anticipo pagado."""
cita = enlace.cita_id
if not cita:
return
vals = {'anticipo_estado': 'pagado'}
if enlace.monto:
vals['amount_paid'] = (cita.amount_paid or 0.0) + enlace.monto
if cita.state == 'pending':
cita.write(vals)
cita.set_state('confirmed', user='Portal en línea',
note='Anticipo pagado (%s)' % (enlace.name or ''))
else:
cita.write(vals)
precio = cita.servicio_price or 0.0
if precio and cita.amount_paid + 1e-6 >= precio:
cita.write({'payment_state': 'paid'})
elif cita.payment_state != 'paid':
cita.write({'payment_state': 'partial'})
try:
cita.message_post(body='Anticipo pagado en línea: %s %s (enlace %s).' % (
enlace.currency_id.name or '', enlace.monto, enlace.name))
except Exception:
pass
self.notificar_confirmacion_reserva(cita)
@api.model
def notificar_confirmacion_reserva(self, cita):
"""Encola la confirmación al pagarse el anticipo (soft-ref a WhatsApp)."""
from odoo.addons.skeen_citas.clinic_settings import get_notificaciones
cfg = get_notificaciones(self.env)
if not (cfg.get('activo') and cfg.get('confirmacion_al_crear')):
return False
try:
service = self.env['skeen.notificacion.service'].sudo()
except Exception:
return False
try:
creadas = service.encolar(cita, 'confirmacion')
for notif in creadas:
service._intentar(notif, cfg)
return bool(creadas)
except Exception:
_logger.exception('No se pudo avisar la confirmación del anticipo')
return False
@api.model
def reembolsar_anticipo(self, cita):
"""Reembolsa el anticipo de una cita cancelada si la config lo permite.
Comportamiento elegido: si `reembolsable=True` se marca el `skeen.pago`
como 'refunded' y la cita como `anticipo_estado='reembolsado'` (NO se
revierten puntos de monedero, porque el anticipo no los generó). Si
`reembolsable=False` el anticipo se retiene y se deja bitácora.
"""
from odoo.addons.skeen_citas.clinic_settings import get_anticipo
cfg = get_anticipo(self.env)
if not cfg.get('activo'):
return False
enlaces = self.env['skeen.cobro.enlace'].sudo().search([
('cita_id', '=', cita.id), ('origen', '=', 'reserva'),
('estado', '=', 'pagado')])
hecho = False
for enlace in enlaces:
if not cfg.get('reembolsable', True):
enlace._log('reembolso_omitido', 'El anticipo no es reembolsable')
continue
if enlace.pago_id and enlace.pago_id.state != 'refunded':
enlace.pago_id.write({'state': 'refunded'})
enlace._log('reembolsado', 'Anticipo reembolsado por cancelación')
cita.write({'anticipo_estado': 'reembolsado'})
hecho = True
return hecho
@api.model
def cancelar_enlaces_de_cita(self, cita, motivo=''):
"""Cancela los enlaces pendientes de una cita (al cancelarla)."""
enlaces = self.env['skeen.cobro.enlace'].sudo().search([
('cita_id', '=', cita.id), ('estado', '=', 'pendiente')])
for enlace in enlaces:
enlace.write({'estado': 'cancelado'})
enlace._log('cancelado', motivo or 'La cita se canceló')
return len(enlaces)
@api.model
def _confirmar_membresia(self, enlace, pago):
"""Delega en el periodo de la membresía (definido en skeen_membresias)."""
periodo_id = getattr(enlace, 'periodo_id', None)
if periodo_id and hasattr(periodo_id, '_cobro_confirmado'):
periodo_id._cobro_confirmado(enlace, pago)
# ------------------------------------------------------------------
# Expiración (cron)
# ------------------------------------------------------------------
@api.model
def _cron_tick(self):
return self.expirar_vencidos()
@api.model
def expirar_vencidos(self):
"""Marca expirados los enlaces vencidos y aplica su consecuencia."""
ahora = fields.Datetime.now()
enlaces = self.env['skeen.cobro.enlace'].sudo().search([
('estado', '=', 'pendiente'),
('token_expira', '!=', False),
('token_expira', '<', ahora),
])
total = 0
for enlace in enlaces:
enlace.write({'estado': 'expirado'})
enlace._log('expirado', 'Venció el %s' % enlace.token_expira)
try:
self._on_expirado(enlace)
except Exception:
_logger.exception('No se pudo aplicar la expiración del enlace %s', enlace.id)
total += 1
return total
@api.model
def _on_expirado(self, enlace):
if enlace.origen == 'reserva' and enlace.cita_id:
self._cancelar_reserva(enlace)
elif enlace.origen == 'membresia':
periodo_id = getattr(enlace, 'periodo_id', None)
if periodo_id and hasattr(periodo_id, '_cobro_expirado'):
periodo_id._cobro_expirado(enlace)
@api.model
def _cancelar_reserva(self, enlace):
"""Cancela la cita retenida y libera el horario."""
cita = enlace.cita_id
if not cita or cita.anticipo_estado != 'pendiente':
return False
if cita.state not in ('pending', 'confirmed'):
return False
cita.write({'cancellation_reason': 'Anticipo no pagado'})
cita.set_state('cancelled', user='Sistema',
note='Anticipo no pagado en %s' % (enlace.name or ''))
cita.write({'anticipo_estado': 'ninguno'})
try:
cita.message_post(body='Reserva cancelada automáticamente: no se pagó el anticipo (%s).' % (enlace.name or ''))
except Exception:
pass
return True

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_cobro_enlace_user,Acceso a enlaces de cobro,model_skeen_cobro_enlace,base.group_user,1,1,1,1
access_skeen_cobro_enlace_log_user,Acceso a bitácora de cobros,model_skeen_cobro_enlace_log,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_cobro_enlace_user Acceso a enlaces de cobro model_skeen_cobro_enlace base.group_user 1 1 1 1
3 access_skeen_cobro_enlace_log_user Acceso a bitácora de cobros model_skeen_cobro_enlace_log base.group_user 1 1 1 1

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_skeen_cobro_enlace_tree" model="ir.ui.view">
<field name="name">skeen.cobro.enlace.tree</field>
<field name="model">skeen.cobro.enlace</field>
<field name="arch" type="xml">
<tree string="Enlaces de cobro" create="false">
<field name="name"/>
<field name="origen"/>
<field name="partner_id"/>
<field name="concepto"/>
<field name="monto"/>
<field name="currency_id"/>
<field name="proveedor"/>
<field name="estado"/>
<field name="token_expira"/>
<field name="fecha_pago"/>
</tree>
</field>
</record>
<record id="view_skeen_cobro_enlace_form" model="ir.ui.view">
<field name="name">skeen.cobro.enlace.form</field>
<field name="model">skeen.cobro.enlace</field>
<field name="arch" type="xml">
<form string="Enlace de cobro" create="false">
<sheet>
<group>
<group>
<field name="name"/>
<field name="origen"/>
<field name="estado"/>
<field name="proveedor"/>
<field name="partner_id"/>
</group>
<group>
<field name="concepto"/>
<field name="monto"/>
<field name="currency_id"/>
<field name="token_expira"/>
<field name="fecha_pago"/>
</group>
</group>
<group>
<field name="cita_id"/>
<field name="venta_id"/>
<field name="pago_id"/>
<field name="url"/>
<field name="provider_reference"/>
</group>
<notebook>
<page string="Bitácora">
<field name="log_ids">
<tree create="false" edit="false">
<field name="create_date"/>
<field name="evento"/>
<field name="detalle"/>
<field name="user_name"/>
</tree>
</field>
</page>
<page string="Respuesta de la pasarela">
<field name="provider_response"/>
</page>
</notebook>
<field name="notas"/>
</sheet>
</form>
</field>
</record>
<record id="action_skeen_cobro_enlace" model="ir.actions.act_window">
<field name="name">Enlaces de cobro</field>
<field name="res_model">skeen.cobro.enlace</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>