625 lines
26 KiB
Python
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
|