Files
SKEEN-Proyecto/odoo-addons/skeen_cobros/models/cobro_enlace.py

625 lines
26 KiB
Python

# -*- 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