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:
2026-10-11 23:54:47 +00:00
parent 5fc0d2a780
commit 6a6c2a928b
35 changed files with 2841 additions and 32 deletions

View File

@@ -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',

View File

@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
from . import membresia
from . import venta
from . import cobro

View 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

View File

@@ -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