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:
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>
|
||||
Reference in New Issue
Block a user