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:
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user