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:
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
4
odoo-addons/skeen_cobros/__init__.py
Normal file
4
odoo-addons/skeen_cobros/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
38
odoo-addons/skeen_cobros/__manifest__.py
Normal file
38
odoo-addons/skeen_cobros/__manifest__.py
Normal 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',
|
||||
}
|
||||
3
odoo-addons/skeen_cobros/controllers/__init__.py
Normal file
3
odoo-addons/skeen_cobros/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import public_cobro
|
||||
356
odoo-addons/skeen_cobros/controllers/public_cobro.py
Normal file
356
odoo-addons/skeen_cobros/controllers/public_cobro.py
Normal file
@@ -0,0 +1,356 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Página pública de pago de un enlace de cobro (sin sesión).
|
||||
|
||||
Endpoints anónimos bajo `/skeen/frontend/v1/public/cobro`:
|
||||
|
||||
GET /cobro?token=... datos mínimos y no sensibles del enlace
|
||||
POST /cobro/pagar confirma el pago (simulado o pasarela real)
|
||||
POST /cobro/webhook/stripe notificación de Stripe (firma verificada)
|
||||
POST /cobro/webhook/mercadopago notificación de MercadoPago (secreto/token)
|
||||
|
||||
Reglas de oro:
|
||||
- El token es HMAC firmado con caducidad; sin token válido no se expone nada.
|
||||
- El pago en modo `simulado` SOLO se permite si el ajuste correspondiente
|
||||
(`reservas_online.anticipo.permitir_simulado` o
|
||||
`membresias.cobro_automatico.permitir_simulado`) está encendido. Nunca se
|
||||
simula un pago en producción con la configuración por defecto.
|
||||
- Límite de peticiones por IP y trampa antiautómatas (honeypot) como en el
|
||||
portal de reservas.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
|
||||
from odoo.addons.skeen_citas.clinic_settings import (
|
||||
get_settings as get_clinic_settings,
|
||||
get_anticipo,
|
||||
get_cobro_automatico,
|
||||
brand_dict,
|
||||
)
|
||||
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
|
||||
|
||||
PREFIX = '/skeen/frontend/v1/public/cobro'
|
||||
|
||||
RATE_LIMITS = {'get': (60, 60), 'pagar': (10, 60), 'webhook': (120, 60)}
|
||||
_RATE_LOCK = threading.Lock()
|
||||
_RATE_HITS = {}
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_ERROR_INTERNO = 'No se pudo procesar la solicitud. Intenta de nuevo.'
|
||||
|
||||
|
||||
def _json(data, status=200):
|
||||
return Response(
|
||||
json.dumps(data, default=str),
|
||||
status=status,
|
||||
mimetype='application/json',
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Stripe-Signature',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _client_ip():
|
||||
xff = request.httprequest.headers.get('X-Forwarded-For') or ''
|
||||
if xff:
|
||||
return xff.split(',')[0].strip()
|
||||
return request.httprequest.remote_addr or 'desconocida'
|
||||
|
||||
|
||||
def _rate_limited(bucket):
|
||||
limit, window = RATE_LIMITS.get(bucket, (30, 60))
|
||||
key = (_client_ip(), bucket)
|
||||
ahora = time.time()
|
||||
with _RATE_LOCK:
|
||||
hits = [t for t in _RATE_HITS.get(key, []) if ahora - t < window]
|
||||
if len(hits) >= limit:
|
||||
_RATE_HITS[key] = hits
|
||||
return True
|
||||
hits.append(ahora)
|
||||
_RATE_HITS[key] = hits
|
||||
return False
|
||||
|
||||
|
||||
class SkeenPublicCobroController(http.Controller):
|
||||
"""Página pública de pago de un enlace."""
|
||||
|
||||
def _service(self):
|
||||
return request.env['skeen.cobro.service'].sudo()
|
||||
|
||||
def _settings(self):
|
||||
return get_clinic_settings(request.env)
|
||||
|
||||
def _brand(self):
|
||||
guardado = brand_dict(request.env)
|
||||
out = {
|
||||
'nombre': self._settings().get('nombre') or 'Clínica',
|
||||
'logo_claro': '/brand/logo-blanco.svg',
|
||||
'logo_oscuro': '/brand/logo-negro.svg',
|
||||
'colores': {'primario': '', 'acento': ''},
|
||||
}
|
||||
if isinstance(guardado, dict):
|
||||
if guardado.get('nombre'):
|
||||
out['nombre'] = str(guardado['nombre'])
|
||||
for k in ('logo_claro', 'logo_oscuro'):
|
||||
if guardado.get(k):
|
||||
out[k] = str(guardado[k])
|
||||
colores = guardado.get('colores')
|
||||
if isinstance(colores, dict):
|
||||
for c in ('primario', 'acento'):
|
||||
if colores.get(c):
|
||||
out['colores'][c] = str(colores[c])
|
||||
return out
|
||||
|
||||
def _simulado_permitido(self, enlace):
|
||||
try:
|
||||
if enlace.origen == 'reserva':
|
||||
return bool(get_anticipo(request.env).get('permitir_simulado'))
|
||||
if enlace.origen == 'membresia':
|
||||
return bool(get_cobro_automatico(request.env).get('permitir_simulado'))
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _estado_label(enlace):
|
||||
return dict(enlace._fields['estado'].selection).get(enlace.estado, enlace.estado)
|
||||
|
||||
def _enlace_de_token(self, token):
|
||||
return self._service().verificar_token(token)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Datos del enlace (mínimos, no sensibles)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def cobro_get(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if _rate_limited('get'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||
try:
|
||||
enlace = self._enlace_de_token(kw.get('token'))
|
||||
if not enlace:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Enlace no válido o expirado'}, 404)
|
||||
settings = self._settings()
|
||||
_pack, terms = resolver_glosario(request.env)
|
||||
proveedor = self._service().proveedor_disponible()
|
||||
puede_pagar = (enlace.estado == 'pendiente' and
|
||||
(proveedor != 'simulado' or self._simulado_permitido(enlace)))
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'folio': enlace.name,
|
||||
'concepto': enlace.concepto,
|
||||
'monto': enlace.monto,
|
||||
'moneda': enlace.currency_id.name or 'MXN',
|
||||
'estado': enlace.estado,
|
||||
'estado_label': self._estado_label(enlace),
|
||||
'origen': enlace.origen,
|
||||
'proveedor': enlace.proveedor,
|
||||
'expira': enlace.token_expira,
|
||||
'fecha_pago': enlace.fecha_pago,
|
||||
'paciente': (enlace.partner_id.name or '').split(' ')[0] if enlace.partner_id else '',
|
||||
'puede_pagar': bool(puede_pagar),
|
||||
'simulado': enlace.proveedor == 'simulado',
|
||||
'url_externa': enlace.url_externa or '',
|
||||
'brand': self._brand(),
|
||||
'terms': terms,
|
||||
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('Error consultando enlace de cobro: %s', exc)
|
||||
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Pagar
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/pagar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def cobro_pagar(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if _rate_limited('pagar'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||
try:
|
||||
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||
except Exception:
|
||||
data = {}
|
||||
if str(data.get('hp') or '').strip():
|
||||
return _json({'status': 'error', 'message': 'Solicitud rechazada'}, 400)
|
||||
try:
|
||||
enlace = self._enlace_de_token(data.get('token') or kw.get('token'))
|
||||
if not enlace:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Enlace no válido o expirado'}, 404)
|
||||
if enlace.estado == 'pagado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este enlace ya fue pagado.'}, 409)
|
||||
if enlace.estado == 'expirado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este enlace expiró.'}, 410)
|
||||
if enlace.estado == 'cancelado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este enlace fue cancelado.'}, 409)
|
||||
|
||||
proveedor = self._service().proveedor_disponible()
|
||||
if proveedor != 'simulado':
|
||||
# Camino real: se crea/recupera el checkout de la pasarela.
|
||||
if not enlace.url_externa:
|
||||
self._service().intentar_checkout(enlace)
|
||||
if enlace.url_externa:
|
||||
return _json({'status': 'success', 'requiere_pasarela': True,
|
||||
'url': enlace.url_externa, 'proveedor': proveedor})
|
||||
return _json({'status': 'error',
|
||||
'message': 'La pasarela no está disponible. Contacta a la clínica.'}, 502)
|
||||
|
||||
# Camino simulado (sin pasarela).
|
||||
if not self._simulado_permitido(enlace):
|
||||
return _json({'status': 'error',
|
||||
'message': 'El pago en línea no está disponible. '
|
||||
'Contacta a la clínica.'}, 403)
|
||||
pago = self._service().confirmar(
|
||||
enlace, proveedor='simulado', referencia='SIMULADO-%s' % enlace.id,
|
||||
response='Pago simulado desde la página pública', user='Portal en línea')
|
||||
if not pago:
|
||||
return _json({'status': 'error',
|
||||
'message': 'No se pudo confirmar el pago.'}, 409)
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'ok': True,
|
||||
'estado': 'pagado',
|
||||
'estado_label': self._estado_label(enlace),
|
||||
'comprobante': {
|
||||
'folio': enlace.name,
|
||||
'concepto': enlace.concepto,
|
||||
'monto': enlace.monto,
|
||||
'moneda': enlace.currency_id.name or 'MXN',
|
||||
'fecha': enlace.fecha_pago,
|
||||
'proveedor': 'simulado',
|
||||
'pago': pago.name,
|
||||
},
|
||||
})
|
||||
except Exception as exc:
|
||||
request.env.cr.rollback()
|
||||
_logger.exception('Error pagando enlace de cobro: %s', exc)
|
||||
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Webhooks (notificación de la pasarela)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/webhook/stripe', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def webhook_stripe(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
secret = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'skeen.stripe_webhook_secret')
|
||||
raw = request.httprequest.get_data() or b''
|
||||
if not secret:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Webhook de Stripe no configurado'}, 400)
|
||||
firma = request.httprequest.headers.get('Stripe-Signature') or ''
|
||||
if not self._stripe_firma_valida(raw, firma, secret):
|
||||
return _json({'status': 'error', 'message': 'Firma inválida'}, 400)
|
||||
try:
|
||||
evento = json.loads(raw.decode('utf-8') or '{}')
|
||||
except Exception:
|
||||
return _json({'status': 'error', 'message': 'Cuerpo inválido'}, 400)
|
||||
tipo = evento.get('type') or ''
|
||||
obj = ((evento.get('data') or {}).get('object') or {})
|
||||
if tipo in ('checkout.session.completed', 'payment_intent.succeeded'):
|
||||
meta = obj.get('metadata') or {}
|
||||
enlace_id = int(meta.get('enlace_id') or 0)
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||
if enlace.exists():
|
||||
self._service().confirmar(
|
||||
enlace, proveedor='stripe',
|
||||
referencia=obj.get('id') or obj.get('payment_intent') or '',
|
||||
response=json.dumps(obj)[:2000], user='Webhook Stripe')
|
||||
return _json({'status': 'success'})
|
||||
|
||||
@staticmethod
|
||||
def _stripe_firma_valida(raw, header, secret):
|
||||
partes = {}
|
||||
for item in (header or '').split(','):
|
||||
if '=' in item:
|
||||
k, v = item.split('=', 1)
|
||||
partes.setdefault(k.strip(), v.strip())
|
||||
t = partes.get('t')
|
||||
v1 = partes.get('v1')
|
||||
if not t or not v1:
|
||||
return False
|
||||
firmado = ('%s.%s' % (t, raw.decode('utf-8'))).encode('utf-8')
|
||||
esperada = hmac.new(secret.encode('utf-8'), firmado, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(esperada, v1)
|
||||
|
||||
@http.route(PREFIX + '/webhook/mercadopago', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def webhook_mercadopago(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
token = ICP.get_param('skeen.mercadopago_access_token')
|
||||
secret = ICP.get_param('skeen.mercadopago_webhook_secret')
|
||||
raw = request.httprequest.get_data() or b''
|
||||
try:
|
||||
cuerpo = json.loads(raw.decode('utf-8') or '{}')
|
||||
except Exception:
|
||||
cuerpo = {}
|
||||
data_id = (cuerpo.get('data') or {}).get('id') or request.httprequest.args.get('data.id') or ''
|
||||
tipo = cuerpo.get('type') or request.httprequest.args.get('type') or ''
|
||||
if not token and not secret:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Webhook de MercadoPago no configurado'}, 400)
|
||||
# Verificación por secreto (x-signature) cuando está configurado.
|
||||
if secret:
|
||||
firma = request.httprequest.headers.get('x-signature') or ''
|
||||
if not self._mp_firma_valida(request.httprequest.args.get('data.id') or '', firma, secret):
|
||||
return _json({'status': 'error', 'message': 'Firma inválida'}, 400)
|
||||
elif tipo != 'payment' or not data_id:
|
||||
return _json({'status': 'error', 'message': 'Notificación inválida'}, 400)
|
||||
# Con token, se confirma el pago real consultando la API de MercadoPago.
|
||||
if tipo == 'payment' and data_id and token:
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get('https://api.mercadopago.com/v1/payments/%s' % data_id,
|
||||
headers={'Authorization': 'Bearer %s' % token}, timeout=30)
|
||||
pago = resp.json() if resp.status_code == 200 else {}
|
||||
estado = pago.get('status')
|
||||
ext = (pago.get('metadata') or {}).get('enlace_id')
|
||||
if estado == 'approved' and ext:
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(int(ext))
|
||||
if enlace.exists():
|
||||
self._service().confirmar(
|
||||
enlace, proveedor='mercadopago', referencia=str(data_id),
|
||||
response=json.dumps(pago)[:2000], user='Webhook MercadoPago')
|
||||
except Exception as exc:
|
||||
_logger.warning('Webhook MercadoPago: %s', exc)
|
||||
return _json({'status': 'error', 'message': 'No se pudo verificar'}, 502)
|
||||
return _json({'status': 'success'})
|
||||
|
||||
@staticmethod
|
||||
def _mp_firma_valida(data_id, header, secret):
|
||||
partes = {}
|
||||
for item in (header or '').split(','):
|
||||
if '=' in item:
|
||||
k, v = item.split('=', 1)
|
||||
partes[k.strip()] = v.strip()
|
||||
ts = partes.get('ts')
|
||||
v1 = partes.get('v1')
|
||||
if not ts or not v1:
|
||||
return False
|
||||
manifiesto = 'id:%s;ts:%s;' % (data_id, ts)
|
||||
esperada = hmac.new(secret.encode('utf-8'), manifiesto.encode('utf-8'),
|
||||
hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(esperada, v1)
|
||||
12
odoo-addons/skeen_cobros/data/cobro_sequence.xml
Normal file
12
odoo-addons/skeen_cobros/data/cobro_sequence.xml
Normal 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>
|
||||
20
odoo-addons/skeen_cobros/data/cobros_cron.xml
Normal file
20
odoo-addons/skeen_cobros/data/cobros_cron.xml
Normal 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>
|
||||
3
odoo-addons/skeen_cobros/models/__init__.py
Normal file
3
odoo-addons/skeen_cobros/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import cobro_enlace
|
||||
624
odoo-addons/skeen_cobros/models/cobro_enlace.py
Normal file
624
odoo-addons/skeen_cobros/models/cobro_enlace.py
Normal 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
|
||||
3
odoo-addons/skeen_cobros/security/ir.model.access.csv
Normal file
3
odoo-addons/skeen_cobros/security/ir.model.access.csv
Normal 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
|
||||
|
77
odoo-addons/skeen_cobros/views/cobro_views.xml
Normal file
77
odoo-addons/skeen_cobros/views/cobro_views.xml
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import membresia
|
||||
from . import venta
|
||||
from . import cobro
|
||||
|
||||
250
odoo-addons/skeen_membresias/models/cobro.py
Normal file
250
odoo-addons/skeen_membresias/models/cobro.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1921,6 +1921,11 @@ class SkeenFrontendController(http.Controller):
|
||||
'payment_state': cita.payment_state,
|
||||
'price': cita.servicio_price,
|
||||
'amount_paid': cita.amount_paid,
|
||||
# Anticipo de reserva en línea (distinguir "esperando anticipo").
|
||||
'anticipo_estado': cita.anticipo_estado or 'ninguno',
|
||||
'anticipo_estado_label': dict(cita._fields['anticipo_estado'].selection).get(
|
||||
cita.anticipo_estado, cita.anticipo_estado),
|
||||
'anticipo_monto': cita.anticipo_monto or 0.0,
|
||||
# Duración efectiva (propia de la cita o la del servicio)
|
||||
'duration': cita._duracion(),
|
||||
'duration_override': cita.duration or 0,
|
||||
@@ -5423,6 +5428,146 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Cobros y enlaces de pago
|
||||
# ============================================================
|
||||
def _cobro_to_dict(self, e):
|
||||
return {
|
||||
'id': e.id,
|
||||
'nombre': e.name,
|
||||
'origen': e.origen,
|
||||
'origen_label': dict(e._fields['origen'].selection).get(e.origen, e.origen),
|
||||
'estado': e.estado,
|
||||
'estado_label': dict(e._fields['estado'].selection).get(e.estado, e.estado),
|
||||
'proveedor': e.proveedor,
|
||||
'partner_id': e.partner_id.id if e.partner_id else None,
|
||||
'patient': e.partner_id.name if e.partner_id else '',
|
||||
'cita_id': e.cita_id.id if e.cita_id else None,
|
||||
'folio_cita': e.cita_id.name if e.cita_id else '',
|
||||
'venta_id': e.venta_id.id if e.venta_id else None,
|
||||
'concepto': e.concepto or '',
|
||||
'monto': e.monto,
|
||||
'moneda': e.currency_id.name or 'MXN',
|
||||
'creado': e.create_date.strftime('%Y-%m-%d %H:%M') if e.create_date else None,
|
||||
'expira': e.token_expira.strftime('%Y-%m-%d %H:%M') if e.token_expira else None,
|
||||
'fecha_pago': e.fecha_pago.strftime('%Y-%m-%d %H:%M') if e.fecha_pago else None,
|
||||
'recordatorios': e.recordatorios,
|
||||
'url': e.url or '',
|
||||
}
|
||||
|
||||
@http.route('/skeen/frontend/v1/cobros', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('cobros.ver')
|
||||
def list_cobros(self, **kw):
|
||||
"""Listado de enlaces de cobro (pendientes, pagados, expirados)."""
|
||||
try:
|
||||
domain = []
|
||||
if kw.get('estado'):
|
||||
domain.append(('estado', '=', kw.get('estado')))
|
||||
if kw.get('origen'):
|
||||
domain.append(('origen', '=', kw.get('origen')))
|
||||
enlaces = request.env['skeen.cobro.enlace'].sudo().search(
|
||||
domain, order='create_date desc', limit=200)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'cobros': [self._cobro_to_dict(e) for e in enlaces],
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/cobros/<int:enlace_id>/forzar', type='http',
|
||||
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('cobros.forzar')
|
||||
@forbid_roles('marketing')
|
||||
def forzar_cobro(self, enlace_id, **kw):
|
||||
"""Registra el pago de un enlace sin pasar por la pasarela."""
|
||||
try:
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||
if not enlace.exists():
|
||||
return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404)
|
||||
if enlace.estado == 'pagado':
|
||||
return json_response({'status': 'error', 'message': 'El enlace ya está pagado'}, 409)
|
||||
user = current_user(request.env)
|
||||
data = _parse_json_body()
|
||||
pago = request.env['skeen.cobro.service'].sudo().confirmar(
|
||||
enlace, proveedor=(data.get('proveedor') or enlace.proveedor or 'simulado'),
|
||||
referencia=(data.get('referencia') or 'FORZADO'),
|
||||
response='Forzado desde el panel', user=(user.login if user else 'staff'),
|
||||
force=True)
|
||||
if not pago:
|
||||
return json_response({'status': 'error', 'message': 'No se pudo registrar el pago'}, 409)
|
||||
self._auditar('cobros', 'cobro', str(enlace.id),
|
||||
new_value='forzado', entity_label=enlace.concepto or enlace.name,
|
||||
notes='Pago forzado')
|
||||
return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace),
|
||||
'pago': pago.name})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/cobros/<int:enlace_id>/cancelar', type='http',
|
||||
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('cobros.cancelar')
|
||||
def cancelar_cobro(self, enlace_id, **kw):
|
||||
"""Cancela un enlace pendiente y libera la retención (cita/periodo)."""
|
||||
try:
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||
if not enlace.exists():
|
||||
return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404)
|
||||
if enlace.estado != 'pendiente':
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Solo se pueden cancelar enlaces pendientes'}, 409)
|
||||
data = _parse_json_body()
|
||||
user = current_user(request.env)
|
||||
enlace.action_cancelar(motivo=(data.get('motivo') or 'Cancelado desde el panel'),
|
||||
user=(user.login if user else 'staff'))
|
||||
return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/reports/cobros', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('reportes.ver')
|
||||
def report_cobros(self, **kw):
|
||||
"""Reporte de cobros pendientes y expirados (anticipos y membresías)."""
|
||||
try:
|
||||
start = kw.get('start')
|
||||
end = kw.get('end')
|
||||
domain = [('estado', 'in', ('pendiente', 'expirado', 'cancelado'))]
|
||||
if start:
|
||||
domain.append(('create_date', '>=', f'{start} 00:00:00'))
|
||||
if end:
|
||||
domain.append(('create_date', '<=', f'{end} 23:59:59'))
|
||||
enlaces = request.env['skeen.cobro.enlace'].sudo().search(domain,
|
||||
order='estado, token_expira')
|
||||
ahora = fields.Datetime.now()
|
||||
filas = []
|
||||
resumen = {'pendiente': 0.0, 'expirado': 0.0, 'cancelado': 0.0}
|
||||
for e in enlaces:
|
||||
vencido = bool(e.estado == 'pendiente' and e.token_expira and e.token_expira < ahora)
|
||||
filas.append({
|
||||
**self._cobro_to_dict(e),
|
||||
'vencido': vencido,
|
||||
'dias_para_vencer': ((e.token_expira - ahora).days
|
||||
if e.estado == 'pendiente' and e.token_expira else 0),
|
||||
})
|
||||
if e.estado in resumen:
|
||||
resumen[e.estado] += e.monto
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'start': start, 'end': end,
|
||||
'cobros': filas,
|
||||
'total': len(filas),
|
||||
'resumen': resumen,
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Cortes de Caja
|
||||
# ============================================================
|
||||
|
||||
@@ -333,9 +333,24 @@ class SkeenPublicBookingController(http.Controller):
|
||||
'descripcion': cfg['descripcion'],
|
||||
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
||||
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
|
||||
'anticipo': self._anticipo_publico(cfg),
|
||||
},
|
||||
})
|
||||
|
||||
def _anticipo_publico(self, cfg):
|
||||
"""Sub-bloque de anticipo expuesto al portal (sin datos sensibles)."""
|
||||
a = cfg.get('anticipo') or {}
|
||||
tipo = a.get('tipo') or 'porcentaje'
|
||||
return {
|
||||
'activo': bool(a.get('activo')) and float(a.get('valor') or 0) > 0,
|
||||
'tipo': tipo,
|
||||
'valor': float(a.get('valor') or 0),
|
||||
'moneda': a.get('moneda') or 'MXN',
|
||||
'horas_para_pagar': int(a.get('horas_para_pagar') or 24),
|
||||
'reembolsable': bool(a.get('reembolsable', True)),
|
||||
'texto_aviso': a.get('texto_aviso') or '',
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Disponibilidad de horarios
|
||||
# ------------------------------------------------------------------
|
||||
@@ -400,6 +415,21 @@ class SkeenPublicBookingController(http.Controller):
|
||||
except Exception as e:
|
||||
return _error_interno(e)
|
||||
|
||||
def _monto_anticipo(self, anticipo, servicio):
|
||||
"""Monto del anticipo en moneda de la clínica (0 si no aplica)."""
|
||||
if not isinstance(anticipo, dict) or not anticipo.get('activo'):
|
||||
return 0.0
|
||||
try:
|
||||
valor = float(anticipo.get('valor') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
if valor <= 0:
|
||||
return 0.0
|
||||
if (anticipo.get('tipo') or 'porcentaje') == 'porcentaje':
|
||||
precio = float(getattr(servicio, 'price', 0) or 0)
|
||||
return round(precio * valor / 100.0, 2)
|
||||
return round(valor, 2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Crear la reserva
|
||||
# ------------------------------------------------------------------
|
||||
@@ -523,6 +553,13 @@ class SkeenPublicBookingController(http.Controller):
|
||||
creado_paciente = True
|
||||
|
||||
state = 'pending' if cfg['requiere_confirmacion'] else 'confirmed'
|
||||
# Anticipo: si está encendido y hay monto, la cita queda RETENIDA
|
||||
# (state pending) esperando el pago, aunque no se exija confirmación.
|
||||
anticipo = cfg.get('anticipo') or {}
|
||||
monto_anticipo = self._monto_anticipo(anticipo, servicio)
|
||||
con_anticipo = bool(anticipo.get('activo')) and monto_anticipo > 0
|
||||
if con_anticipo:
|
||||
state = 'pending'
|
||||
nota = ORIGEN_MARCA
|
||||
if notas:
|
||||
nota = '%s\n%s' % (ORIGEN_MARCA, notas)
|
||||
@@ -537,6 +574,9 @@ class SkeenPublicBookingController(http.Controller):
|
||||
'notes': nota,
|
||||
'booking_source': 'public_portal',
|
||||
}
|
||||
if con_anticipo:
|
||||
cita_vals['anticipo_estado'] = 'pendiente'
|
||||
cita_vals['anticipo_monto'] = monto_anticipo
|
||||
if doctor_id:
|
||||
cita_vals['doctor_id'] = doctor_id
|
||||
|
||||
@@ -559,10 +599,30 @@ class SkeenPublicBookingController(http.Controller):
|
||||
pass
|
||||
|
||||
# Hook Fase 2.4: aviso de confirmación por WhatsApp/WACRM.
|
||||
if cfg['requiere_confirmacion']:
|
||||
# Con anticipo NO se confirma todavía: se avisa al pagarse.
|
||||
enlace = None
|
||||
if con_anticipo:
|
||||
try:
|
||||
svc = request.env['skeen.cobro.service'].sudo()
|
||||
enlace = svc.crear(
|
||||
'reserva', monto_anticipo,
|
||||
'Anticipo: %s' % (servicio.name or ''),
|
||||
ttl_horas=anticipo.get('horas_para_pagar') or 24,
|
||||
partner=partner, cita=cita,
|
||||
moneda=anticipo.get('moneda') or 'MXN')
|
||||
svc.intentar_checkout(enlace)
|
||||
cita.sudo().message_post(
|
||||
subject='Anticipo solicitado',
|
||||
body='Enlace %s por %s %s (vence %s).' % (
|
||||
enlace.name, enlace.currency_id.name or '',
|
||||
monto_anticipo, enlace.token_expira),
|
||||
message_type='comment')
|
||||
except Exception:
|
||||
_logger.exception('No se pudo crear el enlace de anticipo')
|
||||
elif cfg['requiere_confirmacion']:
|
||||
self._encolar_confirmacion(cita)
|
||||
|
||||
return _json({
|
||||
respuesta = {
|
||||
'ok': True,
|
||||
'folio': cita.name,
|
||||
'fecha': cita.date.strftime('%Y-%m-%d'),
|
||||
@@ -574,7 +634,21 @@ class SkeenPublicBookingController(http.Controller):
|
||||
'requiere_confirmacion': cfg['requiere_confirmacion'],
|
||||
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
||||
'token_de_gestion': self._sign_token(cita),
|
||||
}, 201)
|
||||
}
|
||||
if con_anticipo and enlace:
|
||||
respuesta['esperando_anticipo'] = True
|
||||
respuesta['mensaje_anticipo'] = anticipo.get('texto_aviso') or ''
|
||||
respuesta['anticipo'] = {
|
||||
'monto': monto_anticipo,
|
||||
'moneda': anticipo.get('moneda') or 'MXN',
|
||||
'horas_para_pagar': int(anticipo.get('horas_para_pagar') or 24),
|
||||
'reembolsable': bool(anticipo.get('reembolsable', True)),
|
||||
'url': enlace.url,
|
||||
'token': enlace.token,
|
||||
'expira': enlace.token_expira,
|
||||
'proveedor': enlace.proveedor,
|
||||
}
|
||||
return _json(respuesta, 201)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _error_interno(e)
|
||||
@@ -669,6 +743,12 @@ class SkeenPublicBookingController(http.Controller):
|
||||
cita.write({'cancellation_reason': motivo or 'Cancelada por el cliente en el portal'})
|
||||
cita.set_state('cancelled', user='Portal en línea',
|
||||
note=motivo or 'Cancelada en el portal público')
|
||||
try:
|
||||
svc = request.env['skeen.cobro.service'].sudo()
|
||||
svc.reembolsar_anticipo(cita)
|
||||
svc.cancelar_enlaces_de_cita(cita, 'Cita cancelada en el portal')
|
||||
except Exception:
|
||||
_logger.exception('No se pudo resolver el anticipo al cancelar la cita')
|
||||
try:
|
||||
cita.sudo().message_post(
|
||||
subject='Cancelación en línea',
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user