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