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

@@ -1921,6 +1921,11 @@ class SkeenFrontendController(http.Controller):
'payment_state': cita.payment_state,
'price': cita.servicio_price,
'amount_paid': cita.amount_paid,
# Anticipo de reserva en línea (distinguir "esperando anticipo").
'anticipo_estado': cita.anticipo_estado or 'ninguno',
'anticipo_estado_label': dict(cita._fields['anticipo_estado'].selection).get(
cita.anticipo_estado, cita.anticipo_estado),
'anticipo_monto': cita.anticipo_monto or 0.0,
# Duración efectiva (propia de la cita o la del servicio)
'duration': cita._duracion(),
'duration_override': cita.duration or 0,
@@ -5423,6 +5428,146 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Cobros y enlaces de pago
# ============================================================
def _cobro_to_dict(self, e):
return {
'id': e.id,
'nombre': e.name,
'origen': e.origen,
'origen_label': dict(e._fields['origen'].selection).get(e.origen, e.origen),
'estado': e.estado,
'estado_label': dict(e._fields['estado'].selection).get(e.estado, e.estado),
'proveedor': e.proveedor,
'partner_id': e.partner_id.id if e.partner_id else None,
'patient': e.partner_id.name if e.partner_id else '',
'cita_id': e.cita_id.id if e.cita_id else None,
'folio_cita': e.cita_id.name if e.cita_id else '',
'venta_id': e.venta_id.id if e.venta_id else None,
'concepto': e.concepto or '',
'monto': e.monto,
'moneda': e.currency_id.name or 'MXN',
'creado': e.create_date.strftime('%Y-%m-%d %H:%M') if e.create_date else None,
'expira': e.token_expira.strftime('%Y-%m-%d %H:%M') if e.token_expira else None,
'fecha_pago': e.fecha_pago.strftime('%Y-%m-%d %H:%M') if e.fecha_pago else None,
'recordatorios': e.recordatorios,
'url': e.url or '',
}
@http.route('/skeen/frontend/v1/cobros', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('cobros.ver')
def list_cobros(self, **kw):
"""Listado de enlaces de cobro (pendientes, pagados, expirados)."""
try:
domain = []
if kw.get('estado'):
domain.append(('estado', '=', kw.get('estado')))
if kw.get('origen'):
domain.append(('origen', '=', kw.get('origen')))
enlaces = request.env['skeen.cobro.enlace'].sudo().search(
domain, order='create_date desc', limit=200)
return json_response({
'status': 'success',
'cobros': [self._cobro_to_dict(e) for e in enlaces],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/cobros/<int:enlace_id>/forzar', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('cobros.forzar')
@forbid_roles('marketing')
def forzar_cobro(self, enlace_id, **kw):
"""Registra el pago de un enlace sin pasar por la pasarela."""
try:
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
if not enlace.exists():
return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404)
if enlace.estado == 'pagado':
return json_response({'status': 'error', 'message': 'El enlace ya está pagado'}, 409)
user = current_user(request.env)
data = _parse_json_body()
pago = request.env['skeen.cobro.service'].sudo().confirmar(
enlace, proveedor=(data.get('proveedor') or enlace.proveedor or 'simulado'),
referencia=(data.get('referencia') or 'FORZADO'),
response='Forzado desde el panel', user=(user.login if user else 'staff'),
force=True)
if not pago:
return json_response({'status': 'error', 'message': 'No se pudo registrar el pago'}, 409)
self._auditar('cobros', 'cobro', str(enlace.id),
new_value='forzado', entity_label=enlace.concepto or enlace.name,
notes='Pago forzado')
return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace),
'pago': pago.name})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/cobros/<int:enlace_id>/cancelar', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('cobros.cancelar')
def cancelar_cobro(self, enlace_id, **kw):
"""Cancela un enlace pendiente y libera la retención (cita/periodo)."""
try:
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
if not enlace.exists():
return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404)
if enlace.estado != 'pendiente':
return json_response({'status': 'error',
'message': 'Solo se pueden cancelar enlaces pendientes'}, 409)
data = _parse_json_body()
user = current_user(request.env)
enlace.action_cancelar(motivo=(data.get('motivo') or 'Cancelado desde el panel'),
user=(user.login if user else 'staff'))
return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/cobros', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('reportes.ver')
def report_cobros(self, **kw):
"""Reporte de cobros pendientes y expirados (anticipos y membresías)."""
try:
start = kw.get('start')
end = kw.get('end')
domain = [('estado', 'in', ('pendiente', 'expirado', 'cancelado'))]
if start:
domain.append(('create_date', '>=', f'{start} 00:00:00'))
if end:
domain.append(('create_date', '<=', f'{end} 23:59:59'))
enlaces = request.env['skeen.cobro.enlace'].sudo().search(domain,
order='estado, token_expira')
ahora = fields.Datetime.now()
filas = []
resumen = {'pendiente': 0.0, 'expirado': 0.0, 'cancelado': 0.0}
for e in enlaces:
vencido = bool(e.estado == 'pendiente' and e.token_expira and e.token_expira < ahora)
filas.append({
**self._cobro_to_dict(e),
'vencido': vencido,
'dias_para_vencer': ((e.token_expira - ahora).days
if e.estado == 'pendiente' and e.token_expira else 0),
})
if e.estado in resumen:
resumen[e.estado] += e.monto
return json_response({
'status': 'success',
'start': start, 'end': end,
'cobros': filas,
'total': len(filas),
'resumen': resumen,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Cortes de Caja
# ============================================================