# -*- 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