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:
@@ -21,7 +21,8 @@
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base', 'mail', 'skeen_citas', 'skeen_pagos', 'skeen_ventas', 'skeen_whatsapp'],
|
||||
'depends': ['base', 'mail', 'skeen_citas', 'skeen_pagos', 'skeen_ventas',
|
||||
'skeen_whatsapp', 'skeen_cobros'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/sequences.xml',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import membresia
|
||||
from . import venta
|
||||
from . import cobro
|
||||
|
||||
250
odoo-addons/skeen_membresias/models/cobro.py
Normal file
250
odoo-addons/skeen_membresias/models/cobro.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# -*- 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
|
||||
@@ -688,7 +688,8 @@ class SkeenMembresiaService(models.AbstractModel):
|
||||
@api.model
|
||||
def _cron_tick(self):
|
||||
"""Corre la lógica diaria. Con `membresias.activo=False` no hace nada."""
|
||||
resultado = {'vencidas': 0, 'avisos': 0, 'cargos': 0, 'descongeladas': 0}
|
||||
resultado = {'vencidas': 0, 'avisos': 0, 'cargos': 0, 'descongeladas': 0,
|
||||
'cobros': 0}
|
||||
cfg = self._cfg()
|
||||
if not cfg.get('activo'):
|
||||
return resultado
|
||||
@@ -696,6 +697,7 @@ class SkeenMembresiaService(models.AbstractModel):
|
||||
resultado['vencidas'] = self._marcar_vencidas(cfg)
|
||||
resultado['avisos'] = self._encolar_avisos(cfg)
|
||||
resultado['cargos'] = self._generar_cargos(cfg)
|
||||
resultado['cobros'] = self._cobro_automatico(cfg)
|
||||
if any(resultado.values()):
|
||||
_logger.info('SKEEN membresías: %s', resultado)
|
||||
return resultado
|
||||
|
||||
Reference in New Issue
Block a user