← Todos los artículos EN

Arquitectura de formularios en frontends de alta compliance

Los formularios en banca no son componentes UI — son interfaces de compliance con una capa de UI encima. Esta es la arquitectura que lo maneja sin acoplar las reglas de negocio a tus componentes.

Un formulario de solicitud de préstamo en un entorno financiero regulado no es un formulario. Es una interfaz de compliance con una capa de UI encima.

Las reglas de validación vienen de legal. La visibilidad de los campos depende del alcance regulatorio — reglas distintas aplican en Chile, Australia, Canadá. Algunos campos son requeridos solo cuando el solicitante es persona jurídica. Otros son condicionalmente requeridos según el valor de un campo anterior, que a su vez podría ser condicionalmente visible.

Y todo necesita ser auditable: cada estado por el que pasa el formulario, incluyendo estados intermedios, necesita poder reconstruirse.

Cuando empecé en la plataforma de onboarding digital de Scotiabank CCAU, teníamos react-hook-form conectado directamente a llamadas a la API con lógica de validación inline dispersa entre docenas de componentes de campo. Funcionaba. No escalaba.

El problema central con la validación inline

El instinto al construir formularios es co-ubicar la validación con el campo:

<Input
  name="rut"
  validate={(value) => {
    if (!value) return 'RUT requerido';
    if (!isValidRut(value)) return 'Formato de RUT inválido';
  }}
/>

Esto funciona bien con tres campos. Con treinta campos en cuatro productos con contextos regulatorios variables, tienes lógica de validación en JSX, en custom hooks, en respuestas de API, y a veces duplicada entre productos porque alguien no sabía que existía la versión compartida.

El problema más profundo: la validación no es una preocupación de la UI. Si un RUT es válido es una regla de negocio. Si un campo es requerido dado el perfil del solicitante es una regla de negocio. La UI debería preguntar “¿es válido este valor en este contexto?” — no implementar la respuesta.

Formularios basados en schema con Zod

El primer cambio estructural: centralizar todos los schemas de validación en un paquete agnóstico al producto.

// @onebank/schemas/src/prestamo/solicitante.ts
import { z } from 'zod';

const TipoPersona = z.enum(['natural', 'juridica']);

const BaseSchema = z.object({
  nombre: z.string().min(1, 'Requerido'),
  apellido: z.string().min(1, 'Requerido'),
  email: z.string().email('Email inválido'),
  tipoPersona: TipoPersona,
});

const PersonaNaturalSchema = BaseSchema.extend({
  tipoPersona: z.literal('natural'),
  rut: z.string().regex(/^\d{7,8}-[\dkK]$/, 'RUT inválido'),
  fechaNacimiento: z.string().refine(esMayorDeEdad, 'Debe ser mayor de 18 años'),
});

const PersonaJuridicaSchema = BaseSchema.extend({
  tipoPersona: z.literal('juridica'),
  rutEmpresa: z.string().min(9, 'RUT empresa inválido'),
  representanteLegal: z.string().min(1, 'Requerido'),
});

export const SolicitanteSchema = z.discriminatedUnion('tipoPersona', [
  PersonaNaturalSchema,
  PersonaJuridicaSchema,
]);

export type Solicitante = z.infer<typeof SolicitanteSchema>;

z.discriminatedUnion hace trabajo real aquí. TypeScript estrecha el tipo basado en tipoPersona — no puedes acceder a rut en el solicitante de persona jurídica sin un error de tipo. Esto elimina toda una clase de bugs en runtime que aparecían en QA.

Separar la visibilidad de campos de la validación

La visibilidad de campos (mostrar/ocultar) y la validación de campos (requerido/opcional) suelen estar acopladas en el lugar equivocado.

Un campo puede ser: visible y requerido, visible y opcional, oculto (y por tanto no validado), o condicionalmente visible según el valor de otro campo. Modelamos esto explícitamente con un objeto de configuración del formulario:

interface ConfigCampo {
  visible: boolean;
  requerido: boolean;
  deshabilitado: boolean;
  label: string;
}

type ConfigFormulario = Record<keyof ValoresFormulario, ConfigCampo>;

function resolverConfigFormulario(
  solicitante: Partial<ValoresFormulario>,
  contextoRegulatorio: ContextoRegulatorio,
): ConfigFormulario {
  const esNatural = solicitante.tipoPersona === 'natural';
  const esChile = contextoRegulatorio.jurisdiccion === 'CL';

  return {
    rut: {
      visible: esNatural && esChile,
      requerido: esNatural && esChile,
      deshabilitado: false,
      label: 'RUT',
    },
    rutEmpresa: {
      visible: !esNatural,
      requerido: !esNatural,
      deshabilitado: false,
      label: 'RUT empresa',
    },
  };
}

resolverConfigFormulario es una función pura. Recibe el estado actual del formulario y el contexto regulatorio, devuelve un objeto de configuración. Sin React, sin hooks, sin efectos secundarios. Es trivialmente testeable:

test('oculta el campo RUT para jurisdicciones no chilenas', () => {
  const config = resolverConfigFormulario(
    { tipoPersona: 'natural' },
    { jurisdiccion: 'AU' },
  );
  expect(config.rut.visible).toBe(false);
});

Los componentes del formulario consumen la configuración en lugar de tomar decisiones de visibilidad por sí mismos:

function FormularioSolicitante() {
  const form = useForm<ValoresFormulario>();
  const contexto = useContextoRegulatorio();
  const config = resolverConfigFormulario(form.watch(), contexto);

  return (
    <form>
      {config.rut.visible && (
        <Controller
          name="rut"
          control={form.control}
          rules={{ required: config.rut.requerido }}
          render={({ field }) => (
            <Input {...field} label={config.rut.label} />
          )}
        />
      )}
    </form>
  );
}

El problema de la trazabilidad

Los reguladores financieros requieren que el estado de las solicitudes sea auditable: si un cliente inició una solicitud, completó diez campos, la abandonó, volvió tres días después y la envió — necesitas reconstruir cada estado por el que pasó esa solicitud.

“Estado” aquí no significa estado de Redux. Significa los valores que ingresó el cliente, cuándo los ingresó, y cuál era la configuración del formulario en ese momento — porque las reglas regulatorias pueden haber cambiado entre sesiones.

Lo resolvimos tratando el estado del formulario como un registro de eventos, no como una instantánea:

interface EventoFormulario {
  sessionId: string;
  timestamp: string;
  tipoEvento: 'campo_cambiado' | 'paso_completado' | 'validacion_fallida' | 'enviado';
  nombreCampo?: string;
  versionFormulario: string;
  contextoRegulatorio: ContextoRegulatorio;
}

versionFormulario es crítico — permite reproducir el formulario contra las reglas de validación que estaban vigentes en ese momento, no las de hoy.

Qué compra esta arquitectura

Velocidad de onboarding. Un desarrollador nuevo no necesita entender el contexto regulatorio para construir un paso del formulario. Consume el objeto de configuración y renderiza campos basado en él. La lógica de negocio vive en otro lugar.

Testeabilidad. resolverConfigFormulario es pura. SolicitanteSchema es puro. Ambos se testean de forma aislada sin un browser, sin React, sin mocks de API. Los test suites de lógica de negocio corren en menos de dos segundos.

Reutilización entre productos. Cuando el equipo de Seguros necesitó un formulario con campos similares, importó los schemas compartidos y el resolvedor de configuración, agregó sus campos específicos, y tuvo un formulario funcional y compliant en dos días en vez de dos semanas.

Los formularios son infraestructura. Trátelos como tal.