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

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -9,8 +9,17 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
...CLINIC_SETTINGS_DEFAULT,
...(settings || {}),
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
reservas_online: {
...RESERVAS_ONLINE_DEFAULT,
...(settings?.reservas_online || {}),
anticipo: { ...ANTICIPO_DEFAULT, ...((settings?.reservas_online || {}).anticipo || {}) },
},
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
membresias: {
...MEMBRESIAS_DEFAULT,
...(settings?.membresias || {}),
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT, ...((settings?.membresias || {}).cobro_automatico || {}) },
},
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
@@ -31,6 +40,12 @@ const publicar = (s: ClinicSettings) => {
};
export const cargarClinicSettings = (): Promise<ClinicSettings> => {
// Sin sesión no hay nada que pedir: los ajustes requieren token y el portal
// público (reservas, pago, formularios) no lo tiene. Se devuelven los
// defaults para no ensuciar la consola con un 401.
if (typeof window !== 'undefined' && !window.localStorage.getItem('skeen_token')) {
return Promise.resolve(actual);
}
if (peticion) return peticion;
peticion = odooApi.getClinicSettings()
.then((res) => {

View File

@@ -388,6 +388,8 @@ export const es: Dict = {
'reports.tab.cortes.desc': 'Arqueos y cierres por día',
'reports.tab.adeudos': 'Adeudos',
'reports.tab.adeudos.desc': 'Cartera por cobrar y planes de pago',
'reports.tab.cobros': 'Cobros',
'reports.tab.cobros.desc': 'Anticipos y membresías por cobrar (pendientes y expirados)',
'reports.tab.devoluciones': 'Devoluciones',
'reports.tab.devoluciones.desc': 'Ventas devueltas y montos',
'reports.tab.top-clientes': 'Top clientes',
@@ -487,6 +489,35 @@ export const es: Dict = {
'public.manage.leadTime': ' (con menos de {h} h de anticipación)',
'public.manage.contactPhone': ' Comunícate al {phone}.',
'public.manage.contactClinic': ' Comunícate con la clínica.',
// Anticipo al reservar en línea
'public.awaitingDeposit': 'Reserva retenida: falta tu anticipo',
'public.depositAmount': 'Anticipo a pagar: {amount}',
'public.depositHelp': 'Tienes {h} horas para pagar el anticipo; si no se paga, la reserva se libera.',
'public.payDeposit': 'Pagar anticipo',
'public.depositExpires': 'El enlace vence en {h} horas',
// --- Página pública de pago de un enlace de cobro ---
'pay.title': 'Pago en línea',
'pay.folioWord': 'Folio',
'pay.concept': 'Concepto',
'pay.amount': 'Monto',
'pay.forPatient': 'A nombre de {name}',
'pay.payNow': 'Pagar ahora',
'pay.paySimulated': 'Confirmar pago (modo prueba)',
'pay.paidOk': '¡Pago registrado!',
'pay.receipt': 'Comprobante',
'pay.date': 'Fecha',
'pay.notAvailable': 'El pago en línea no está disponible en este momento. Comunícate con la clínica.',
'pay.expiresAt': 'Este enlace vence el {date}',
'pay.expiredBody': 'Este enlace ya expiró. Pide uno nuevo a la clínica.',
'pay.cancelledBody': 'Este enlace fue cancelado.',
'pay.unavailableTitle': 'Enlace no disponible',
'pay.invalidLink': 'Enlace no válido',
'pay.notFound': 'No encontramos este enlace de pago.',
'pay.payError': 'No se pudo completar el pago. Intenta de nuevo.',
'pay.simulatedNote': 'Modo de prueba: no se cobra dinero real.',
'pay.secureNote': 'Pago protegido por la pasarela de la clínica.',
'pay.state.pagado': 'Pagado',
// --- POS ---
'pos.title': 'Punto de Venta',
@@ -603,8 +634,42 @@ export const es: Dict = {
'agenda.noDoctorsSubWeek': 'No hay médicos dados de alta para mostrar en la vista semanal.',
'agenda.noResourcesTitle': 'Sin recursos',
'agenda.noResourcesSub': 'Agrega recursos en Configuración › Recursos para ver esta vista.',
'agenda.depositPending': 'Esperando anticipo',
'config.idiomaTitle': 'Idioma de la interfaz',
'config.idiomaBody': 'Elige el idioma con el que se muestra el sistema en este navegador.',
// Anticipo de reservas en línea
'config.anticipo.title': 'Anticipo al reservar',
'config.anticipo.help': 'Pide un depósito al reservar en línea. La reserva queda retenida sin confirmar hasta que se pague; si no se paga a tiempo, se libera el horario.',
'config.anticipo.enable': 'Requerir anticipo',
'config.anticipo.enableHelp': 'Apagado, el portal reserva sin pedir dinero (como hasta hoy).',
'config.anticipo.type': 'Tipo de anticipo',
'config.anticipo.typePercent': 'Porcentaje del servicio',
'config.anticipo.typeFixed': 'Monto fijo',
'config.anticipo.value': 'Valor (porcentaje o monto)',
'config.anticipo.currency': 'Moneda',
'config.anticipo.hours': 'Horas para pagar',
'config.anticipo.refundable': 'Reembolsable',
'config.anticipo.refundableHelp': 'Si se cancela la cita, el anticipo se devuelve.',
'config.anticipo.simulated': 'Permitir pago simulado',
'config.anticipo.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.',
'config.anticipo.notice': 'Aviso para el cliente',
'config.anticipo.noticePlaceholder': 'Se requiere un anticipo para confirmar tu cita.',
// Cobro automático de membresías
'config.cobroAuto.title': 'Cobro automático de membresías',
'config.cobroAuto.help': 'Genera el enlace de pago del periodo y lo envía al paciente por notificaciones, con reintentos. El periodo queda pendiente hasta que se pague.',
'config.cobroAuto.enable': 'Activar cobro automático',
'config.cobroAuto.enableHelp': 'Apagado, el proceso solo genera el adeudo (no cobra ni avisa).',
'config.cobroAuto.method': 'Método',
'config.cobroAuto.methodLink': 'Enlace de pago',
'config.cobroAuto.methodCard': 'Tarjeta guardada (Stripe)',
'config.cobroAuto.daysBefore': 'Días antes del vencimiento',
'config.cobroAuto.grace': 'Días de gracia',
'config.cobroAuto.retries': 'Días de reintento del aviso',
'config.cobroAuto.retriesHelp': 'Días (respecto al vencimiento) en que se reintenta avisar, separados por coma.',
'config.cobroAuto.cancel': 'Vencer si no paga',
'config.cobroAuto.cancelHelp': 'Al agotar los reintentos, marca la membresía vencida. Apagado, sigue activa hasta el proceso normal de vencimiento.',
'config.cobroAuto.simulated': 'Permitir pago simulado',
'config.cobroAuto.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.',
};
// ------------------------------------------------------------
@@ -953,6 +1018,8 @@ export const en: Dict = {
'reports.tab.cortes.desc': 'Daily counts and closings',
'reports.tab.adeudos': 'Debts',
'reports.tab.adeudos.desc': 'Accounts receivable and payment plans',
'reports.tab.cobros': 'Charges',
'reports.tab.cobros.desc': 'Deposits and memberships pending collection (pending and expired)',
'reports.tab.devoluciones': 'Refunds',
'reports.tab.devoluciones.desc': 'Returned sales and amounts',
'reports.tab.top-clientes': 'Top clients',
@@ -1052,6 +1119,32 @@ export const en: Dict = {
'public.manage.leadTime': ' (less than {h} h in advance)',
'public.manage.contactPhone': ' Contact us at {phone}.',
'public.manage.contactClinic': ' Contact the clinic.',
'public.awaitingDeposit': 'Booking held: deposit pending',
'public.depositAmount': 'Deposit due: {amount}',
'public.depositHelp': 'You have {h} hours to pay the deposit; otherwise the booking is released.',
'public.payDeposit': 'Pay deposit',
'public.depositExpires': 'The link expires in {h} hours',
'pay.title': 'Online payment',
'pay.folioWord': 'Reference',
'pay.concept': 'Concept',
'pay.amount': 'Amount',
'pay.forPatient': 'For {name}',
'pay.payNow': 'Pay now',
'pay.paySimulated': 'Confirm payment (test mode)',
'pay.paidOk': 'Payment recorded!',
'pay.receipt': 'Receipt',
'pay.date': 'Date',
'pay.notAvailable': 'Online payment is not available right now. Please contact the clinic.',
'pay.expiresAt': 'This link expires on {date}',
'pay.expiredBody': 'This link has expired. Please ask the clinic for a new one.',
'pay.cancelledBody': 'This link was cancelled.',
'pay.unavailableTitle': 'Link unavailable',
'pay.invalidLink': 'Invalid link',
'pay.notFound': 'We could not find this payment link.',
'pay.payError': 'The payment could not be completed. Please try again.',
'pay.simulatedNote': 'Test mode: no real money is charged.',
'pay.secureNote': 'Payment protected by the clinic payment gateway.',
'pay.state.pagado': 'Paid',
// --- POS ---
'pos.title': 'Point of Sale',
@@ -1168,8 +1261,40 @@ export const en: Dict = {
'agenda.noDoctorsSubWeek': 'No doctors are registered to show in the weekly view.',
'agenda.noResourcesTitle': 'No resources',
'agenda.noResourcesSub': 'Add resources in Settings › Resources to see this view.',
'agenda.depositPending': 'Deposit pending',
'config.idiomaTitle': 'Interface language',
'config.idiomaBody': 'Choose the language the system displays in this browser.',
'config.anticipo.title': 'Deposit at booking',
'config.anticipo.help': 'Ask for a deposit when booking online. The booking is held without confirming until paid; if unpaid in time, the slot is released.',
'config.anticipo.enable': 'Require deposit',
'config.anticipo.enableHelp': 'Off, the portal books without asking for money (as today).',
'config.anticipo.type': 'Deposit type',
'config.anticipo.typePercent': 'Percentage of the service',
'config.anticipo.typeFixed': 'Fixed amount',
'config.anticipo.value': 'Value (percentage or amount)',
'config.anticipo.currency': 'Currency',
'config.anticipo.hours': 'Hours to pay',
'config.anticipo.refundable': 'Refundable',
'config.anticipo.refundableHelp': 'If the appointment is cancelled, the deposit is refunded.',
'config.anticipo.simulated': 'Allow simulated payment',
'config.anticipo.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.',
'config.anticipo.notice': 'Customer notice',
'config.anticipo.noticePlaceholder': 'A deposit is required to confirm your appointment.',
'config.cobroAuto.title': 'Automatic membership charging',
'config.cobroAuto.help': 'Generates the payment link for the period and sends it to the patient via notifications, with retries. The period stays pending until paid.',
'config.cobroAuto.enable': 'Enable automatic charging',
'config.cobroAuto.enableHelp': 'Off, the process only creates the charge (no charge or notice).',
'config.cobroAuto.method': 'Method',
'config.cobroAuto.methodLink': 'Payment link',
'config.cobroAuto.methodCard': 'Saved card (Stripe)',
'config.cobroAuto.daysBefore': 'Days before due date',
'config.cobroAuto.grace': 'Grace days',
'config.cobroAuto.retries': 'Notice retry days',
'config.cobroAuto.retriesHelp': 'Days (relative to due date) on which the notice is retried, comma separated.',
'config.cobroAuto.cancel': 'Expire if unpaid',
'config.cobroAuto.cancelHelp': 'When retries are exhausted, marks the membership as expired. Off, it stays active until the normal expiration process.',
'config.cobroAuto.simulated': 'Allow simulated payment',
'config.cobroAuto.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.',
};
export const DICT: Record<Locale, Dict> = { es, en };

View File

@@ -23,6 +23,7 @@ export const PERM_KEYS: string[] = [
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
'cobros.ver', 'cobros.administrar', 'cobros.forzar', 'cobros.cancelar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
'api.claves', 'webhooks.editar',
];
@@ -53,6 +54,7 @@ const READ_PERM_KEYS = new Set([
'compras.ver',
'proveedores.ver',
'comisiones.ver',
'cobros.ver',
]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
@@ -84,6 +86,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'compras.editar': AG, 'compras.cancelar': AG,
'proveedores.ver': ALL, 'proveedores.editar': AG,
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
'cobros.ver': ALL, 'cobros.administrar': AG,
'cobros.forzar': ['admin', 'recepcion', 'gerencia'], 'cobros.cancelar': AG,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
'api.claves': AD, 'webhooks.editar': AG,
};