251 lines
10 KiB
Python
251 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Cobro automático de membresías con enlace de pago (bloque `cobro_automatico`).
|
|
|
|
Añade al enlace de cobro su liga con el periodo de membresía y el motor que,
|
|
desde el cron diario, genera el enlace del periodo, avisa por el motor de
|
|
notificaciones (con reintentos) y renueva la membresía al pagarse.
|
|
|
|
Con `membresias.cobro_automatico.activo=False` (default) NADA cambia: el cron
|
|
sigue generando solo el adeudo, sin enlaces ni avisos.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import api, fields, models, _
|
|
|
|
from odoo.addons.skeen_citas.clinic_settings import (
|
|
get_cobro_automatico,
|
|
get_membresias,
|
|
)
|
|
|
|
from .membresia import _delta_periodo
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SkeenCobroEnlace(models.Model):
|
|
_inherit = 'skeen.cobro.enlace'
|
|
|
|
periodo_id = fields.Many2one('skeen.membresia.periodo', string='Periodo de membresía',
|
|
index=True, ondelete='set null')
|
|
membresia_id = fields.Many2one(related='periodo_id.membresia_id',
|
|
string='Membresía', store=True)
|
|
|
|
|
|
class SkeenMembresiaPeriodo(models.Model):
|
|
_inherit = 'skeen.membresia.periodo'
|
|
|
|
def _cobro_confirmado(self, enlace, pago):
|
|
"""Marca el periodo pagado y renueva la membresía (avanza el ciclo)."""
|
|
self.ensure_one()
|
|
vals = {'estado': 'pagado'}
|
|
if pago and pago.venta_id:
|
|
vals['venta_id'] = pago.venta_id.id
|
|
self.write(vals)
|
|
m = self.membresia_id
|
|
if self.tipo == 'periodo' and m.proximo_cobro == self.periodo_inicio:
|
|
m.write({
|
|
'estado': 'activa',
|
|
'periodo_actual_inicio': self.periodo_inicio,
|
|
'periodo_actual_fin': self.periodo_fin,
|
|
'proximo_cobro': self.periodo_fin + relativedelta(days=1),
|
|
'sesiones_usadas_periodo': 0,
|
|
})
|
|
m._post('Periodo pagado en línea (%s): %s a %s.' % (
|
|
enlace.name or '', self.periodo_inicio, self.periodo_fin))
|
|
return True
|
|
|
|
def _cobro_expirado(self, enlace):
|
|
"""El enlace venció sin pago: el periodo queda vencido."""
|
|
self.ensure_one()
|
|
if self.estado == 'pendiente':
|
|
self.write({'estado': 'vencido'})
|
|
self.membresia_id._post('Enlace de cobro vencido (%s) para el periodo %s a %s.' % (
|
|
enlace.name or '', self.periodo_inicio, self.periodo_fin))
|
|
return True
|
|
|
|
|
|
class SkeenMembresiaService(models.AbstractModel):
|
|
_inherit = 'skeen.membresia.service'
|
|
|
|
@api.model
|
|
def _cobro_automatico(self, cfg):
|
|
"""Motor del cobro automático. Devuelve cuántos enlaces creó/avisó."""
|
|
ca = cfg.get('cobro_automatico') or get_cobro_automatico(self.env)
|
|
if not ca.get('activo'):
|
|
return 0
|
|
if (ca.get('metodo') or 'enlace') == 'tarjeta_guardada':
|
|
return self._cobro_tarjeta_guardada(ca)
|
|
return self._cobro_por_enlace(ca)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Método 'enlace'
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def _cobro_por_enlace(self, ca):
|
|
hoy = fields.Date.today()
|
|
dias_antes = int(ca.get('dias_antes') or 3)
|
|
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
|
|
gracia = int(ca.get('dias_gracia') or 0)
|
|
Membresia = self.env['skeen.membresia'].sudo()
|
|
creados = 0
|
|
for m in Membresia.search([('estado', '=', 'activa')]):
|
|
if not m.proximo_cobro:
|
|
continue
|
|
faltan = (m.proximo_cobro - hoy).days
|
|
if faltan > dias_antes:
|
|
continue
|
|
try:
|
|
creados += self._asegurar_enlace_periodo(m, ca)
|
|
except Exception:
|
|
# Una membresía sin servicio de cobro (u otro dato faltante) no
|
|
# debe detener el resto del proceso.
|
|
_logger.exception('Cobro automático: falló la membresía %s', m.id)
|
|
return creados
|
|
|
|
@api.model
|
|
def _asegurar_enlace_periodo(self, m, ca):
|
|
"""Crea (si falta) el enlace del próximo periodo y aplica los avisos."""
|
|
hoy = fields.Date.today()
|
|
proximo = m.proximo_cobro
|
|
delta = _delta_periodo(m.plan_id.periodicidad)
|
|
fin = proximo + delta - relativedelta(days=1)
|
|
periodo = m._crear_periodo_pendiente(proximo, fin)
|
|
if not periodo or periodo.estado == 'pagado':
|
|
return 0
|
|
svc = self.env['skeen.cobro.service'].sudo()
|
|
enlace = self.env['skeen.cobro.enlace'].sudo().search([
|
|
('origen', '=', 'membresia'), ('periodo_id', '=', periodo.id),
|
|
('estado', '=', 'pendiente')], limit=1)
|
|
creado = 0
|
|
if not enlace:
|
|
anterior = self.env['skeen.cobro.enlace'].sudo().search([
|
|
('origen', '=', 'membresia'), ('periodo_id', '=', periodo.id),
|
|
('estado', '=', 'pagado')], limit=1)
|
|
if anterior:
|
|
return 0
|
|
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
|
|
ultimo = max(reintentos) if reintentos else 0
|
|
ttl_horas = max(24, ((proximo + timedelta(days=ultimo + int(ca.get('dias_gracia') or 0) + 2)
|
|
- hoy).days or 1) * 24)
|
|
enlace = svc.crear(
|
|
'membresia', periodo.monto,
|
|
'Membresía %s (%s a %s)' % (m.plan_id.name, proximo, fin),
|
|
ttl_horas=ttl_horas, partner=m.partner_id, moneda=periodo.moneda)
|
|
enlace.write({
|
|
'periodo_id': periodo.id,
|
|
'venta_id': periodo.venta_id.id if periodo.venta_id else False,
|
|
})
|
|
svc.intentar_checkout(enlace)
|
|
creado = 1
|
|
self._avisar_enlace(m, enlace, ca, periodo)
|
|
return creado
|
|
|
|
@api.model
|
|
def _hitos_aviso(self, proximo_cobro, ca):
|
|
"""Fechas en que toca avisar: inicial (dias_antes) + reintentos."""
|
|
dias_antes = int(ca.get('dias_antes') or 3)
|
|
hitos = [proximo_cobro - timedelta(days=dias_antes)]
|
|
for d in (ca.get('reintentos_dias') or []):
|
|
hitos.append(proximo_cobro + timedelta(days=int(d)))
|
|
return hitos
|
|
|
|
@api.model
|
|
def _avisar_enlace(self, m, enlace, ca, periodo):
|
|
"""Encola el aviso que toque (inicial o reintento) sin duplicar."""
|
|
hoy = fields.Date.today()
|
|
hitos = self._hitos_aviso(m.proximo_cobro, ca)
|
|
objetivo = None
|
|
for i, fecha in enumerate(hitos):
|
|
if hoy >= fecha and (enlace.recordatorios or 0) <= i:
|
|
objetivo = i
|
|
break
|
|
if objetivo is None:
|
|
return 0
|
|
self._encolar_aviso_cobro(m, enlace, periodo, objetivo)
|
|
enlace.write({
|
|
'recordatorios': objetivo + 1,
|
|
'ultimo_envio': fields.Datetime.now(),
|
|
'enviado_at': enlace.enviado_at or fields.Datetime.now(),
|
|
})
|
|
# Escalada cuando ya se agotaron los avisos.
|
|
if (enlace.recordatorios or 0) >= len(hitos):
|
|
self._escalar_sin_pago(m, enlace, periodo, ca)
|
|
return 1
|
|
|
|
@api.model
|
|
def _encolar_aviso_cobro(self, m, enlace, periodo, hito):
|
|
try:
|
|
service = self.env['skeen.notificacion.service'].sudo()
|
|
except Exception:
|
|
return False
|
|
clave = 'cobro_mem:%s:%s' % (periodo.id, hito)
|
|
try:
|
|
service.encolar(
|
|
partner=m.partner_id, tipo='cobro_membresia',
|
|
cuando=fields.Datetime.now(), ventana=str(hito),
|
|
clave_unica=clave,
|
|
contexto_extra={
|
|
'enlace': enlace.url or '',
|
|
'monto': '%s %s' % (enlace.currency_id.name or '',
|
|
format(enlace.monto, ',.2f')),
|
|
'plan': m.plan_id.name,
|
|
'periodo': '%s a %s' % (periodo.periodo_inicio, periodo.periodo_fin),
|
|
'membresia': m.folio or '',
|
|
'fecha': (m.proximo_cobro.strftime('%d/%m/%Y')
|
|
if m.proximo_cobro else ''),
|
|
})
|
|
return True
|
|
except Exception:
|
|
_logger.exception('No se pudo encolar el aviso de cobro de membresía')
|
|
return False
|
|
|
|
@api.model
|
|
def _escalar_sin_pago(self, m, enlace, periodo, ca):
|
|
"""Comportamiento al agotar los reintentos.
|
|
|
|
Si `cancelar_si_no_paga` está encendido, al pasar los reintentos + días
|
|
de gracia del cobro se marca el periodo vencido y la membresía vencida de
|
|
inmediato. Si está apagado, el periodo queda pendiente y es el proceso
|
|
normal de vencimiento (`_marcar_vencidas`, con `dias_gracia` de
|
|
membresías) el que la vence unos días después.
|
|
"""
|
|
if not ca.get('cancelar_si_no_paga'):
|
|
return False
|
|
hoy = fields.Date.today()
|
|
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
|
|
ultimo = max(reintentos) if reintentos else 0
|
|
limite = m.proximo_cobro + timedelta(days=ultimo + int(ca.get('dias_gracia') or 0))
|
|
if hoy <= limite:
|
|
return False
|
|
if periodo.estado == 'pendiente':
|
|
periodo.write({'estado': 'vencido'})
|
|
if m.estado == 'activa':
|
|
m.write({'estado': 'vencida'})
|
|
m._post('Membresía vencida por falta de pago del periodo (cobro automático).')
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# Método 'tarjeta_guardada' (requiere Stripe real)
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def _cobro_tarjeta_guardada(self, ca):
|
|
"""Cargo sin presencia del cliente con tarjeta guardada (Stripe).
|
|
|
|
Camino técnico dejado INERTE y documentado: sin `skeen.stripe_secret_key`
|
|
y sin un método de pago guardado del cliente no hay nada que cobrar. No
|
|
se simula: si no hay llaves, no se hace nada (ni cargos ni avisos).
|
|
"""
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
if not ICP.get_param('skeen.stripe_secret_key'):
|
|
_logger.info('Cobro automático con tarjeta_guardada: sin Stripe configurado, inerte.')
|
|
return 0
|
|
# A partir de aquí requeriría tarjetas guardadas por cliente (Stripe
|
|
# Customer + PaymentMethod) que hoy no se persisten. Se deja el punto de
|
|
# extensión explícito y no se marca nada como cobrado.
|
|
_logger.info('Cobro automático con tarjeta_guardada: no hay tarjetas guardadas; inerte.')
|
|
return 0
|