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