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

@@ -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:

View File

@@ -201,6 +201,18 @@ class SkeenCita(models.Model):
('paid', 'Pagado'),
], string='Estado de Pago', default='not_paid')
amount_paid = fields.Float(string='Monto Pagado', default=0.0)
# Anticipo de una reserva en línea. 'ninguno' es el comportamiento de
# siempre (una cita normal no tiene anticipo). Con 'pendiente' la cita está
# RETENIDA esperando el pago; el enlace de cobro la confirma y la pasa a
# 'pagado'. El campo es puramente informativo/de UI: no cambia la agenda.
anticipo_estado = fields.Selection([
('ninguno', 'Sin anticipo'),
('pendiente', 'Esperando anticipo'),
('pagado', 'Anticipo pagado'),
('reembolsado', 'Anticipo reembolsado'),
], string='Anticipo', default='ninguno', tracking=True, index=True)
anticipo_monto = fields.Float(string='Monto del anticipo', default=0.0)
# WACRM
wacrm_conversation_id = fields.Char(string='WACRM Conversation ID')

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>

View File

@@ -21,7 +21,8 @@
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base', 'mail', 'skeen_citas', 'skeen_pagos', 'skeen_ventas', 'skeen_whatsapp'],
'depends': ['base', 'mail', 'skeen_citas', 'skeen_pagos', 'skeen_ventas',
'skeen_whatsapp', 'skeen_cobros'],
'data': [
'security/ir.model.access.csv',
'data/sequences.xml',

View File

@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
from . import membresia
from . import venta
from . import cobro

View File

@@ -0,0 +1,250 @@
# -*- coding: utf-8 -*-
"""Cobro automático de membresías con enlace de pago (bloque `cobro_automatico`).
Añade al enlace de cobro su liga con el periodo de membresía y el motor que,
desde el cron diario, genera el enlace del periodo, avisa por el motor de
notificaciones (con reintentos) y renueva la membresía al pagarse.
Con `membresias.cobro_automatico.activo=False` (default) NADA cambia: el cron
sigue generando solo el adeudo, sin enlaces ni avisos.
"""
import logging
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.addons.skeen_citas.clinic_settings import (
get_cobro_automatico,
get_membresias,
)
from .membresia import _delta_periodo
_logger = logging.getLogger(__name__)
class SkeenCobroEnlace(models.Model):
_inherit = 'skeen.cobro.enlace'
periodo_id = fields.Many2one('skeen.membresia.periodo', string='Periodo de membresía',
index=True, ondelete='set null')
membresia_id = fields.Many2one(related='periodo_id.membresia_id',
string='Membresía', store=True)
class SkeenMembresiaPeriodo(models.Model):
_inherit = 'skeen.membresia.periodo'
def _cobro_confirmado(self, enlace, pago):
"""Marca el periodo pagado y renueva la membresía (avanza el ciclo)."""
self.ensure_one()
vals = {'estado': 'pagado'}
if pago and pago.venta_id:
vals['venta_id'] = pago.venta_id.id
self.write(vals)
m = self.membresia_id
if self.tipo == 'periodo' and m.proximo_cobro == self.periodo_inicio:
m.write({
'estado': 'activa',
'periodo_actual_inicio': self.periodo_inicio,
'periodo_actual_fin': self.periodo_fin,
'proximo_cobro': self.periodo_fin + relativedelta(days=1),
'sesiones_usadas_periodo': 0,
})
m._post('Periodo pagado en línea (%s): %s a %s.' % (
enlace.name or '', self.periodo_inicio, self.periodo_fin))
return True
def _cobro_expirado(self, enlace):
"""El enlace venció sin pago: el periodo queda vencido."""
self.ensure_one()
if self.estado == 'pendiente':
self.write({'estado': 'vencido'})
self.membresia_id._post('Enlace de cobro vencido (%s) para el periodo %s a %s.' % (
enlace.name or '', self.periodo_inicio, self.periodo_fin))
return True
class SkeenMembresiaService(models.AbstractModel):
_inherit = 'skeen.membresia.service'
@api.model
def _cobro_automatico(self, cfg):
"""Motor del cobro automático. Devuelve cuántos enlaces creó/avisó."""
ca = cfg.get('cobro_automatico') or get_cobro_automatico(self.env)
if not ca.get('activo'):
return 0
if (ca.get('metodo') or 'enlace') == 'tarjeta_guardada':
return self._cobro_tarjeta_guardada(ca)
return self._cobro_por_enlace(ca)
# ------------------------------------------------------------------
# Método 'enlace'
# ------------------------------------------------------------------
@api.model
def _cobro_por_enlace(self, ca):
hoy = fields.Date.today()
dias_antes = int(ca.get('dias_antes') or 3)
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
gracia = int(ca.get('dias_gracia') or 0)
Membresia = self.env['skeen.membresia'].sudo()
creados = 0
for m in Membresia.search([('estado', '=', 'activa')]):
if not m.proximo_cobro:
continue
faltan = (m.proximo_cobro - hoy).days
if faltan > dias_antes:
continue
try:
creados += self._asegurar_enlace_periodo(m, ca)
except Exception:
# Una membresía sin servicio de cobro (u otro dato faltante) no
# debe detener el resto del proceso.
_logger.exception('Cobro automático: falló la membresía %s', m.id)
return creados
@api.model
def _asegurar_enlace_periodo(self, m, ca):
"""Crea (si falta) el enlace del próximo periodo y aplica los avisos."""
hoy = fields.Date.today()
proximo = m.proximo_cobro
delta = _delta_periodo(m.plan_id.periodicidad)
fin = proximo + delta - relativedelta(days=1)
periodo = m._crear_periodo_pendiente(proximo, fin)
if not periodo or periodo.estado == 'pagado':
return 0
svc = self.env['skeen.cobro.service'].sudo()
enlace = self.env['skeen.cobro.enlace'].sudo().search([
('origen', '=', 'membresia'), ('periodo_id', '=', periodo.id),
('estado', '=', 'pendiente')], limit=1)
creado = 0
if not enlace:
anterior = self.env['skeen.cobro.enlace'].sudo().search([
('origen', '=', 'membresia'), ('periodo_id', '=', periodo.id),
('estado', '=', 'pagado')], limit=1)
if anterior:
return 0
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
ultimo = max(reintentos) if reintentos else 0
ttl_horas = max(24, ((proximo + timedelta(days=ultimo + int(ca.get('dias_gracia') or 0) + 2)
- hoy).days or 1) * 24)
enlace = svc.crear(
'membresia', periodo.monto,
'Membresía %s (%s a %s)' % (m.plan_id.name, proximo, fin),
ttl_horas=ttl_horas, partner=m.partner_id, moneda=periodo.moneda)
enlace.write({
'periodo_id': periodo.id,
'venta_id': periodo.venta_id.id if periodo.venta_id else False,
})
svc.intentar_checkout(enlace)
creado = 1
self._avisar_enlace(m, enlace, ca, periodo)
return creado
@api.model
def _hitos_aviso(self, proximo_cobro, ca):
"""Fechas en que toca avisar: inicial (dias_antes) + reintentos."""
dias_antes = int(ca.get('dias_antes') or 3)
hitos = [proximo_cobro - timedelta(days=dias_antes)]
for d in (ca.get('reintentos_dias') or []):
hitos.append(proximo_cobro + timedelta(days=int(d)))
return hitos
@api.model
def _avisar_enlace(self, m, enlace, ca, periodo):
"""Encola el aviso que toque (inicial o reintento) sin duplicar."""
hoy = fields.Date.today()
hitos = self._hitos_aviso(m.proximo_cobro, ca)
objetivo = None
for i, fecha in enumerate(hitos):
if hoy >= fecha and (enlace.recordatorios or 0) <= i:
objetivo = i
break
if objetivo is None:
return 0
self._encolar_aviso_cobro(m, enlace, periodo, objetivo)
enlace.write({
'recordatorios': objetivo + 1,
'ultimo_envio': fields.Datetime.now(),
'enviado_at': enlace.enviado_at or fields.Datetime.now(),
})
# Escalada cuando ya se agotaron los avisos.
if (enlace.recordatorios or 0) >= len(hitos):
self._escalar_sin_pago(m, enlace, periodo, ca)
return 1
@api.model
def _encolar_aviso_cobro(self, m, enlace, periodo, hito):
try:
service = self.env['skeen.notificacion.service'].sudo()
except Exception:
return False
clave = 'cobro_mem:%s:%s' % (periodo.id, hito)
try:
service.encolar(
partner=m.partner_id, tipo='cobro_membresia',
cuando=fields.Datetime.now(), ventana=str(hito),
clave_unica=clave,
contexto_extra={
'enlace': enlace.url or '',
'monto': '%s %s' % (enlace.currency_id.name or '',
format(enlace.monto, ',.2f')),
'plan': m.plan_id.name,
'periodo': '%s a %s' % (periodo.periodo_inicio, periodo.periodo_fin),
'membresia': m.folio or '',
'fecha': (m.proximo_cobro.strftime('%d/%m/%Y')
if m.proximo_cobro else ''),
})
return True
except Exception:
_logger.exception('No se pudo encolar el aviso de cobro de membresía')
return False
@api.model
def _escalar_sin_pago(self, m, enlace, periodo, ca):
"""Comportamiento al agotar los reintentos.
Si `cancelar_si_no_paga` está encendido, al pasar los reintentos + días
de gracia del cobro se marca el periodo vencido y la membresía vencida de
inmediato. Si está apagado, el periodo queda pendiente y es el proceso
normal de vencimiento (`_marcar_vencidas`, con `dias_gracia` de
membresías) el que la vence unos días después.
"""
if not ca.get('cancelar_si_no_paga'):
return False
hoy = fields.Date.today()
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
ultimo = max(reintentos) if reintentos else 0
limite = m.proximo_cobro + timedelta(days=ultimo + int(ca.get('dias_gracia') or 0))
if hoy <= limite:
return False
if periodo.estado == 'pendiente':
periodo.write({'estado': 'vencido'})
if m.estado == 'activa':
m.write({'estado': 'vencida'})
m._post('Membresía vencida por falta de pago del periodo (cobro automático).')
return True
# ------------------------------------------------------------------
# Método 'tarjeta_guardada' (requiere Stripe real)
# ------------------------------------------------------------------
@api.model
def _cobro_tarjeta_guardada(self, ca):
"""Cargo sin presencia del cliente con tarjeta guardada (Stripe).
Camino técnico dejado INERTE y documentado: sin `skeen.stripe_secret_key`
y sin un método de pago guardado del cliente no hay nada que cobrar. No
se simula: si no hay llaves, no se hace nada (ni cargos ni avisos).
"""
ICP = self.env['ir.config_parameter'].sudo()
if not ICP.get_param('skeen.stripe_secret_key'):
_logger.info('Cobro automático con tarjeta_guardada: sin Stripe configurado, inerte.')
return 0
# A partir de aquí requeriría tarjetas guardadas por cliente (Stripe
# Customer + PaymentMethod) que hoy no se persisten. Se deja el punto de
# extensión explícito y no se marca nada como cobrado.
_logger.info('Cobro automático con tarjeta_guardada: no hay tarjetas guardadas; inerte.')
return 0

View File

@@ -688,7 +688,8 @@ class SkeenMembresiaService(models.AbstractModel):
@api.model
def _cron_tick(self):
"""Corre la lógica diaria. Con `membresias.activo=False` no hace nada."""
resultado = {'vencidas': 0, 'avisos': 0, 'cargos': 0, 'descongeladas': 0}
resultado = {'vencidas': 0, 'avisos': 0, 'cargos': 0, 'descongeladas': 0,
'cobros': 0}
cfg = self._cfg()
if not cfg.get('activo'):
return resultado
@@ -696,6 +697,7 @@ class SkeenMembresiaService(models.AbstractModel):
resultado['vencidas'] = self._marcar_vencidas(cfg)
resultado['avisos'] = self._encolar_avisos(cfg)
resultado['cargos'] = self._generar_cargos(cfg)
resultado['cobros'] = self._cobro_automatico(cfg)
if any(resultado.values()):
_logger.info('SKEEN membresías: %s', resultado)
return resultado

View File

@@ -10,7 +10,8 @@
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base', 'skeen_citas', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas'],
'depends': ['base', 'skeen_citas', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas',
'skeen_cobros'],
'data': [
'security/ir.model.access.csv',
'data/roles.xml',

View File

@@ -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
# ============================================================

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',

View File

@@ -45,6 +45,7 @@ TIPOS = [
('recuperacion', 'Recuperación'),
('formulario', 'Formulario'),
('renovacion_membresia', 'Renovación de membresía'),
('cobro_membresia', 'Cobro de membresía'),
('manual', 'Manual'),
]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
@@ -70,6 +71,8 @@ PLANTILLA_POR_TIPO = {
'formulario': 'plantilla_formulario',
# Aviso de renovación/cobro de una membresía (bloque `membresias`).
'renovacion_membresia': 'plantilla_renovacion_membresia',
# Aviso con enlace de pago del periodo (cobro automático de membresías).
'cobro_membresia': 'plantilla_cobro_membresia',
'manual': 'plantilla_recordatorio',
}

View File

@@ -121,6 +121,12 @@ PERMISSION_GROUPS = [
('comisiones.editar', 'Administrar reglas y metas de comisiones'),
('comisiones.liquidar', 'Generar, ajustar y pagar liquidaciones'),
]),
('cobros', 'Cobros y enlaces de pago', [
('cobros.ver', 'Ver enlaces de cobro pendientes y pagados'),
('cobros.administrar', 'Administrar enlaces de cobro'),
('cobros.forzar', 'Forzar el cobro de un enlace'),
('cobros.cancelar', 'Cancelar enlaces y liberar retenciones'),
]),
('config', 'Usuarios y configuración', [
('config.usuarios', 'Administrar usuarios'),
('config.roles', 'Administrar roles y menús'),
@@ -158,6 +164,7 @@ READ_PERM_KEYS = (
'compras.ver',
'proveedores.ver',
'comisiones.ver',
'cobros.ver',
)
# ---------------------------------------------------------------------------
@@ -273,6 +280,13 @@ DEFAULT_PERM_ROLES = {
'comisiones.ver': ALL,
'comisiones.editar': AG,
'comisiones.liquidar': AG,
# Cobros y enlaces de pago. Ver es de todos; administrar (crear/cancelar) es
# de admin/gerencia; forzar el cobro (registrar un pago sin pasarela) lo
# hace recepción/gerencia/admin; cancelar retenciones, admin/gerencia.
'cobros.ver': ALL,
'cobros.administrar': AG,
'cobros.forzar': ('admin', 'recepcion', 'gerencia'),
'cobros.cancelar': AG,
# Usuarios y configuración
'config.usuarios': AG,
'config.roles': AG,