Mapa de procesos · préstamos personales
Cómo funciona IRIS, flujo por flujo
Dieciséis circuitos de negocio reconstruidos leyendo el código: quién interviene, qué dispara cada paso, en qué estados puede quedar una operación y qué reglas hace cumplir el sistema. Los nombres técnicos en monoespaciado son los literales que viven en la base de datos.
El sistema de un vistazo
IRIS es una plataforma de préstamos personales con descuento por recibo de sueldo, operada por una cooperativa. El recorrido siempre es el mismo: un vendedor arma la solicitud contra un plan comercial, riesgo la aprueba, tesorería la desembolsa, el organismo empleador descuenta la cuota mes a mes, y el crédito termina cobrado, precancelado o vendido a un tercero.
Configuración comercial y jerarquía de venta
Es el árbol de datos maestros que tiene que estar armado antes de que alguien pueda cargar una operación. Arranca en la Modalidad de Cobro (cómo se le va a descontar la cuota al cliente), de ahí cuelgan los Organismos empleadores y el Plan Comercial (el producto: tasas, topes de edad, montos, reglas BCRA). En paralelo se arma la jerarquía comercial de 5 niveles (Casa Central, Sucursal, Organización, Zonal 1, Zonal 2). El Convenio es la bisagra: cruza una Organización con un Plan Comercial y le pone vigencia, retención, comisión y sucursal responsable. Después, la asignación Usuario-Organización define qué convenios ve cada vendedor, y eso determina exactamente qué planes y qué organismos le aparecen en el alta.
Quién interviene
Qué lo dispara
- Alta de un nuevo empleador / organismo con el que se firma acuerdo de descuento de haberes
- Lanzamiento de un producto nuevo o cambio de tasas: alta o edición de un Plan Comercial y sus Plazos
- Firma de un convenio con una organización comercializadora (alta de Convenio, o alta de Organización con convenio inline)
- Incorporación de una sucursal, zonal o estudio a la red de venta
- Alta o cambio de alcance de un vendedor (asignación Usuario-Organización y selección de convenios)
- Carga masiva por CSV de organismos (/organismos/carga-masiva), de sub-códigos (/sub-codigos/carga-masiva) o de organismos habilitados dentro del alta de un plan comercial
- Cambio del plazo de gracia en una Modalidad de Cobro, que dispara el recálculo de tasas de los plazos asociados
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Da de alta la Modalidad de Cobro: nombre, tipo (descuento de haberes, CBU, tarjeta de débito o pago voluntario), día de corte y de presentación, día de cobro, cantidad de haberes que administra, plazo de gracia, gastos de cobranza y de gestión de mora, si admite múltiples créditos por persona, y el flag activa.
/mod-cobro/alta·Crea la fila en modalidades_cobro. Es la raíz del árbol: sin modalidad no se puede crear ni un organismo, ni un plan comercial, ni un servicio social.
Carga los catálogos que clasifican al organismo: Categoría, Subcategoría de Organismo y Dependencia de Organismo.
/categorias/alta, /subcategorias-organismo/alta, /dependencias-organismo/alta·Filas en categorias, subcategorias_organismo y dependencias_organismo. Subcategorías y dependencias tienen flag activo: solo las activas se ofrecen en el formulario de organismo.
Da de alta el Organismo empleador eligiendo obligatoriamente su Modalidad de Cobro, su Subcategoría y su Dependencia; opcionalmente la Categoría. Define además CUIT del empleador, días de gracia de saldo, porcentaje de gasto de precancelación y cuotas a vencer para renovar. También existe la carga masiva por CSV.
/organismos/alta y /organismos/carga-masiva·Filas en organismos. Un organismo queda atado de por vida a una única modalidad de cobro, y eso condiciona en qué planes comerciales puede ser habilitado.
Carga los Sub-códigos del organismo (los códigos internos con los que el empleador identifica el descuento), de a uno o por CSV masivo contra un organismo elegido.
/sub-codigos/alta y /sub-codigos/carga-masiva·Filas en sub_codigos, únicas por combinación código + organismo.
Crea el Plan Comercial. Primero elige el TIPO de modalidad de cobro, que habilita y filtra el selector de Modalidad de Cobro concreta; recién ahí puede elegir los organismos habilitados (que se traen filtrados por esa modalidad) y los haberes que administra. Carga vigencia, ingreso mínimo, edades mín/máx por género, montos mín/máx, afectación máxima, endeudamiento, tipo de calculadora, seguros, gastos asociados y el anexo BCRA por situación.
/planes-comerciales/alta·Crea planes_comerciales, las filas de organismos_planes_comerciales, los gastos del plan (gastos_planes_comerciales) y, si el anexo BCRA está configurado, las 6 filas de planes_comerciales_situaciones_bcra.
Carga los Plazos del plan: rangos de cuotas (desde-hasta) con TEM, TNA, CFTEA, porcentaje de gasto de seguro y de gasto administrativo. Puede calcular desde TEM o despejar la TEM desde la CFTEA.
/planes-comerciales/:planId/plazos/alta·Filas en plazos_planes_comerciales. Sin un plazo activo que cubra la cantidad de cuotas pedida, la operación no puede resolver sus tasas.
Da de alta Servicios Sociales (cuota social), eligiendo también una Modalidad de Cobro, el valor de cuota, vigencia y los gastos asociados.
/servicios-sociales/alta·Filas en servicios_sociales y gastos_servicios_sociales. Habilita el producto servicio social, que puede venderse solo o junto con el préstamo.
Arma la jerarquía comercial dando de alta unidades: Sucursal Casa Central (la raíz), Sucursales (que exigen delegado con CUIL, DNI y mayoría de edad), Organizaciones, Org Zonal 1 y Org Zonal 2. Cada nivel exige el padre del nivel inmediato superior, salvo la sucursal que se cuelga sola de Casa Central. En el mismo alta puede generarse un convenio inline.
/organizaciones/alta·Filas en organizaciones (con padreId), domicilios_organizaciones, sucursales_delegados, usuario_organizaciones para los usuarios asignados y, si se marcó, convenios más el historial de comisión.
Da de alta el Convenio cruzando una Organización con un Plan Comercial: tipo de convenio (STANDARD, E-COMMERCE, PROFINSA), vigencia desde/hasta, porcentaje de retención, porcentaje de comisión y su base (capital solicitado u otorgado), comisión de la organización ascendente, sucursal responsable, estado activo/inactivo y comisión de excepción con vigencia.
/convenios/alta·Fila en convenios. Es el nodo que efectivamente habilita a vender: sin convenio activo, el plan comercial no aparece para nadie en el alta de operación.
Asigna el usuario a una unidad de la jerarquía (Organización, Zonal 1 o Zonal 2) y define si tiene acceso a todos los convenios de esa cadena o solo a una lista seleccionada.
/usuario-organizaciones/alta·Filas en usuario_organizaciones y usuario_organizacion_convenios. Define el alcance de venta del usuario.
Entra al alta de operación. El sistema le arma los combos: solo planes comerciales con habilitado comercial y habilitado de riesgo y con convenio activo dentro de su alcance; solo las unidades de la jerarquía que le corresponden; y, en el paso de datos laborales, solo los organismos habilitados por el plan elegido.
/operaciones/alta-operacion y /operaciones/:operacionId/alta-operacion·No escribe configuración: lee el árbol. Al guardar organización y plan, el sistema resuelve y congela en la operación el convenio y la sucursal responsable (Operacion.convenioId y sucursalResponsableId).
Estados
- activo
- Valor por defecto de Convenio.estado: el convenio está vigente comercialmente. El mismo literal se usa como flag booleano activo en Organismo, SubCategoriaOrganismo, DependenciaOrganismo, ServicioSocial y PlazoPlanComercial para indicar que el registro se puede seguir usando.
- inactivo
- Valor de Convenio.estado que saca el convenio de circulación: aunque las fechas estén vigentes, deja de listarse para operar. Es reversible desde la edición del convenio.
- activa
- Flag de ModalidadCobro. Solo las modalidades con activa=true se ofrecen en los selectores de alta de operación, de sub-códigos y en la API de modalidades. Ojo: el formulario de plan comercial NO aplica este filtro.
- habilitadoComercial
- Flag del Plan Comercial que representa el visto bueno del área comercial. Es condición necesaria para que el plan aparezca en cualquier alta de operación.
- habilitadoRiesgo
- Flag del Plan Comercial que representa el visto bueno de Riesgo. Se exige junto con habilitadoComercial: si falta cualquiera de los dos, el plan es invisible para vender.
- bcraConfigurado
- Flag del Plan Comercial que enciende el anexo BCRA: si está en true se guardan las 6 filas de situación BCRA y toda alta contra ese plan consulta la Central de Deudores. Si está en false, el alta muestra una advertencia de que el anexo no está configurado.
- accesoTodosConvenios
- Flag de la asignación Usuario-Organización. En true el usuario ve todos los convenios de la cadena de su unidad; en false solo ve los convenios explícitamente seleccionados en usuario_organizacion_convenios.
Reglas que el sistema hace cumplir
- La jerarquía comercial tiene exactamente 5 niveles y el orden es rígido: SUCURSAL_CASA_CENTRAL, SUCURSAL, ORGANIZACION, ZONAL, ZONAL2 (app/lib/organization-units/constants.ts:1-7; app/routes/organizations/validations.ts:17-31).
- Toda unidad que no sea SUCURSAL ni SUCURSAL_CASA_CENTRAL exige padre explícito; la SUCURSAL se cuelga automáticamente de la Casa Central existente sin que el usuario la elija (app/routes/organizations/validations.ts:169; app/routes/organizations/create.tsx:231-235).
- Dar de alta una SUCURSAL obliga a cargar el delegado completo: CUIL de 11 dígitos, DNI de 8, nombre, apellido, fecha de nacimiento con mayoría de edad y género (app/routes/organizations/validations.ts:167-215).
- Un Plan Comercial pertenece a una y solo una Modalidad de Cobro, y es un campo obligatorio no nulo (prisma/schema.prisma:836).
- En el formulario de Plan Comercial hay cascadeo en dos saltos: primero se elige el Tipo de modalidad de cobro (DESCUENTO_HABERES, DESCUENTO_CBU, TARJETA_DEBITO o PAGO_VOLUNTARIO); ese tipo filtra la lista de Modalidades de Cobro concretas, el selector de modalidad queda deshabilitado hasta elegir tipo, y cambiar el tipo limpia la modalidad ya seleccionada (app/routes/planes-comerciales/components/CommercialPlanForm.tsx:157-166 y 316-328; app/lib/payment-methods/constants.ts:4-9).
- Los organismos que se pueden habilitar en un plan comercial son únicamente los que tienen la misma modalidad de cobro que el plan; el combo se recarga por API cada vez que cambia la modalidad y descarta las selecciones que ya no aplican (app/routes/api/organismos.by-modalidad-cobro.tsx:9-31; app/routes/planes-comerciales/components/CommercialPlanForm.tsx:185-219).
- Los haberes que administra el plan deben ser enteros entre 1 y la cantidad de haberes administrados que declara la modalidad de cobro, sin repetidos; si la modalidad no tiene ese dato cargado, el plan no puede seleccionar ninguno (app/lib/planes-comerciales/haberQueAdministra.server.ts:13-40).
- Un Organismo exige obligatoriamente Modalidad de Cobro, Subcategoría y Dependencia en el formulario; la Categoría es opcional (app/routes/employers/validations.ts:7-10). En base de datos, en cambio, subcategoría y dependencia son nulables (prisma/schema.prisma:783-784).
- Los Sub-códigos son únicos por combinación código + organismo (prisma/schema.prisma:1277).
- Un Convenio es el cruce de una Organización con un Plan Comercial, y concentra la comisión, la base de cálculo de comisión, la sucursal responsable, el estado y la comisión de excepción (prisma/schema.prisma:958-995). No hay restricción de unicidad: la misma organización puede tener varios convenios activos sobre el mismo plan, y por eso el alta ofrece un selector de convenio.
- Un convenio se considera operable solo si estado = 'activo', fechaInicio menor o igual a hoy y fechaFin nula o mayor o igual a hoy (app/lib/operations/getOperacionData.ts:20-26; app/lib/organization-units/queries.ts:588-595).
- Si se carga un porcentaje de comisión de excepción mayor a cero, las fechas Desde y Hasta de la excepción pasan a ser obligatorias (app/routes/convenios/validations.ts:84-108).
- Un Plan Comercial solo aparece en el alta de operación si tiene habilitadoComercial = true Y habilitadoRiesgo = true (app/lib/operations/getOperacionData.ts:120-122 y 206-210; app/routes/api/planes-comerciales.ts:15-21).
- Para un usuario de perfil comercial o de riesgo de sucursal, el universo de planes se reduce a los planes de los convenios activos que tiene permitidos; si no tiene ninguna asignación de organización o ningún convenio permitido, ve la lista vacía (app/lib/operations/getOperacionData.ts:37-107 y 193-205).
- El permiso sobre convenios se resuelve por asignación: si accesoTodosConvenios está en true valen todos los convenios colgados de su unidad y de hasta 3 ancestros hacia arriba; si está en false valen solo los convenios explícitamente seleccionados (app/lib/operations/getOperacionData.ts:53-104; app/lib/organization-units/queries.ts:496-560).
- Los convenios asignables a un usuario deben pertenecer a la cadena de ancestros de la organización elegida; se valida en el servidor al guardar la asignación (app/lib/usuario-organizaciones/convenios.server.ts:59-66; app/routes/usuario-organizaciones/create.tsx:122).
- La organización que un vendedor puede poner en una operación se valida contra la jerarquía permitida para ese usuario y ese plan comercial: si el convenio cuelga de un ancestro se le habilita todo su árbol, y si no, solo la rama que arranca en la organización del convenio (app/lib/organization-units/queries.ts:236-435; app/routes/operaciones/[id]-alta-operacion/actions.ts:654-666).
- El convenio de una operación se resuelve recorriendo la jerarquía: si la operación está en una Sucursal se consideran sus Organizaciones hijas, y si está en una Zonal 1 o 2 se sube hasta la Organización (app/lib/organization-units/queries.ts:474-500).
- Si hay un solo convenio activo compatible el sistema lo asigna solo; si hay varios y el usuario no eligió, la operación queda sin convenio hasta que se elija; un convenio ya elegido y todavía válido no se pisa al volver a guardar (app/lib/operations/resolveConvenioSnapshot.ts:27-77).
- En el paso de datos laborales de la operación, el organismo elegible se restringe a los organismos habilitados del plan comercial de esa operación (app/routes/operaciones/[id]-alta-operacion/loader.ts:213-234).
- Los rangos de plazos de un plan no se pueden superponer entre sí (app/routes/planes-comerciales/plazos/create.tsx:101-111) y el plazo hasta debe ser mayor o igual al plazo desde (app/routes/planes-comerciales/plazos/validations.ts:60-63).
- Cambiar el plazo de gracia de una Modalidad de Cobro propaga el nuevo valor a todos los plazos de todos los planes que usan esa modalidad; en los plazos calculados desde CFTEA además se re-despeja TEM y TNA para mantener la CFTEA pactada (app/routes/payment-methods/update.tsx:208-231; app/lib/operations/plazoPlanComercial.server.ts:135-196).
- El plan comercial define las reglas duras que un cliente debe cumplir: ingreso mínimo, edad mínima y máxima diferenciada por género, situaciones BCRA y endeudamiento financiero; se validan contra el socio al asociarlo a la operación (app/lib/operations/validateClienteAgainstPlanComercial.ts:20-170).
- Si la Modalidad de Cobro tiene admiteMultiplesCreditos en false, un socio con un crédito vigente bajo esa misma modalidad no puede tener otro: es advertencia en el alta y bloqueo duro en la aprobación (prisma/schema.prisma:726; app/lib/operations/validateMultiplesCreditos.ts:36-60).
- Las plantillas de formulario aplicables a una operación se filtran por organización, modalidad de cobro y plan comercial: la configuración comercial también define qué papeles se imprimen (app/lib/formularios/resolverFormularios.server.ts:20-50).
- Solo el rol admin puede administrar organizaciones, convenios, planes comerciales, plazos, categorías, servicios sociales, sub-códigos y asignaciones usuario-organización. Modalidades de Cobro y Organismos los administran también el supervisor y el operador de área Cobranzas; Subcategorías y Dependencias de Organismo, solo el supervisor de área Cobranzas (app/routes/organizations/create.tsx:30; app/routes/convenios/create.tsx:16; app/routes/planes-comerciales/create.tsx:30; app/routes/payment-methods/create.tsx:21; app/routes/employers/create.tsx:27; app/lib/auth/authorization.ts:333-336).
- Las Subcategorías y Dependencias de Organismo no se borran: la acción de eliminar hace baja lógica poniendo activo en false, y solo los valores activos se ofrecen en el alta y edición de organismos (app/routes/subcategorias-organismo/delete.tsx:32; app/routes/dependencias-organismo/delete.tsx:32; app/routes/employers/catalogos.server.ts:14-32).
- El alta de organización puede generar el convenio en el mismo paso; en ese caso el nombre del convenio se arma como nombre de la organización más nombre del plan y se siembra el historial de comisión (app/routes/organizations/create.tsx:284-315).
Dónde vive en el código
/Users/martin.long/Documents/work/rebl/iris/app/routes.ts/Users/martin.long/Documents/work/rebl/iris/prisma/schema.prisma/Users/martin.long/Documents/work/rebl/iris/app/lib/organization-units/queries.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/organization-units/constants.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/operations/getOperacionData.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/operations/resolveConvenioSnapshot.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/operations/validateClienteAgainstPlanComercial.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/operations/plazoPlanComercial.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/operations/validateMultiplesCreditos.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/payment-methods/constants.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/planes-comerciales/haberQueAdministra.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/employers/carga-masiva.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/usuario-organizaciones/convenios.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/servicios-sociales/servicio-social-delete-guards.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/organizations/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/organizations/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/convenios/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/convenios/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/planes-comerciales/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/planes-comerciales/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/planes-comerciales/components/CommercialPlanForm.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/planes-comerciales/organismos-csv.server.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/planes-comerciales/plazos/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/planes-comerciales/plazos/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/employers/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/employers/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/employers/catalogos.server.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/payment-methods/update.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/api/organismos.by-modalidad-cobro.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/api/organization-hierarchy.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/api/planes-comerciales.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/api/modalidades-cobro.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/usuario-organizaciones/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/subcategorias-organismo/delete.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-alta-operacion/loader.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/auth/authorization.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/auth/roles.tsA tener en cuenta
- Hay dos cosas distintas que se llaman categoría y conviene no confundirlas: el modelo Categoria (menú Comercial, /categorias) clasifica ORGANISMOS empleadores, no organizaciones (prisma/schema.prisma:1445-1454). La categoría de una Organización es otro campo, un string con opciones fijas como COMERCIALIZADOR o ESTUDIO JURIDICO (app/routes/organizations/validations.ts:52-59). Que el ABM de Categorías viva bajo el módulo Comercial y no bajo Cobranzas es engañoso.
- La carga de organismos habilitados por CSV dentro del alta de un plan comercial CREA organismos que no existen, con solo nombre, modalidad y activo=true, salteándose las validaciones del ABM de organismos (subcategoría y dependencia obligatorias). Quedan organismos incompletos entrando por la puerta de atrás (app/routes/planes-comerciales/organismos-csv.server.ts:84-98).
- El selector de Modalidad de Cobro del formulario de Plan Comercial NO filtra por el flag activa: se pueden crear planes sobre modalidades inactivas, mientras que el alta de operación, el ABM de sub-códigos y la API de modalidades sí filtran (app/routes/planes-comerciales/create.tsx:33-41 contra app/routes/api/modalidades-cobro.ts:11-13).
- Ningún borrado del árbol tiene guardas de negocio propias: eliminar una Organización, un Convenio, un Plan Comercial, una Modalidad de Cobro o una Categoría intenta el delete directo y, si la base lo rechaza por integridad referencial, devuelve un error genérico del tipo Error al eliminar, por favor intente nuevamente. El usuario no se entera de qué lo bloquea. El único ABM del dominio con guardas explícitas y mensaje útil es Servicios Sociales (app/lib/servicios-sociales/servicio-social-delete-guards.ts).
- El campo Convenio.estado es un string libre con default activo y un comentario que dice activo | inactivo, no un enum de Postgres; la lista de valores válidos solo se sostiene desde Zod (prisma/schema.prisma:973; app/routes/convenios/validations.ts:5-8). Lo mismo pasa con Organizacion.tipo y ModalidadCobro.tipoModalidadCobro.
- Poner un convenio en inactivo o dejarlo vencer no valida si hay operaciones abiertas bajo ese convenio. Las operaciones que ya lo congelaron lo siguen apuntando, pero cualquier reguardado que vuelva a resolver el snapshot lo deja en nulo.
- El recorrido de ancestros de la jerarquía está hardcodeado a 3 saltos hacia arriba (padre.padre.padre), lo cual alcanza justo para los 5 niveles actuales pero se rompe si algún día se agrega un nivel más (app/lib/operations/getOperacionData.ts:86-89; app/lib/organization-units/queries.ts:326-330 y 566-570).
- En resolveOrganismoIdsFromCsvUpload la variable omitted se devuelve siempre en cero y se loguea como si contara nombres descartados: es código muerto, porque ahora todos los nombres no encontrados se crean en lugar de omitirse (app/routes/planes-comerciales/organismos-csv.server.ts:79-110).
- Subcategoría y Dependencia de Organismo son obligatorias en el formulario (Zod) pero nulables en la base (prisma/schema.prisma:783-784). Convive con datos históricos sin esos valores, y esos organismos no se pueden volver a guardar sin completarlos.
- No hay ninguna restricción de unicidad sobre el par organización más plan comercial en Convenio, así que se pueden cargar convenios duplicados sin darse cuenta. El sistema lo maneja mostrando un selector al operar, pero nada impide el error de carga.
- La creación de OrganismosPlanComercial usa createMany, que según las reglas del proyecto saltea el registro de auditoría por operación (app/routes/planes-comerciales/create.tsx:216-223). Lo mismo hace el alta de organización con usuario_organizaciones.
- El endpoint /api/organization-hierarchy y los ABM de este dominio exigen rol admin, mientras que la lógica que arma la jerarquía permitida para vender (getAllowedHierarchyForCommercialUser) es la que realmente gobierna qué ve un vendedor: son dos caminos distintos sobre el mismo árbol y conviene no confundirlos al depurar.
Originación de la operación
Es el circuito por el cual un vendedor toma una solicitud de crédito y/o de servicio social, desde que identifica al socio hasta que la solicitud queda numerada y enviada a aprobación. Arranca en una pantalla de carga donde se busca o se da de alta el socio y se elige el plan comercial, y sigue en un asistente de 6 pasos —7 si la solicitud incluye préstamo y servicio social a la vez. El sistema valida al cliente contra las reglas del plan (ingreso mínimo, edad, BCRA, endeudamiento), calcula la capacidad de descuento sobre el recibo de sueldo y la cuota por sistema francés, y recién cuando todo está completo asigna el número de operación, congela las tasas y deja la operación en esperando_aprobacion. Toda carga iniciada queda registrada como pre-solicitud para poder retomarla después.
Quién interviene
Qué lo dispara
- El vendedor entra a Operaciones > Alta de Operación (/operaciones/alta-operacion) y busca un socio por CUIL o DNI
- El vendedor retoma una carga previa desde la Bandeja de Pre-Solicitudes (/bandeja-pre-solicitudes), que linkea a /operaciones/alta-operacion?preSolicitudId=N
- El vendedor vuelve a una operación ya abierta desde la Bandeja de Operaciones, entrando a /operaciones/:id/alta-operacion
- Un admin aprueba una excepción al plan comercial: la operación vuelve a 'pendiente' y se retoma el asistente
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Abre la pantalla de Carga de Solicitud. El sistema le arma el catálogo de modalidades de cobro activas y de planes comerciales disponibles: solo los que están habilitados comercialmente y por riesgo, y para usuarios comerciales solo los que cuelgan de convenios activos a los que su organización tiene acceso.
/operaciones/alta-operacion·Solo lectura. Si viene con ?preSolicitudId=N precarga los datos del socio de esa pre-solicitud (validando que siga en 'pre_solicitud' y que el usuario tenga alcance sobre ella).
Busca al socio por CUIL o por DNI en un buscador con autocompletado, o carga uno nuevo: CUIL, DNI, apellido, nombre, sexo, fecha de nacimiento, teléfono, ingresos brutos y fecha de ingreso laboral. Aprieta 'Guardar socio'.
/operaciones/alta-operacion·Crea o actualiza el Socio, su teléfono principal y sus DatosLaborales. Además crea (o reutiliza) una pre-solicitud: una Operacion en estado 'pre_solicitud' asociada al socio, sin plan ni servicio. El numeroSocio NO se asigna acá (se asigna recién al aprobar el primer préstamo).
Elige 'Producto a vender' (Préstamos, Servicio, o Préstamo y servicio). Si incluye préstamo, elige primero la modalidad de cobro y después el plan comercial: la lista de planes se filtra por esa modalidad, por vigencia del plan (fechaInicio/fechaFin) y por el tipo de contrato laboral del socio.
/operaciones/alta-operacion·Solo estado en pantalla. Si el plan elegido no tiene anexo BCRA configurado o no tiene endeudamiento financiero configurado, se muestran advertencias; a los admin además se les avisa si el plan no tiene ningún convenio activo.
Aprieta 'Validar': el sistema chequea al cliente contra las reglas duras del plan comercial (ingreso mínimo, edades mínima/máxima por género y, si el plan lo tiene configurado, situación en la Central de Deudores del BCRA y relación de endeudamiento financiero).
/operaciones/alta-operacion·Consulta la deuda actual del CUIL en el BCRA (con caché mensual en BcraRequest). No escribe nada en la operación: el resultado es solo el semáforo verde o la lista de errores en pantalla.
Si la validación dio OK, aprieta 'Abrir operación'. Si el producto es solo servicio social, este botón aparece directo sin validar plan.
/operaciones/alta-operacion·Verifica que el socio no esté en una depuración masiva activa ni el CUIL inhabilitado, consume la pre-solicitud pendiente (la pasa a estado 'pendiente' con el plan comercial y el tipo de producto, refrescando fechaSolicitud) y redirige a /operaciones/:id/alta-operacion. Si no había pre-solicitud, crea la Operacion.
Camino alternativo: si la validación arrojó errores, puede apretar 'Excepcionar' y escribir una argumentación en un modal.
/operaciones/alta-operacion·Deja la operación en estado 'esperando_aprobacion_excepcion' guardando la argumentación, el usuario que la pidió y la lista de errores de validación, y redirige a la Bandeja de Operaciones. La operación queda fuera del asistente hasta que un admin resuelva.
Paso 1 del asistente - Verificación: completa/corrige los datos del socio (tipo de persona, nacionalidad, estado civil, e-mail, CBU, vencimiento del DNI, domicilios, teléfonos y datos laborales completos: empleador, CUIT, dependencia, cargo, legajo, ingresos, domicilio laboral).
/operaciones/:id/alta-operacion·Guarda Socio, Direcciones, Telefonos y DatosLaborales (intent saveDatosCliente). Valida que haya un solo domicilio principal, un solo teléfono principal y que las provincias vengan del catálogo.
Paso 2 - Datos de la Operación (solo si el producto incluye préstamo): elige canal comercial/vendedor (sucursal u organización), organismo empleador, carga descuento de ley, descuento por deudas en el recibo y descuento de caja de ahorro, el capital solicitado, ve los gastos del plan en modo lectura y elige el plazo de una grilla que muestra la cuota calculada para cada plazo (más el gasto de cobranza del organismo). También puede pedir saldos de cancelación/renovación de créditos vigentes.
/operaciones/:id/alta-operacion·Calcula y persiste la capacidad de descuento (cuotaMaximaDisponible y cuotaMaxima), guarda organismo, descuentos, capital solicitado, plazo, cuota final, haber que administra y organización asignada; forkea/actualiza los DatosLaborales de la operación; re-sincroniza los OperacionGasto contra la config vigente del plan y del servicio; y resuelve el snapshot de convenio para comisiones.
Paso 3 - Datos del Servicio (solo si el producto incluye servicio social): elige el servicio social del catálogo activo y ve su cuota y sus gastos asociados.
/operaciones/:id/alta-operacion·Guarda servicioSocialId en la operación y re-sincroniza los gastos. Si el servicio tiene su propia modalidad de cobro, la toma.
Paso 4 - Datos de Cobro: completa los campos dinámicos que exige la modalidad de cobro (definidos en datosDePago de la modalidad) y, si el plan y la organización tienen más de un convenio activo, elige cuál aplica.
/operaciones/:id/alta-operacion·Guarda datosPagoModalidadCobro (JSON validado por schema), plazo y cuota si vinieron, y persiste el convenio elegido como snapshot de la operación.
Paso 5 - Documentación Adjunta: sube los seis documentos requeridos (Certificado de Haberes, Constancia de CBU, DNI frente, DNI dorso, Recibo de sueldo, Servicio). Puede descargarlos o borrarlos y ve una columna con el estado del OCR.
/operaciones/:id/alta-operacion·Sube el archivo a S3, crea el DocumentoOperacion con ocrStatus 'pending' y encola un job de OCR. La pantalla hace polling del estado hasta 5 veces cada 3 segundos.
Paso 6 - Confirmar Carga: revisa el resumen completo (socio, domicilio, capacidad de descuento, capital, plazo y cuota, cancelaciones), elige la forma de pago y el CBU cuando corresponde, y aprieta Continuar.
/operaciones/:id/alta-operacion·Corre la validación completa del formulario; si falta algo, marca en rojo los pasos con errores y no envía. Si está todo: valida que haya canal comercial asignado, resuelve el haber que administra, asigna el número de operación desde la secuencia, congela el snapshot de tasas del plazo (TEM, TNA, CFTEA, % seguro, % administrativo, días de gracia), guarda forma de pago y CBU y dispara la transición ENVIAR.
Post-envío: calcula y persiste el nivel de riesgo del socio a partir de los datos de la operación y encola la generación del legajo PDF (con fallback en línea si la cola falla).
Crea el registro Legajo con el PDF unificado (documentos + matriz de riesgo) en S3. Si falla todo, la operación igual queda enviada y se avisa que se reintente desde la vista de operación.
Ve la pantalla de Resumen: 'La operación ha sido enviada para aprobación', con el número asignado, y el link a la Bandeja de Operaciones.
/operaciones/:id/alta-operacion·Ninguno. La operación queda en 'esperando_aprobacion' esperando a un aprobador.
Estados
- pre_solicitud
- Carga iniciada: se guardó el socio pero todavía no se eligió plan ni se abrió la operación. Vive en la Bandeja de Pre-Solicitudes y se puede retomar. No cuenta como operación activa del socio.
- pendiente
- Operación abierta y en carga por el vendedor. Es el estado en el que se recorre todo el asistente de 6 pasos.
- esperando_aprobacion_excepcion
- El cliente no cumplió alguna regla del plan comercial y el vendedor pidió una excepción con argumentación. Queda esperando resolución de un admin y no se puede editar en el asistente (el loader redirige a la bandeja).
- esperando_aprobacion
- El vendedor confirmó la carga: la solicitud ya tiene número de operación, tasas congeladas y legajo generado, y espera la decisión de un aprobador.
- aprobado
- Un aprobador liquidó la operación. Fuera del alcance de la originación, pero es el destino natural del flujo.
- rechazadofinal
- Estado final al que llega una excepción rechazada por el admin.
- cancelado
- Operación cancelada. No es estrictamente final: un admin puede reactivarla y devolverla a 'pendiente'.
- desistidofinal
- El socio desistió de la operación después de enviarla. Estado final.
Reglas que el sistema hace cumplir
- Toda carga iniciada queda registrada: al guardar el socio se crea o reutiliza una pre-solicitud (Operacion en estado 'pre_solicitud', sin plan ni servicio) para poder retomarla desde la bandeja - app/lib/operations/preSolicitud.server.ts:64-88
- El socio debe ser mayor de 18 años para poder guardarse desde el alta - app/routes/operaciones/alta-operacion/actions.ts:62
- El CUIL y el DNI tienen que ser consistentes: los dígitos 3 a 10 del CUIL deben coincidir con el DNI - app/routes/operaciones/alta-operacion/validationSchemas.ts:78-89
- No se puede abrir operación si el asociado está incluido en una solicitud de depuración masiva pendiente o autorizada - app/routes/operaciones/alta-operacion/actions.ts:265-272
- No se puede originar ningún producto si el CUIL/CUIT tiene una inhabilitación vigente; según el motivo, la pantalla ofrece pedir la habilitación - app/routes/operaciones/alta-operacion/actions.ts:274-287 y app/lib/inhabilitaciones/bloqueoOriginacion.ts:59-66
- El plan comercial es obligatorio cuando el producto incluye préstamo; las operaciones de solo servicio social no llevan plan - app/routes/operaciones/alta-operacion/actions.ts:288-293
- Solo se ofrecen planes con habilitadoComercial y habilitadoRiesgo en true; para usuarios comerciales, además, solo los planes que cuelgan de convenios activos a los que su organización tiene acceso - app/lib/operations/getOperacionData.ts:121-122 y 193-215
- La lista de planes se filtra por la modalidad de cobro elegida, por vigencia del plan y por el tipo de contrato laboral del socio (los planes sin tipo de contrato son compatibles con todos) - app/routes/operaciones/alta-operacion/components/ValidarPlanComercial.tsx:96-131
- La validación contra el plan comercial verifica ingreso mínimo, edad mínima y máxima según género y, si el plan lo tiene configurado, la situación en la Central de Deudores del BCRA y el endeudamiento financiero sobre el ingreso bruto - app/lib/operations/validateClienteAgainstPlanComercial.ts:73-148
- Si el BCRA no está disponible, la validación falla con un error explícito y no deja avanzar por el camino normal - app/lib/operations/validateClienteAgainstPlanComercial.ts:145
- La excepción al plan exige argumentación no vacía y solo la puede aprobar un admin, con explicación obligatoria; el rechazo exige motivo - app/state/operacionEstado/machine.ts:65-84 y 184-195, app/routes/operaciones/aprobar-excepcion/actions.ts:15 y 26-34
- Capacidad de descuento: ingreso neto = ingreso bruto x (1 - descuento ley); cuota máxima = mínimo entre (ingreso neto x afectación máxima del plan - descuentos por deudas en recibo y caja de ahorro) y (ingreso neto x RCI del plan). Si da 0 o menos, la operación no es viable - app/lib/capacidad-descuento/calculate.ts:45-70
- Si la capacidad de descuento da no viable, solo un usuario con permiso operations:override_capacidad_descuento puede cargar una cuota máxima manual, y debe documentar un motivo de al menos 10 caracteres; la excepción queda auditada - app/routes/operaciones/[id]-alta-operacion/actions.ts:818-833 y 934-955
- No se puede guardar el paso de Datos de la Operación si el socio no tiene ingresos brutos numéricos mayores a cero o si el plan comercial no tiene afectación máxima y RCI definidos - app/routes/operaciones/[id]-alta-operacion/actions.ts:773-789
- Capital prestado = capital solicitado / (1 - gastos de inicio - integración de capital), donde los gastos de inicio son el % de seguro, el % administrativo y los gastos del plan marcados como incluyeCft - app/lib/prestamos/calculoPrestamos.ts:96-108 y 155-158
- La cuota se calcula por sistema francés sobre el capital prestado usando una tasa efectiva de TEM x 1,21 (IVA sobre intereses), y la cuota final que se guarda en la operación le suma el gasto de cobranza de la modalidad - app/lib/prestamos/calculoPrestamos.ts:180-205 y app/routes/operaciones/[id]-alta-operacion/components/SeleccionPlazo.tsx:43-48
- Cuando el plan comercial administra más de un haber, elegir a qué haber va el crédito es obligatorio; si administra uno solo lo determina el plan y no se le pregunta al vendedor - app/lib/operations/haberQueAdministra.ts:37-89
- Para confirmar la carga de una operación con préstamo, la operación debe tener organización / canal comercial asignado; las de solo servicio social no lo necesitan - app/routes/operaciones/[id]-alta-operacion/actions.ts:1524-1533
- Al confirmar la carga se asigna el número de operación desde una secuencia de base de datos, con hasta 5 reintentos ante colisión, y se revierte si la transición de estado falla - app/routes/operaciones/[id]-alta-operacion/actions.ts:1600-1650
- Al confirmar la carga se congela el snapshot de tasas del plazo vigente del plan (TEM, TNA, CFTEA, % gasto seguro, % gasto administrativo y días de gracia); si no hay plazo activo que contenga el plazo elegido, la confirmación falla - app/routes/operaciones/[id]-alta-operacion/actions.ts:1613-1622
- Los seis documentos de la lista (certificado de haberes, constancia de CBU, DNI frente y dorso, recibo de sueldo y servicio) deben estar todos subidos para poder finalizar la carga - app/routes/operaciones/[id]-alta-operacion/validation.ts:356-368 y app/state/altaOperacionState.ts:154-161
- La modalidad de cobro que no admite múltiples créditos solo genera una advertencia en el alta (banner amarillo y log); el bloqueo duro se aplica recién en la aprobación - app/lib/operations/validateMultiplesCreditos.ts:50-56 y app/routes/operaciones/[id]-alta-operacion/actions.ts:1535-1548
- Una operación ya cedida (vendida en cartera) no puede modificarse en ningún paso del alta - app/routes/operaciones/[id]-alta-operacion/actions.ts:720, 1273 y 1520
- El número de socio no se asigna en el alta: se asigna recién cuando se aprueba el primer préstamo del socio - app/lib/operations/transitionEstado.server.ts:112-115 y app/routes/operaciones/alta-operacion/actions.ts:161-163
- Los gastos de la operación se re-materializan contra la configuración vigente del plan y del servicio en cada guardado del paso de datos, siempre que la operación no esté aprobada - app/routes/operaciones/[id]-alta-operacion/actions.ts:920-931 y app/lib/gastos/sincronizarGastosOperacion.ts:31-96
Dónde vive en el código
app/routes.tsapp/routes/operaciones/alta-operacion/loader.tsapp/routes/operaciones/alta-operacion/actions.tsapp/routes/operaciones/alta-operacion/index.tsxapp/routes/operaciones/alta-operacion/validationSchemas.tsapp/routes/operaciones/alta-operacion/components/CargaClienteYPlanComercial.tsxapp/routes/operaciones/alta-operacion/components/ValidarPlanComercial.tsxapp/routes/operaciones/[id]-alta-operacion/index.tsxapp/routes/operaciones/[id]-alta-operacion/loader.tsapp/routes/operaciones/[id]-alta-operacion/actions.tsapp/routes/operaciones/[id]-alta-operacion/validation.tsapp/routes/operaciones/[id]-alta-operacion/components/OperationStepper.tsxapp/routes/operaciones/[id]-alta-operacion/components/DatosCliente.tsxapp/routes/operaciones/[id]-alta-operacion/components/DatosOperacion.tsxapp/routes/operaciones/[id]-alta-operacion/components/SeleccionPlazo.tsxapp/routes/operaciones/[id]-alta-operacion/components/DatosServicio.tsxapp/routes/operaciones/[id]-alta-operacion/components/DatosCobro.tsxapp/routes/operaciones/[id]-alta-operacion/components/DocumentacionAdjunta.tsxapp/routes/operaciones/[id]-alta-operacion/components/ConfirmarCarga.tsxapp/routes/operaciones/[id]-alta-operacion/components/ResumenOperacion.tsxapp/routes/bandeja-pre-solicitudes/index.tsxapp/state/altaOperacionState.tsapp/state/altaOperacionContext.tsxapp/state/operacionEstado/machine.tsapp/lib/operations/estados.tsapp/lib/operations/preSolicitud.server.tsapp/lib/operations/validateClienteAgainstPlanComercial.tsapp/lib/operations/validateMultiplesCreditos.tsapp/lib/operations/haberQueAdministra.tsapp/lib/operations/transitionEstado.server.tsapp/lib/operations/numeroDeOperacion.server.tsapp/lib/operations/plazoPlanComercial.server.tsapp/lib/capacidad-descuento/calculate.tsapp/lib/prestamos/calculoPrestamos.tsapp/lib/gastos/sincronizarGastosOperacion.tsapp/lib/inhabilitaciones/bloqueoOriginacion.tsapp/lib/legajo/generarLegajoOperacion.tsapp/lib/auth/roles.tsapp/lib/auth/authorization.tsapp/jobs/worker.tsprisma/schema.prismaA tener en cuenta
- El stepper define 7 pasos (
OperationStepper.tsx:5-13) ybuildStepOrderarma 6 cuando el producto es sólo préstamo o sólo servicio social, y 7 cuando incluye ambos. El último,resumen, no es una pantalla de carga sino la vista final de la solicitud ya enviada. - El OCR nunca marca un documento como 'completed': el bloque que persiste el resultado está comentado en app/jobs/worker.ts:119-152, así que el documento queda en 'processing' salvo que falle. La pantalla hace 5 reintentos de polling y termina mostrando el cartel 'No se pudo determinar el estado de extracción del OCR', que asusta al vendedor pero no bloquea la confirmación (la validación final solo exige que el archivo esté subido).
- La validación contra el plan comercial se ejecuta una sola vez, antes de abrir la operación, y su resultado no se persiste ni se vuelve a correr al confirmar la carga: el ENVIAR se dispara con formValidationPassed y planValidationPassed en true hardcodeados (app/routes/operaciones/[id]-alta-operacion/actions.ts:1662-1665). Se puede abrir la operación con datos que validaban, cambiar ingresos o edad en el paso 1 y enviarla igual.
- La integración con Nosis está a medio camino: existe el intent 'validateNosis' (app/routes/operaciones/alta-operacion/actions.ts:543-590) y el componente NosisValidationResult.tsx, pero ninguna pantalla los usa. Es código muerto que puede confundir sobre qué burós se consultan realmente (hoy solo BCRA).
- Las condiciones que parecen exigir que capital solicitado y los descuentos sean distintos de cero en realidad no bloquean nada: la condición se cumple con el valor '0' pero después isEmpty('0') da false y nunca se registra el error (app/routes/operaciones/[id]-alta-operacion/validation.ts:243 y 275). En la práctica se puede confirmar una operación con capital solicitado en 0.
- El campo capitalMaximo (que la pantalla de Confirmar Carga muestra como 'Capital Otorgado') nunca se escribe durante el alta: saveDatosOperacion solo persiste capitalSolicitado, cuotaMaxima y cuotaMaximaDisponible, así que ese dato aparece vacío hasta que se aprueba la operación.
- La edad del socio para validar contra el plan se calcula por resta de años calendario (app/lib/operations/validateClienteAgainstPlanComercial.ts:86), no por fecha exacta de cumpleaños, así que puede diferir en un año respecto de la edad real en los bordes.
- El estado del asistente vive únicamente en memoria (máquina xstate del cliente): si el vendedor recarga la página vuelve al paso de Verificación y el trabajo no guardado se pierde. Hay un guard de cambios sin guardar que avisa antes de navegar, pero no hay autoguardado por paso.
- La regla de múltiples créditos por modalidad de cobro es solo informativa durante toda la originación: el vendedor puede cargar y enviar la operación completa, y recién en la aprobación se entera de que no se puede liquidar sin cancelar o renovar el crédito vigente. Es retrabajo garantizado en esos casos.
- Los seis documentos requeridos son fijos y no dependen del tipo de producto ni de la modalidad de cobro: una operación de solo servicio social igual necesita certificado de haberes y recibo de sueldo para poder confirmarse.
- La transición pre_solicitud a pendiente no pasa por la máquina de estados: consumePreSolicitud escribe el estado directamente (app/lib/operations/preSolicitud.server.ts:104-135), y 'pre_solicitud' ni siquiera existe como nodo de la máquina, que arranca en 'pendiente'. Los estados de la excepción creada desde el alta también se escriben en forma directa.
Análisis de riesgo y scoring
IRIS combina tres fuentes externas para evaluar el riesgo de un socio: la Central de Deudores del BCRA (deuda actual e historial), el bureau Nosis (score y variables crediticias) y el INDEC (canasta básica). El BCRA es la única fuente que efectivamente corta o deja pasar una operación: en el alta valida el anexo BCRA y el endeudamiento financiero configurados en el plan comercial, y al confirmar la carga alimenta la Matriz de Riesgo (Anexo II), que puntúa 11 factores de 1 a 55 y persiste el resultado en Socio.nivelRiesgo. Nosis vive en una pantalla de consulta manual (/analisis-riesgo) que no bloquea nada, y la canasta básica se descarga y guarda pero hoy no alimenta ningún cálculo. En paralelo, la capacidad de descuento define la cuota máxima admisible a partir del recibo de sueldo y los topes del plan comercial.
Quién interviene
Qué lo dispara
- El operador presiona 'Validar' al asociar un socio a un plan comercial en el alta de operación: dispara la consulta BCRA de deuda actual si el plan lo exige.
- El operador guarda los datos de la operación de préstamo: dispara el cálculo de capacidad de descuento.
- El operador confirma la carga y envía la operación a aprobación (evento ENVIAR): dispara el cálculo y la persistencia del nivel de riesgo.
- El usuario abre /operaciones/:operacionId/matriz-riesgo para descargar el PDF del cuadro normativo.
- El job de legajo se encola tras el envío a aprobación y adjunta la matriz de riesgo al legajo.
- El usuario busca un documento en /analisis-riesgo y presiona 'Actualizar datos': consulta viva a Nosis.
- Cron mensual del día 15 a las 13:00 UTC que baja la canasta básica del INDEC, o el botón manual en /canasta-basica.
- El usuario abre la ficha del socio y despliega el informe crediticio BCRA (deuda actual, histórica y cheques rechazados).
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Carga o selecciona el socio, elige el plan comercial y presiona 'Validar' para chequear si el socio califica.
/operaciones/alta-operacion·Envía el intent validateClienteAgainstPlanComercial con socioId y planComercialId. Todavía no escribe nada en la base.
Valida ingreso mínimo, edades por género y que el socio no esté incluido en una depuración masiva pendiente.
/operaciones/alta-operacion·Acumula mensajes de error en una lista. Sin escrituras en base.
Si el plan tiene el anexo BCRA configurado o un tope de endeudamiento financiero, busca la deuda actual del socio en la Central de Deudores con estrategia cache-first: reutiliza la última consulta cacheada si es del mes en curso, si no consulta BCRA en vivo.
/operaciones/alta-operacion·Si consulta en vivo, crea un registro en bcra_requests con endpoint 'deuda_actual', el request, la respuesta cruda y el usuario que la disparó (auditoría de consumo del servicio pago).
Aplica las reglas del anexo BCRA del plan: por cada situación 1 a 6 controla la cantidad de entidades informadas, la sumatoria de deuda contra el monto tope y el equivalente en sueldos brutos; además valida los topes agregados de situaciones 3, 4 y 5 (cantidad y porcentaje sobre la deuda total).
/operaciones/alta-operacion·Devuelve un mensaje de error por cada tope superado. Los montos de BCRA vienen en miles y se multiplican por 1000 al normalizarlos.
Valida el endeudamiento financiero: la deuda total informada por BCRA dividida por el ingreso bruto del socio debe ser estrictamente menor al tope de sueldos del plan comercial.
/operaciones/alta-operacion·Agrega un error si el ratio deuda sobre sueldo alcanza o supera el tope. Si el socio no tiene ingresos brutos registrados, también es error.
Si la validación pasó, abre la operación. Si falló, puede pedir una excepción escribiendo una argumentación.
/operaciones/alta-operacion·Abre la operación en estado 'pendiente', o crea/convierte la pre-solicitud en una operación en 'esperando_aprobacion_excepcion' guardando argumentacionExcepcion y el array erroresValidacionPlanComercial con los mensajes que fallaron.
Revisa la excepción y la aprueba con explicación o la rechaza con motivo.
/operaciones/:operacionId/aprobar-excepcion·APROBAR_EXCEPCION lleva la operación a 'pendiente' y limpia los errores de validación guardados; RECHAZAR_EXCEPCION la lleva a 'rechazado'. Solo el rol admin puede hacerlo.
Carga los datos del préstamo: descuento de ley, descuentos por deudas en recibo, caja de ahorro, capital, plazo y haber que administra.
/operaciones/:operacionId/alta-operacion·Calcula la capacidad de descuento y persiste cuotaMaximaDisponible y cuotaMaxima en la operación.
Calcula la capacidad de descuento: ingreso neto = bruto menos descuento de ley; capacidad de afectación = neto por afectación máxima del plan; disponible = capacidad menos descuentos del recibo y caja de ahorro; límite RCI = neto por RCI del plan. La cuota máxima es el menor entre disponible y límite RCI.
/operaciones/:operacionId/alta-operacion·Marca la operación como viable solo si la cuota máxima resulta mayor a cero.
Si la operación no es viable por capacidad de descuento, puede forzar una cuota máxima manual con un motivo documentado.
/operaciones/:operacionId/alta-operacion·Requiere el permiso operations:override_capacidad_descuento y un motivo de al menos 10 caracteres. Crea un AuditLog de tipo OperacionCapacidadDescuentoOverride con el motivo, la cuota manual y el cálculo original.
Confirma la carga de la operación y la envía a aprobación.
/operaciones/:operacionId/alta-operacion·Asigna número de operación, forma de pago y snapshot de tasas, y dispara el evento ENVIAR que lleva la operación a 'esperando_aprobacion'.
Obtiene la señal de historial crediticio BCRA del socio: reutiliza la consulta histórica cacheada si tiene menos de 3 meses; si está vencida o no existe consulta en vivo; si la consulta viva falla usa la cache vencida como último recurso.
/operaciones/:operacionId/alta-operacion·Cuenta la cantidad de períodos mensuales informados. Si consulta en vivo, graba un registro nuevo en bcra_requests con endpoint 'deudas_historicas'. Un socio sin historial en BCRA (not_found) cuenta 0 períodos.
Calcula la Matriz de Riesgo Personas Humanas puntuando 11 factores: demostración de ingresos, lugar de residencia, historial BCRA, actividad, PEP, línea de producto, canal de atención, sujeto obligado, medios de pago, documentación completa y trabas para obtener información. Cada factor vale 5, 3 o 1 punto.
/operaciones/:operacionId/alta-operacion·Suma los puntajes (máximo 55) y deriva la categoría BAJO, MEDIO o ALTO según los umbrales de la hoja Valoración.
Persiste el puntaje total en el socio.
/operaciones/:operacionId/alta-operacion·Actualiza Socio.nivelRiesgo con el total de 1 a 55. El cálculo está envuelto en try/catch: si falla se loguea y devuelve null, nunca interrumpe el envío a aprobación.
Genera el legajo PDF de la operación e intenta adjuntarle el cuadro de la matriz de riesgo.
Si no hay ninguna consulta BCRA cacheada, loguea un warning y arma el legajo sin la matriz (el job no recibe userId, así que no puede consultar BCRA en vivo).
Descarga el PDF del cuadro de la matriz de riesgo desde la vista de la operación.
/operaciones/:operacionId/matriz-riesgo·Recalcula la matriz en modo solo lectura (no toca Socio.nivelRiesgo). Reutiliza la cache BCRA aunque esté vencida; solo consulta en vivo si no existe ninguna consulta previa. Si no hay dato BCRA devuelve 404 con mensaje explicativo.
Consulta el dashboard de riesgo Nosis por documento: score vigente, tendencia, endeudamiento vigente e histórico, tarjetas de crédito, compromiso mensual, cheques rechazados, juicios y quiebras, referencias comerciales, consultas al bureau y compliance PEP.
/analisis-riesgo·El loader muestra la última consulta cacheada en nosis_requests sin llamar a la API. El botón 'Actualizar datos' consulta Nosis en vivo y graba un registro nuevo. Es puramente informativo: no bloquea ni altera ninguna operación.
Consulta y actualiza manualmente los valores de canasta básica alimentaria y total publicados por INDEC.
/canasta-basica·Descarga el XLS de INDEC, toma la última fila con datos y hace upsert en datos_canasta_basica por mes. El cron mensual hace exactamente lo mismo.
Consulta a demanda el informe crediticio completo del socio: deuda actual, deuda histórica y cheques rechazados.
/socios/:id/ver·Llama a las rutas api/bcra/deudores/* que exigen el permiso bcra:read. Cada llamada consulta BCRA en vivo y graba en bcra_requests, alimentando de paso la cache que después usa la matriz de riesgo.
Estados
- BAJO
- Categoría de riesgo del socio cuando el puntaje total de la matriz está entre 45 y 55.
- MEDIO
- Categoría de riesgo del socio cuando el puntaje total está entre 27.6 y 44.
- ALTO
- Categoría de riesgo del socio cuando el puntaje total es 27.5 o menos. También es el valor por defecto si el puntaje no se pudo derivar.
- ok
- Resultado de la consulta de deuda actual a BCRA: hay datos (de cache del mes en curso o de consulta viva) y se pueden aplicar las reglas del plan comercial.
- sin_datos
- BCRA respondió not_found para ese CUIL: el socio no tiene deuda registrada. Se trata como situación limpia, no como error.
- no_disponible
- No se pudo consultar la Central de Deudores por red, timeout o CUIL inválido. Genera un error de validación que es excepcionable.
- sin_consulta_bcrafinal
- Motivo por el que no se puede generar el PDF de la matriz de riesgo: no hay ninguna consulta BCRA cacheada ni se pudo consultar en vivo.
- operacion_sin_sociofinal
- Motivo por el que no se puede generar la matriz de riesgo: la operación no tiene socio asociado.
- pendiente
- Estado de la operación tras pasar la validación contra el plan comercial, o tras la aprobación de la excepción. Desde acá se cargan los datos del préstamo y se calcula la capacidad de descuento.
- esperando_aprobacion_excepcion
- La operación incumplió alguna regla del plan comercial (anexo BCRA, endeudamiento financiero, ingreso mínimo o edad) y quedó esperando que un admin apruebe la excepción.
- esperando_aprobacion
- Estado al que llega la operación con el evento ENVIAR. Es el momento exacto en que se calcula y persiste el nivel de riesgo del socio.
- rechazadofinal
- La excepción fue rechazada por el admin. La operación no avanza.
Reglas que el sistema hace cumplir
- Cada factor de la matriz de riesgo vale 5 (bajo), 3 (medio) o 1 (alto) punto. app/lib/riesgo/matrizRiesgo.ts:13-17
- Son 11 factores, por lo que el puntaje máximo posible es 55. app/lib/riesgo/matrizRiesgo.ts:22-23
- Umbrales de categoría: hasta 27.5 es ALTO, hasta 44 es MEDIO, de ahí en adelante BAJO. Si el puntaje no es un número finito la categoría queda en ALTO por defecto. app/lib/riesgo/matrizRiesgo.ts:39-52 y app/lib/riesgo/calcularNivelRiesgo.ts:168
- El historial crediticio BCRA califica BAJO solo si la consulta histórica informa 24 o más períodos mensuales; con menos, o sin datos, califica MEDIO. Nunca puede dar ALTO. app/lib/riesgo/matrizRiesgo.ts:31 y app/lib/riesgo/calcularNivelRiesgo.ts:90-93
- Los factores 'Canal de atención' y 'Pone trabas para obtener la información' se asignan siempre BAJO (5 puntos) porque no hay fuente de datos en el sistema; están marcados como TODO en el código. El factor 'Línea de producto' también es siempre BAJO por definición de la matriz. app/lib/riesgo/calcularNivelRiesgo.ts:157-164
- La demostración de ingresos da BAJO solo si los datos laborales tienen recibo; en cualquier otro caso da ALTO. app/lib/riesgo/calcularNivelRiesgo.ts:76-78
- El lugar de residencia da ALTO si el código postal o el nombre de la localidad principal del socio coincide con la lista de 12 localidades de frontera: Formosa Capital, Clorinda, Pilcomayo, La Quiaca, Humahuaca, Posadas, Tartagal, Orán, Santa Victoria, Comandante Andresito, San Antonio y Bernardo de Irigoyen. app/lib/riesgo/matrizRiesgo.ts:87-118
- La actividad da BAJO si la dependencia laboral contiene 'depend', 'jubilad' o 'retirad'; cualquier otro valor presente da MEDIO, y si el campo está vacío da BAJO. app/lib/riesgo/calcularNivelRiesgo.ts:96-103
- Es PEP quien tiene esPEP igual a 2 en el formulario del socio (1 significa No); ser PEP o ser sujeto obligado puntúan ALTO. app/lib/riesgo/calcularNivelRiesgo.ts:106-114
- Medios de pago: transferencia y reintegro comercializador puntúan BAJO, cheque MEDIO y efectivo ALTO. Cualquier forma de pago no reconocida cae en BAJO. app/lib/riesgo/calcularNivelRiesgo.ts:117-129
- La documentación se considera completa (BAJO) solo con los cuatro documentos requeridos: dni_frente, dni_dorso, recibo_sueldo y certificado_haberes. Con alguno da MEDIO, con ninguno da ALTO. app/lib/riesgo/matrizRiesgo.ts:81 y app/lib/riesgo/calcularNivelRiesgo.ts:131-142
- El nivel de riesgo se persiste en Socio.nivelRiesgo únicamente al confirmar la carga y enviar la operación a aprobación; la generación del PDF de la matriz es solo lectura y no lo actualiza. app/routes/operaciones/[id]-alta-operacion/actions.ts:1691 y app/lib/riesgo/generarMatrizRiesgoOperacion.server.ts:22-23
- Si el cálculo del nivel de riesgo falla por cualquier motivo, se loguea y devuelve null: nunca bloquea el envío a aprobación de la operación. app/lib/riesgo/persistirNivelRiesgo.server.ts:84-87
- La consulta histórica de BCRA se cachea 3 meses. Vencida o inexistente se consulta en vivo; si la consulta viva falla se usa la cache vencida como último recurso antes de devolver 'sin historial'. app/lib/riesgo/obtenerHistorialBcra.server.ts:15, 75 y 103-109
- La consulta de deuda actual de BCRA se cachea solo dentro del mes calendario en curso; fuera de eso se consulta en vivo y, si la consulta falla, NO se reutiliza la cache vencida. app/lib/bcra/getDeudaActual.server.ts:47-55
- Un not_found de BCRA significa que el socio no tiene deuda registrada y valida OK; un error de red o timeout genera un error de validación excepcionable. app/lib/bcra/getDeudaActual.server.ts:63-66 y app/lib/operations/validateClienteAgainstPlanComercial.ts:147-149
- Solo se consulta BCRA en el alta si el plan comercial tiene el anexo BCRA configurado o un tope de endeudamiento financiero definido. Si no tiene ninguno de los dos, se emiten warnings informativos y la operación pasa sin consultar. app/lib/operations/validateClienteAgainstPlanComercial.ts:112-113 y 159-165
- Por situación BCRA (1 a 6) se controlan tres topes independientes: cantidad de entidades informadas, sumatoria de deuda contra el monto tope (la igualdad se permite) y equivalente en sueldos (acá la igualdad SÍ falla, el ratio debe ser estrictamente menor). app/lib/operations/validateSituacionBcra.ts:61-90
- Un tope en 0 es un límite real que rechaza todo, no significa 'sin configurar': todos los chequeos comparan contra null, nunca por truthiness. app/lib/operations/validateSituacionBcra.ts:26-28
- Topes agregados de situaciones 3, 4 y 5: cantidad máxima de entidades combinadas, y porcentaje de la deuda 3-4-5 sobre la deuda total informada (la igualdad se permite). Si la deuda total es 0 no hay nada que validar. app/lib/operations/validateSituacionBcra.ts:93-116
- El endeudamiento financiero compara la deuda total informada por BCRA contra el ingreso BRUTO del socio (no el neto), y el ratio debe ser estrictamente menor al tope en sueldos del plan. app/lib/operations/validateEndeudamientoFinanciero.ts:36-42
- Los montos que devuelve BCRA vienen expresados en miles y se multiplican por 1000 al normalizarlos. app/lib/bcra/normalizer.ts:28-34
- La deuda actual se lee del período más reciente que informa BCRA; la deuda histórica toma, por período, la peor situación informada y la suma de montos de todas las entidades. app/lib/bcra/normalizer.ts:62-110
- Capacidad de descuento: ingreso neto = bruto por (1 menos descuento de ley); capacidad de afectación = neto por afectación máxima del plan; disponible = capacidad menos descuentos de recibo y caja de ahorro; límite RCI = neto por RCI del plan; la cuota máxima es el menor entre disponible y límite RCI. app/lib/capacidad-descuento/calculate.ts:53-58
- La operación es viable solo si la cuota máxima resulta estrictamente mayor a cero; si es negativa se normaliza a cero. app/lib/capacidad-descuento/calculate.ts:59-69
- Forzar una cuota máxima manual cuando la operación no es viable exige el permiso operations:override_capacidad_descuento y un motivo de al menos 10 caracteres, y queda registrado en un AuditLog de tipo OperacionCapacidadDescuentoOverride junto con el cálculo original. app/routes/operaciones/[id]-alta-operacion/actions.ts:818-831 y 933-948
- Sin ingresos brutos numéricos mayores a cero, o sin afectación máxima y RCI definidos en el plan comercial, no se puede guardar la operación de préstamo. app/routes/operaciones/[id]-alta-operacion/actions.ts:775-790
- El permiso bcra:read solo lo tienen el admin, el supervisor de área Operaciones y Riesgo y el operador de área Operaciones y Riesgo; el operador de sucursal Operaciones y Riesgo NO lo tiene. app/lib/auth/authorization.ts:346 y 430
- Las pantallas /analisis-riesgo y /canasta-basica exigen rol admin o alguno de los tres roles de Operaciones y Riesgo. app/routes/analisis-riesgo/index.tsx:17 y app/routes/canasta-basica/index.tsx:24
- El documento buscado en /analisis-riesgo debe tener entre 7 y 11 dígitos numéricos. app/routes/analisis-riesgo/index.tsx:13
- La cache de Nosis no tiene vencimiento: el loader siempre muestra la última consulta guardada y solo se refresca si el usuario aprieta 'Actualizar datos'. app/lib/nosis/index.ts:285-301 y app/routes/analisis-riesgo/index.tsx:54-58
- Con la variable de entorno NOSIS_MOCK igual a 1 el cliente devuelve variables aleatorias y las persiste igual en nosis_requests, para demos sin acceso a la API. app/lib/nosis/index.ts:190-192
- Toda consulta a BCRA y a Nosis se persiste con el userId que la disparó, funcionando como auditoría de consumo de servicios pagos. app/lib/bcra/client.ts:83-95 y app/lib/nosis/index.ts:205-212
- El identificador que se manda a BCRA debe ser un CUIL o CUIT de 11 dígitos con prefijo válido (20, 23, 24, 27, 30, 33 o 34) y dígito verificador correcto, validado antes de salir a la red. app/lib/bcra/index.ts:17-69
- El cron de canasta básica corre el día 15 de cada mes a las 13:00 UTC y hace upsert por mes sobre datos_canasta_basica. app/jobs/worker.ts:496-503 y 249-265
- El legajo de la operación intenta adjuntar el PDF de la matriz de riesgo; si no hay ninguna consulta BCRA cacheada, se genera el legajo sin la matriz y solo queda un warning en el log. app/lib/legajo/generarLegajoOperacion.ts:52-63
- El PDF de la matriz por pantalla reutiliza la cache BCRA aunque esté vencida y solo consulta en vivo si no existe ninguna consulta previa, para no gastar consultas innecesarias. app/lib/riesgo/generarMatrizRiesgoOperacion.server.ts:69-77
Dónde vive en el código
app/lib/riesgo/matrizRiesgo.tsapp/lib/riesgo/calcularNivelRiesgo.tsapp/lib/riesgo/persistirNivelRiesgo.server.tsapp/lib/riesgo/obtenerHistorialBcra.server.tsapp/lib/riesgo/generarMatrizRiesgoOperacion.server.tsapp/lib/riesgo/cuadroMatrizRiesgo.tsapp/lib/riesgo/generarMatrizRiesgoPdf.tsapp/lib/bcra/client.tsapp/lib/bcra/index.tsapp/lib/bcra/normalizer.tsapp/lib/bcra/getDeudaActual.server.tsapp/lib/bcra/situacionesPlanComercial.tsapp/lib/bcra/types.tsapp/lib/operations/validateClienteAgainstPlanComercial.tsapp/lib/operations/validateSituacionBcra.tsapp/lib/operations/validateEndeudamientoFinanciero.tsapp/lib/operations/bcraValidationHelpers.tsapp/lib/operations/estados.tsapp/lib/capacidad-descuento/calculate.tsapp/lib/nosis/index.tsapp/lib/nosis/risk-variables.tsapp/lib/canasta-basica/index.tsapp/routes/analisis-riesgo/index.tsxapp/routes/analisis-riesgo/components/RiskDashboard.tsxapp/routes/analisis-riesgo/components/RiskScoreIndicator.tsxapp/routes/analisis-riesgo/components/RiskVariableGroup.tsxapp/routes/canasta-basica/index.tsxapp/routes/api/bcra/deudores.actual.tsapp/routes/api/bcra/deudores.historicas.tsapp/routes/api/bcra/deudores.cheques-rechazados.tsapp/routes/api/bcra/deudores.shared.tsapp/routes/operaciones/[id]-ver-operacion/matriz-riesgo.tsxapp/routes/operaciones/alta-operacion/actions.tsapp/routes/operaciones/[id]-alta-operacion/actions.tsapp/routes/socios/components/BcraCreditReport.tsxapp/components/riesgo/NivelRiesgoBadge.tsxapp/lib/legajo/generarLegajoOperacion.tsapp/jobs/worker.tsapp/jobs/queue.tsapp/lib/auth/roles.tsapp/lib/auth/authorization.tsapp/state/operacionEstado/machine.tsprisma/schema.prismadocs/plan-analisis-de-riesgo.mddocs/plan-anexo-bcra-plan-comercial.mddocs/plan-canasta-basica.mddocs/nosis/README.mddocs/nosis/risk-variables.mdA tener en cuenta
- Nosis no participa de ninguna decisión automática. El score SCO_Vig y las 10 familias de variables de riesgo solo se muestran en /analisis-riesgo para lectura humana: no hay ningún corte, tope ni regla del sistema que lo lea. Además, la semaforización del score (800 o más bajo riesgo, 650 o más medio, resto alto) está hardcodeada en un componente de presentación. app/routes/analisis-riesgo/components/RiskScoreIndicator.tsx:12-18
- La canasta básica es hoy una isla de datos: el job mensual y la pantalla la guardan en datos_canasta_basica, pero ningún cálculo de riesgo, capacidad de descuento ni plan comercial la lee. Está lista para usarse (por ejemplo como piso de subsistencia sobre el ingreso disponible) pero todavía no se usa.
- Hay código muerto de Nosis en el alta de operación: el intent validateNosis (app/routes/operaciones/alta-operacion/actions.ts:544) y el componente NosisValidationResult.tsx no se invocan desde ninguna pantalla. La validación de identidad contra Nosis (fallecido, razón social, fecha de nacimiento) existe en el código pero no está cableada al flujo.
- Dos de los once factores de la matriz ('Canal de atención' y 'Pone trabas para obtener la información') están fijos en 5 puntos con un TODO en el código porque no hay fuente de datos. Eso infla sistemáticamente el puntaje en hasta 10 puntos sobre un máximo de 55 (casi el 20 por ciento del score no mide nada real) y empuja socios hacia la categoría BAJO.
- El factor Historial crediticio BCRA nunca puede dar riesgo ALTO: el peor caso posible es MEDIO. Un socio sin ningún historial en BCRA puntúa igual que uno con 20 meses de historial informado.
- El nivel de riesgo se guarda a nivel Socio (un único campo Socio.nivelRiesgo), no a nivel operación. Cada nueva operación que se envía a aprobación pisa el puntaje anterior, y no queda histórico del puntaje ni del detalle de factores en la base: solo se puede reconstruir regenerando el PDF, que a su vez depende de la cache BCRA vigente en ese momento.
- El PDF de la matriz de riesgo se recalcula cada vez que se abre, con los datos actuales del socio y de la operación. Un legajo generado hoy y el PDF descargado dentro de tres meses pueden mostrar categorías distintas para la misma operación.
- Ventanas de cache inconsistentes entre endpoints BCRA: la deuda actual se considera fresca solo dentro del mes calendario en curso (una consulta del día 31 vence el día 1), mientras que la histórica dura 3 meses corridos. Además, ante fallo de red, la deuda actual no admite fallback a cache vencida pero la histórica sí.
- El job de legajo llama a generarMatrizRiesgoOperacionPdf sin userId, por lo que nunca puede consultar BCRA en vivo. Si el socio no tiene ninguna consulta BCRA previa, el legajo se genera sin la matriz de riesgo y solo queda un warning en el log, sin alerta al negocio.
- El operador de sucursal Operaciones y Riesgo ve el módulo Riesgo en el sidebar pero no tiene el permiso bcra:read, así que no puede desplegar el informe crediticio desde la ficha del socio. Es una asimetría de permisos que probablemente sorprenda al usuario.
- Una caída de BCRA no frena la originación: el error 'No se pudo consultar la Central de Deudores' es excepcionable, con lo cual una caída prolongada del servicio se traduce en una avalancha de operaciones esperando aprobación de admin en lugar de un bloqueo duro.
- El anexo BCRA usa el ingreso BRUTO del socio para calcular el equivalente en 'sueldos netos', tal como está documentado y asumido en docs/plan-anexo-bcra-plan-comercial.md. La etiqueta del campo dice netos pero el cálculo es sobre brutos, y no aplica el descuento de ley que sí usa la capacidad de descuento.
- La forma de pago no reconocida cae en riesgo BAJO por defecto en el factor de medios de pago (app/lib/riesgo/calcularNivelRiesgo.ts:128), lo cual es el sentido contrario al conservador esperable en una matriz de prevención de lavado.
- La lista de localidades de riesgo alto está hardcodeada en el código (12 entradas de zonas de frontera). Cambiarla requiere un deploy, no es configurable por el negocio.
- Un socio puede quedar con nivelRiesgo en null indefinidamente si nunca se le envió una operación a aprobación, y en ese caso el badge de riesgo simplemente no se muestra.
- La cache de Nosis no vence nunca: la pantalla puede estar mostrando un score de hace un año sin ninguna señal visual de obsolescencia más allá de la fecha de última actualización.
Ciclo de vida y aprobación
Describe el recorrido completo de una solicitud desde que el vendedor la abre hasta que queda liquidada o cerrada. El corazón del circuito es una máquina de estados XState (app/state/operacionEstado/machine.ts) que corre del lado del servidor y valida con guardas de rol y de negocio cada cambio de estado. Cuidado con una lectura habitual: rechazar una operación no la mata. RECHAZAR desde esperando_aprobacion la devuelve a pendiente para que ventas la corrija; al estado final rechazado sólo se llega rechazando una excepción. De los 13 eventos que define la máquina, sólo 5 están cableados a una pantalla (ENVIAR, APROBAR, RECHAZAR, APROBAR_EXCEPCION y RECHAZAR_EXCEPCION); el resto de los estados los escriben otros módulos directamente en la base, salteando la máquina — incluida la entrada al circuito de excepciones y el estado inicial real de toda operación, pre_solicitud, que ni siquiera existe dentro de la máquina.
pagado lo escribe Tesorería directamente en la base, salteando la máquina — por eso el mail de préstamo liquidado nunca sale.Quién interviene
Qué lo dispara
- Acción de usuario: 'Guardar socio' en /operaciones/alta-operacion crea una pre-solicitud (Operacion embrionaria en estado pre_solicitud) para que la carga sea retomable
- Acción de usuario: validar el socio contra el plan comercial y abrir la operación (intent abrirOperacion) la lleva a pendiente
- Acción de usuario: solicitar excepción cuando la validación contra el plan comercial falla (intent excepcionarClienteAgainstPlanComercial) la lleva directo a esperando_aprobacion_excepcion
- Acción de usuario: 'Confirmar carga' en /operaciones/:id/alta-operacion dispara el evento ENVIAR
- Acción de usuario: aprobar o rechazar desde /operaciones/:id/aprobar-operacion (bandeja de operaciones)
- Acción de usuario: aprobar o rechazar la excepción desde /operaciones/:id/aprobar-excepcion (sólo admin)
- Acción de usuario en otro módulo: marcar pagado un pago masivo en /pagos-masivos/:id/marcar-pagado deja la operación en pagado
- Acción de usuario en otro módulo: registrar el pago de un pedido de saldo o imputar una renovación deja el crédito en cancelado_anticipadamente o cancelado_anticipadamente_finalizado
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Guarda los datos del socio en el alta. El sistema crea (o reutiliza) una pre-solicitud: una Operacion en estado pre_solicitud, sin plan comercial ni producto, para que toda carga iniciada quede registrada y se pueda retomar.
/operaciones/alta-operacion·Crea Operacion con estado='pre_solicitud', fechaSolicitud, creadoPorId y datosLaboralesId. Un índice parcial único garantiza una sola pre-solicitud pendiente por socio.
Elige el plan comercial y valida al socio contra él: ingreso mínimo, edad, situación BCRA, endeudamiento financiero, anexo BCRA y depuración masiva.
/operaciones/alta-operacion·No persiste estado. Devuelve la lista de errores que después se congela en erroresValidacionPlanComercial si se pide excepción.
Camino feliz: si la validación pasa, abre la operación. Se consume la pre-solicitud y pasa a pendiente. Antes se bloquea si el socio tiene una inhabilitación de CUIL vigente o una depuración masiva activa.
/operaciones/alta-operacion·Actualiza la Operacion a estado='pendiente' con planComercialId, flags de tipo de producto y fechaSolicitud refrescada. Redirige a /operaciones/:id/alta-operacion.
Camino de excepción: si la validación falla, puede pedir una excepción escribiendo una argumentación. La operación nace directamente en esperando_aprobacion_excepcion, sin pasar por pendiente.
/operaciones/alta-operacion·Operacion con estado='esperando_aprobacion_excepcion', argumentacionExcepcion, erroresValidacionPlanComercial (JSON) y excepcionSolicitadaPorId. Redirige a /bandeja-operaciones.
Abre la excepción desde la bandeja y la aprueba (explicación obligatoria) o la rechaza (motivo obligatorio). Aprobar la excepción NO aprueba el crédito: sólo desbloquea la carga.
/operaciones/:operacionId/aprobar-excepcion·Si aprueba: estado='pendiente', explicacionAprobacionExcepcion, excepcionadoPorId, y se limpian los errores de validación en el contexto. Si rechaza: estado='rechazado' (estado final; el motivo NO se persiste hoy).
Completa la operación en el stepper: datos del cliente, datos laborales, plazo y capital, documentación, gastos, forma de pago y CBU. Puede además simular y crear pedidos de saldo por renovación o refinanciación.
/operaciones/:operacionId/alta-operacion·Actualizaciones parciales de la Operacion, DatosLaborales, DocumentoOperacion, OperacionGasto y PedidoSaldo. El estado sigue en pendiente.
Confirma la carga. Antes de enviar se valida que la operación no esté cedida, que tenga vendedor/organización si incluye préstamo, y se congelan las tasas del plazo del plan comercial. Se asigna el número de operación (con reintentos ante colisión). Recién ahí se dispara ENVIAR.
/operaciones/:operacionId/alta-operacion·Asigna numeroDeOperacion, formaDePago, formaDePagoCbu y el snapshot de tasas (tem, tna, cftea, porcentajes de gasto, plazoGraciaDias). Transiciona a estado='esperando_aprobacion'. Si la transición falla, revierte el número y las tasas. Luego recalcula el nivel de riesgo del socio y encola la generación del legajo PDF (con fallback inline si la cola falla).
Ve la operación en la bandeja filtrada por estado y accede a la pantalla de aprobación. Sólo llega ahí si tiene rol aprobador y permiso operations:approve, y si la operación está en esperando_aprobacion.
/bandeja-operaciones·Sólo lectura. La bandeja excluye siempre las pre-solicitudes y las operaciones de compras de cartera anuladas. Ventas ve únicamente las operaciones de sus organizaciones.
Ingresa el capital otorgado (puede diferir del solicitado) y aprueba. El sistema corre una batería de bloqueos duros antes de tocar nada: socio activo, socio asociado, operación no cedida, vendedor asignado, número de operación existente, tope de múltiples créditos por modalidad de cobro, haber que administra resuelto y configuración contable completa.
/operaciones/:operacionId/aprobar-operacion·En una única transacción: recalcula los gastos porcentuales sobre el capital otorgado, calcula la cuota final (francés + gasto de cobranza de la modalidad), integra el capital cooperativo y el de servicio social, fija la fecha de ingreso del socio si es su primera aprobación, activa la suscripción de cuota social, crea el Pago 'PRESTAMO' en estado pendiente por el monto a desembolsar (neto de acción cooperativa, gastos con cancelación automática y pedidos de saldo por renovación), genera todas las CuotaCredito, imputa automáticamente los créditos viejos renovados y emite el asiento contable de alta.
Con la transacción ya confirmada, se dispara el evento APROBAR contra la máquina, que revalida isApprover e isSocioActivo.
/operaciones/:operacionId/aprobar-operacion·Operacion pasa a estado='aprobado' y se sella fechaAprobacion. Si es el primer préstamo liquidado del socio, se le asigna el numeroSocio con un update condicional para evitar duplicados por concurrencia.
Trabajo asíncrono post-aprobación: se encola la generación de formularios y, si la operación incluye préstamo y el socio tiene email, el mail de crédito aprobado.
Job 'generate-formularios' y job de email con template 'credit-approved'. Ninguno de los dos bloquea ni revierte la aprobación si falla: se loguea el error y se sigue.
Alternativa al paso 9: rechaza la operación indicando un motivo obligatorio. El rechazo de una operación NO la mata: la devuelve a pendiente para que ventas la corrija y la vuelva a enviar.
/operaciones/:operacionId/aprobar-operacion·Operacion vuelve a estado='pendiente'. El motivo de rechazo y el usuario que rechazó NO se guardan en la base (está marcado como TODO en el código).
Incluye el Pago de la operación aprobada en un lote de pago masivo, lo envía al banco y luego lo marca como pagado. Ese acto es el desembolso real.
/pagos-masivos/:id/marcar-pagado·Crea los Movimiento de banco a cuenta corriente del socio, pone el Pago en 'pagado' y escribe directamente Operacion.estado='pagado'. Esta escritura NO pasa por la máquina de estados, por lo que no dispara el mail de préstamo liquidado ni ninguna guarda.
Cuando el socio quiere precancelar, se genera un pedido de saldo y se registra su pago (o se imputa contra una renovación). El sistema marca las cuotas incluidas como pagadas y recalcula el estado del crédito.
/cancelaciones/:pedidoSaldoId/registrar-pago·Si no quedan cuotas impagas: Operacion.estado='cancelado_anticipadamente_finalizado' (habilita el Libre Deuda). Si quedan cuotas en vuelo: 'cancelado_anticipadamente'. También es escritura directa, sin pasar por la máquina.
Estados
- pre_solicitud
- Borrador embrionario creado al guardar el socio en el alta. No aparece en la bandeja de operaciones (tiene su propia bandeja, /bandeja-pre-solicitudes). No existe como estado dentro de la máquina XState.
- pendiente
- Operación abierta y en carga. Es el estado inicial de la máquina y el punto de retorno tanto de un rechazo como de una excepción aprobada o una reactivación.
- esperando_aprobacion_excepcion
- El socio no cumple alguna regla del plan comercial y se pidió una excepción con argumentación. Espera resolución exclusiva del administrador.
- esperando_aprobacion
- Carga confirmada, número de operación asignado y tasas congeladas. Espera la decisión del área de riesgo.
- aprobado
- Crédito aprobado: ya tiene capital otorgado, cuotas generadas, asiento de alta y un Pago pendiente esperando ser incluido en un lote de tesorería.
- rechazadofinal
- Estado final. Sólo se alcanza rechazando una excepción; el rechazo de la operación en sí devuelve a pendiente, no rechaza.
- cancelado
- Operación cancelada. La máquina permite reactivarla (vuelve a pendiente) sólo con rol admin, pero hoy no hay pantalla que dispare ni la cancelación ni la reactivación.
- pagado
- Capital efectivamente desembolsado al socio. Es el estado que el negocio considera 'crédito vigente' a los fines del tope de múltiples créditos por modalidad de cobro.
- desistidofinal
- Estado final. El socio se echó atrás. Se contempla en la liquidación de comisiones (resta del total a pagar al canal), pero ninguna pantalla lo dispara hoy.
- cancelado_anticipadamente
- Se precanceló el crédito pero todavía quedan cuotas en vuelo sin saldar.
- cancelado_anticipadamente_finalizado
- Todas las cuotas quedaron saldadas tras la precancelación. Habilita la emisión del Libre Deuda.
- cerradofinal
- Estado final previsto para el cierre definitivo tras la conciliación, que saca al crédito de la cartera activa. Está definido en la máquina y se lee en filtros, pero ningún código lo escribe todavía.
Reglas que el sistema hace cumplir
- Todas las transiciones son server-authoritative: la máquina se rehidrata desde el estado guardado en la base y se pregunta si el evento es admisible antes de ejecutarlo (app/lib/operations/transitionEstado.server.ts:97). El cliente sólo la usa para decidir qué botones mostrar.
- Una operación no puede enviarse a aprobación si no pasaron tanto la validación del formulario como la del plan comercial: guarda isReadyToSubmit (app/state/operacionEstado/machine.ts:60 y machine.ts:165).
- Sólo pueden aprobar o rechazar los roles de APPROVER_ROLES: admin, supervisor_area_operaciones_riesgo, supervisor_area_cobranzas, operador_area_operaciones_riesgo, operador_sucursal_operaciones_riesgo y supervisor_sucursal (app/lib/auth/roles.ts:66, guarda isApprover en machine.ts:89).
- El socio debe estar en estado 'activo' para poder aprobar el crédito: guarda isSocioActivo (app/state/operacionEstado/machine.ts:100), alimentada desde socio.estado en transitionEstado.server.ts:80 y revalidada con verificarMembresiaSocio en aprobar-operacion/actions.ts:145.
- Rechazar una operación NO la cierra: la devuelve a 'pendiente' para que ventas la corrija y la reenvíe (app/state/operacionEstado/machine.ts:211). El único camino al estado final 'rechazado' es el rechazo de una excepción (machine.ts:191).
- Las excepciones al plan comercial las resuelve exclusivamente el administrador: guardas isAdmin en APROBAR_EXCEPCION y RECHAZAR_EXCEPCION (app/state/operacionEstado/machine.ts:185 y machine.ts:190), reforzado con requireUserWithAnyRole([ROLES.ADMIN]) en aprobar-excepcion/actions.ts:15 y el redirect del loader en aprobar-excepcion/index.tsx:75.
- Aprobar una excepción no aprueba el crédito: sólo devuelve la operación a 'pendiente' y limpia los errores de validación para que la carga pueda continuar (app/state/operacionEstado/machine.ts:186).
- Aprobar una excepción exige explicación escrita y rechazarla exige motivo escrito: guardas hasApprovalExplanation y hasRejectionReason (app/state/operacionEstado/machine.ts:73 y machine.ts:81), validado también en el action (aprobar-excepcion/actions.ts:27 y actions.ts:105).
- Reactivar una operación cancelada es potestad exclusiva del admin: guarda isAdmin en la transición REACTIVAR (app/state/operacionEstado/machine.ts:277).
- Una operación cedida (estadoCesion no nulo) no admite ni confirmación de carga ni aprobación: assertOperacionNoCedida (app/lib/operations/assertOperacionNoCedida.ts:7, invocado en aprobar-operacion/actions.ts:102).
- El número de operación se asigna al confirmar la carga, no al aprobar, con hasta 5 reintentos ante colisión del índice único; si la transición ENVIAR falla, el número y el snapshot de tasas se revierten para no dejar números colgados (app/routes/operaciones/[id]-alta-operacion/actions.ts:1579 y actions.ts:1668). La aprobación exige que ese número ya exista (aprobar-operacion/actions.ts:180).
- Las tasas del plazo del plan comercial se congelan al confirmar la carga: editar el plan comercial entre la carga y la aprobación no reprecia una operación ya cotizada; en la aprobación sólo se recalcula el monto de los gastos porcentuales sobre el capital otorgado final (app/routes/operaciones/aprobar-operacion/actions.ts:290).
- El tope de créditos por modalidad de cobro es advertencia en el alta y bloqueo duro en la aprobación, y en la aprobación se cuentan también las operaciones ya aprobadas pendientes de liquidar para que dos aprobaciones en paralelo no ocupen el mismo cupo (app/lib/operations/validateMultiplesCreditos.ts:28 y aprobar-operacion/actions.ts:192).
- No se puede aprobar si la configuración contable del asiento de alta está incompleta, ni si hay gastos con cancelación automática y falta la configuración del asiento correspondiente (app/routes/operaciones/aprobar-operacion/actions.ts:220 y actions.ts:246).
- Las operaciones que incluyen préstamo requieren vendedor (organización / canal comercial) asignado tanto para confirmar la carga como para aprobar; las de sólo servicio social no (app/routes/operaciones/[id]-alta-operacion/actions.ts:1524 y aprobar-operacion/actions.ts:170).
- El número de socio se asigna recién cuando se aprueba (liquida) su primer préstamo, no al cargar la solicitud, y se usa un update condicional sobre numeroSocio nulo para que dos aprobaciones concurrentes no generen dos números distintos (app/lib/operations/transitionEstado.server.ts:120 y transitionEstado.server.ts:142).
- La fecha de ingreso del socio se sella en su primera operación aprobada y las aprobaciones posteriores no la tocan (app/routes/operaciones/aprobar-operacion/actions.ts:361).
- La aprobación es la que materializa el crédito: en una sola transacción crea el Pago 'PRESTAMO' en estado pendiente, todas las CuotaCredito y el asiento contable de alta (app/routes/operaciones/aprobar-operacion/actions.ts:458, actions.ts:492 y actions.ts:541).
- El monto a desembolsar no es el capital otorgado: se le restan la acción cooperativa si el socio es nuevo, los gastos que no van al socio (cancelaciones automáticas, integración de capital, impacta liquidación) y los pedidos de saldo por renovación o refinanciación (app/lib/operations/calcularDesembolsoBase.ts y aprobar-operacion/actions.ts:438).
- Cancelar o desistir revierte la imputación automática de renovación: reabre las cuotas del crédito viejo (vencidas si ya pasó su vencimiento), borra los movimientos internos y devuelve el crédito viejo a 'pagado' (app/lib/operations/transitionEstado.server.ts:117 y app/lib/cancelaciones/revertirImputacionRenovacion.ts:88).
- Cancelar una operación que integró capital cooperativo lo revierte en el socio (app/lib/operations/transitionEstado.server.ts:116 y app/lib/socios/integracion-operaciones.ts).
- El estado 'pagado' equivale a crédito vigente: es el único que cuenta para el tope de múltiples créditos, y se escribe cuando Tesorería marca pagado el lote de pago masivo (app/lib/operations/validateMultiplesCreditos.ts:20 y app/lib/tesoreria/marcarPagoMasivoPagado.ts:189).
- Tras precancelar, el crédito queda en 'cancelado_anticipadamente_finalizado' si no quedan cuotas impagas y en 'cancelado_anticipadamente' si quedan en vuelo (app/lib/cancelaciones/cerrarCreditoPorPedido.ts:72). Sólo el primero habilita el Libre Deuda (app/routes/api/cancelaciones/libre-deuda.ts:8).
- La bandeja de operaciones nunca lista pre-solicitudes, ni siquiera forzando el filtro por querystring, y excluye las operaciones migradas desde compras de cartera anuladas (app/routes/bandeja-operaciones/index.tsx:147 y index.tsx:203).
- El operador de sucursal ventas sólo ve en la bandeja las operaciones de sus organizaciones o las que creó él sin organización asignada (app/routes/bandeja-operaciones/index.tsx:215).
- Los estados 'rechazado', 'cancelado' y 'desistido' se consideran cerrados; a efectos de la baja de socio los no activos incluyen además pre_solicitud, los dos cancelado_anticipadamente y cerrado (app/lib/operations/estados.ts:28 y estados.ts:34).
- Para la reportería gerencial de ventas, sólo cuentan como venta concretada aprobado, pagado, cerrado y los dos estados de cancelación anticipada (app/lib/operations/estados.ts:51).
- Una operación desistida se sigue considerando en la liquidación de comisiones, pero su comisión se resta del total a pagar al canal en lugar de sumarse (app/lib/comisiones/calcular.ts:30 y app/lib/comisiones/resumen.ts:60).
Dónde vive en el código
app/state/operacionEstado/machine.tsapp/state/operacionEstado/types.tsapp/state/operacionEstado/index.tsapp/lib/operations/estados.tsapp/lib/operations/transitionEstado.server.tsapp/lib/operations/resolveOperacionActorRole.tsapp/lib/operations/preSolicitud.server.tsapp/lib/operations/validateMultiplesCreditos.tsapp/lib/operations/validateClienteAgainstPlanComercial.tsapp/lib/operations/assertOperacionNoCedida.tsapp/lib/operations/calcularDesembolsoBase.tsapp/lib/operations/getOperacionEstadoBadgeData.tsapp/lib/auth/roles.tsapp/lib/auth/authorization.tsapp/routes/bandeja-operaciones/index.tsxapp/routes/operaciones/alta-operacion/actions.tsapp/routes/operaciones/[id]-alta-operacion/actions.tsapp/routes/operaciones/aprobar-operacion/actions.tsapp/routes/operaciones/aprobar-operacion/index.tsxapp/routes/operaciones/aprobar-excepcion/actions.tsapp/routes/operaciones/aprobar-excepcion/index.tsxapp/lib/socios/integracion-operaciones.tsapp/lib/tesoreria/marcarPagoMasivoPagado.tsapp/lib/cancelaciones/cerrarCreditoPorPedido.tsapp/lib/cancelaciones/revertirImputacionRenovacion.tsapp/jobs/queue.tsapp/routes.tsprisma/schema.prismaA tener en cuenta
SOLICITAR_EXCEPCIONestá muerto: el estadoesperando_aprobacion_excepcionse escribe directo con Prisma desde el alta (app/routes/operaciones/alta-operacion/actions.ts:488y:504), sin pasar por la máquina.Operacion.estadoadmite 12 valores pero la máquina modela 11: faltapre_solicitud, que es el estado inicial real de toda operación. Si alguien invocaratransitionOperacionEstadosobre una operación enpre_solicitud,resolveStaterompería (app/lib/operations/transitionEstado.server.ts:80).- La máquina define 13 eventos pero sólo 5 están conectados a la aplicación: ENVIAR, APROBAR, RECHAZAR, APROBAR_EXCEPCION y RECHAZAR_EXCEPCION. CANCELAR, DESISTIR, REACTIVAR, PAGAR, SOLICITAR_EXCEPCION, CANCELAR_ANTICIPADAMENTE, FINALIZAR_CANCELACION y CERRAR no se disparan desde ninguna pantalla, action ni job.
- El pase a 'pagado' lo escribe Tesorería con un update directo a la base (app/lib/tesoreria/marcarPagoMasivoPagado.ts:189), salteando la máquina. Consecuencia concreta: el mail 'loan-paid-off' que la máquina dispara al llegar a PAGADO (transitionEstado.server.ts:161) nunca se envía. Es código muerto en la práctica.
- Lo mismo pasa con la cancelación anticipada: cerrarCreditoPorPedido.ts:75 escribe cancelado_anticipadamente y cancelado_anticipadamente_finalizado directo en la tabla, sin guardas ni validación de estado de origen. Un crédito en cualquier estado podría terminar ahí si se le genera un pedido de saldo.
- No hay ninguna funcionalidad de cancelación ni de desistimiento de operaciones en la UI, aunque el negocio las contempla: 'desistido' se usa activamente en la liquidación de comisiones (resta del total a pagar al canal) y en los filtros de estados no activos. Hoy sólo se puede llegar a esos estados tocando la base a mano.
- El estado 'cerrado' está definido en la máquina y en las constantes, y se lee en dos filtros (Libre Deuda y vista de cancelaciones), pero ningún código lo escribe. Es un estado inalcanzable.
- El motivo de rechazo y el usuario que rechaza NO se persisten: buildUpdateData los tiene comentados como TODO (transitionEstado.server.ts:207). Idem para fechaPago, canceladoPorId, fechaCancelacion, reactivadoPorId y fechaReactivacion. Operativamente esto significa que si una operación vuelve a 'pendiente' por rechazo, el vendedor no ve por qué se la rechazaron.
- 'pre_solicitud' es un estado válido en las constantes y en la base, pero no existe como estado de la máquina XState. Si alguna vez se intentara disparar un evento sobre una operación en ese estado, machine.resolveState fallaría; hoy no ocurre porque las dos salidas de pre_solicitud son updates directos.
- Al confirmar la carga, el rol que se pasa a la máquina se fuerza a admin u operador_sucursal_ventas ([id]-alta-operacion/actions.ts:1657) sin importar el rol real del usuario. Funciona porque ENVIAR no tiene guarda de rol, pero es frágil: si mañana se agrega una guarda de rol a ENVIAR, el chequeo evaluaría un rol inventado.
- Desalineación entre APPROVER_ROLES y los permisos: operador_sucursal_operaciones_riesgo está en APPROVER_ROLES (roles.ts:66) y por lo tanto pasa la guarda isApprover de la máquina, pero NO tiene el permiso operations:approve (authorization.ts:474). En la práctica el action lo rechaza con un 403 después de haber pasado la guarda. La bandeja además le muestra el botón de aprobar, así que ve una acción que no puede completar.
- El circuito de excepción no valida el tope de múltiples créditos: al crear la operación de excepción sólo se loguea la advertencia (alta-operacion/actions.ts:455). El bloqueo duro llega recién en la aprobación, así que una operación puede recorrer todo el circuito de excepción para terminar frenada al final.
- Reversar un pago masivo devuelve el Pago a 'pendiente' pero NO devuelve la operación de 'pagado' a 'aprobado' (app/lib/tesoreria/reversarPagoMasivo.ts). Queda un crédito marcado como liquidado sin desembolso efectivo, lo que además ocupa cupo en la validación de múltiples créditos.
- La aprobación es una transacción muy larga que hace de todo (gastos, capital cooperativo, pago, cuotas, imputación de renovaciones, asientos), y el cambio de estado se ejecuta DESPUÉS de que esa transacción confirma (aprobar-operacion/actions.ts:558). Si la transición fallara, quedarían cuotas y pago creados con la operación todavía en 'esperando_aprobacion'.
- El campo formStatus del modelo Operacion (default 'carga_operacion') no se lee ni se escribe en ningún lado salvo el traductor del audit log. Es un vestigio de un control de avance anterior al stepper actual.
Liquidación y pago del préstamo
Es el circuito por el que la plata efectivamente sale del banco hacia el socio (o hacia el destinatario de una cancelación automática). Cuando se aprueba una operación, IRIS deja un Pago en estado 'pendiente' en la Bandeja de Pagos; Tesorería selecciona varios pagos y arma un lote (PagoMasivo) en 'borrador', le asigna un banco habilitado y al finalizarlo el sistema genera el archivo CSV que se sube a S3 para presentar al banco. Cuando el banco confirma la acreditación, un operador marca el lote como pagado: recién ahí se escriben los Movimientos (débito de la cuenta bancaria contra la cuenta corriente del destinatario), se pasa la Operación a 'pagado' y, si el pago era una cancelación de gasto, se emite el asiento contable. Todo el circuito es reversible: reversar devuelve los pagos a 'pendiente' y, si ya se había pagado, escribe el movimiento compensatorio.
enviado sin ningún movimiento contable; sólo al marcarlo como pagado —a mano, no hay webhook— se escriben los movimientos, la operación pasa a pagado y se emite el asiento. Todo el circuito es reversible: reversar libera los pagos a pendiente para rearmar otro lote.Quién interviene
Qué lo dispara
- Aprobación de una operación de préstamo: crea un Pago tipo 'PRESTAMO' en estado 'pendiente' vinculado a la Operación vía PagoOperacion (app/routes/operaciones/aprobar-operacion/actions.ts:458).
- Aprobación de una operación con gastos marcados como cancelación automática y destinatario: crea un Pago adicional por cada gasto, vinculado vía PagoCancelacionGasto, y ya deja el movimiento que hace nacer la deuda con el destinatario (app/lib/gastos/aplicarGastosAprobacion.ts:89).
- Alta manual de un Pago desde la bandeja (/pagos/alta), para pagos sueltos: proveedor, comisión, reintegro, impuesto u otro.
- Otros circuitos que también depositan pagos en la misma bandeja: liquidación de comisiones, compra y recompra de cartera, registro de pagos de cancelaciones y la imputación por renovación/refinanciación.
- Acción manual del operador en la bandeja: seleccionar pagos y crear el lote (intent 'createPagoMasivoBorrador').
- Acción manual del operador: marcar el lote como pagado una vez que el banco confirmó la acreditación. No hay ningún cron ni webhook que lo haga solo.
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Calcula el monto neto a desembolsar y crea el Pago tipo PRESTAMO en estado 'pendiente', sin banco de origen asignado, con el CBU y titular de destino tomados de la operación. Lo vincula a la Operación con PagoOperacion.
/operaciones/:id/aprobar·INSERT en pagos (estado 'pendiente', bancoOrigenId null, pagoMasivoId null) + INSERT en pagos_operaciones. En la misma transacción se crea la cuenta corriente del socio si no existía y se generan las cuotas del crédito.
Por cada gasto marcado como cancelación automática con destinatario configurado, crea un Pago 'pendiente' extra a nombre del destinatario y ya registra la deuda contra su cuenta corriente de cancelación.
/operaciones/:id/aprobar·INSERT en pagos + pagos_cancelacion_gasto + un Movimiento contra la cuenta corriente tipo CANCELACION del destinatario (nace la deuda) + asiento contable de aprobación. El monto se descuenta del desembolso al socio.
Entra a la Bandeja de Pagos, filtra por estado, tipo, banco, rango de fechas o 'solo sin agrupar', y tilda los pagos que quiere mandar juntos al banco. Solo son tildables los pagos en 'pendiente' que todavía no pertenecen a ningún lote.
/pagos/listado·Ninguno, es solo lectura. La misma pantalla muestra abajo el listado paginado de lotes (PagoMasivo) con su estado.
Confirma la selección y crea el borrador del lote. El sistema valida que todos los pagos existan, estén en 'pendiente', no pertenezcan a otro lote y compartan la misma moneda; suma el monto total y redirige a la pantalla de finalización.
/pagos/listado (POST intent=createPagoMasivoBorrador)·INSERT en pagos_masivos con estado 'borrador', bancoId null y montoTotal calculado. UPDATE de los pagos seleccionados fijando pagoMasivoId. OJO: los pagos siguen en estado 'pendiente', no cambian de estado todavía.
En la pantalla de finalización revisa el lote (que muestra los pagos agrupados por banco de origen sugerido) y elige contra qué banco se va a pagar. El desplegable solo lista bancos con habilitadoPagarPrestamos = true y avisa cuántos tienen configurado el formato de archivo.
/pagos-masivos/:id/finalizar·Solo lectura. Si el lote no está en 'borrador', la pantalla redirige a la vista del lote y no deja finalizar.
Valida que el lote esté en 'borrador', que tenga pagos y que el banco elegido esté habilitado para pagar préstamos. Pasa todos los pagos a 'enviado', genera el archivo CSV con las columnas configuradas para ese banco (o un formato por defecto si no hay configuración) y lo sube a S3.
/pagos-masivos/:id/finalizar (POST)·UPDATE masivo de pagos a estado 'enviado'. Subida del CSV a S3 bajo la clave pagos-masivos/pago_masivo_{id}_{timestamp}.csv. UPDATE de pagos_masivos con bancoId, estado 'enviado', fechaEnvio y archivoGenerado (la clave S3). Todavía NO se creó ningún Movimiento.
Descarga el archivo del lote para presentarlo en el homebanking o canal del banco.
/api/pagos-masivos/descargar?id=:id·Ninguno sobre la base. Devuelve un redirect a una URL firmada de S3 válida por 1 hora. Requiere solo PAGOS_READ.
El banco procesa y acredita. Cuando el operador tiene la confirmación, entra a la pantalla de confirmación del lote y lo marca como pagado. La pantalla solo se abre si el lote está en 'enviado'.
/pagos-masivos/:id/marcar-pagado·Ninguno hasta que se confirma. La confirmación es 100% manual: no hay conciliación automática ni webhook que dispare este paso.
Antes de tocar nada valida el lote entero: que esté en 'enviado', que tenga banco asignado, que la configuración contable del asiento de cancelación esté completa si hay pagos de cancelación, y que todo pago de cancelación tenga destinatario resoluble. Si algo falla, no escribe nada.
/pagos-masivos/:id/marcar-pagado (POST)·Ninguno si falla: toda la operación corre dentro de una única transacción de base de datos.
Por cada pago del lote resuelve la cuenta destino (cuenta corriente GENERAL del socio si es PRESTAMO; cuenta corriente CANCELACION del destinatario si es cancelación de gasto), busca o crea la cuenta bancaria del banco en esa moneda y escribe el Movimiento del pago.
/pagos-masivos/:id/marcar-pagado (POST)·INSERT de UN solo Movimiento por pago, tipo 'debito', con cuentaBancariaOrigenId = cuenta del banco y cuentaCorrienteDestinoId = cuenta del socio o destinatario, y pagoId apuntando al pago. Si la cuenta bancaria no existía se crea junto con su cuenta contable asociada.
Cierra el circuito: pasa cada Pago a 'pagado', pasa la Operación asociada a 'pagado', emite el asiento contable 'Caja Cancelaciones (Debe) / Banco (Haber)' para los pagos de cancelación, y finalmente marca el lote como pagado con la fecha.
/pagos-masivos/:id/marcar-pagado (POST)·UPDATE de cada pago a estado 'pagado'; UPDATE de cada operacion a estado 'pagado'; INSERT de asientos contables por cada cancelación; UPDATE de pagos_masivos con estado 'pagado' y fechaPago. Hay un TODO abierto para notificar al cliente: hoy NO se manda ningún mail ni notificación.
Si el lote se rechazó, se cargó mal o no se puede marcar como pagado, lo reversa. Debe escribir un motivo de al menos 10 caracteres. Se puede reversar tanto un lote 'enviado' (que nunca movió plata) como uno 'pagado' (que sí la movió).
/pagos-masivos/:id/reversar·El motivo se valida en el action pero NO se persiste en ninguna tabla: se pierde. Solo queda el rastro genérico del audit log.
Si el lote estaba 'pagado', escribe un Movimiento compensatorio por cada pago (la plata vuelve al banco). Si estaba 'enviado', no escribe ningún movimiento porque nunca se movió plata. En ambos casos devuelve los pagos a 'pendiente' y los desvincula del lote, y marca el lote como 'reversado' (estado final).
/pagos-masivos/:id/reversar (POST)·INSERT de Movimientos tipo 'credito' con cuentaCorrienteOrigenId = cuenta destino y cuentaBancariaDestinoId = cuenta del banco (solo si estaba 'pagado'). UPDATE de cada pago: estado 'pendiente' y pagoMasivoId = null, con lo cual vuelven a estar disponibles en la bandeja para armar un lote nuevo. UPDATE de pagos_masivos a 'reversado'. El lote reversado queda muerto, no se reutiliza.
Consulta el resultado: el listado de Movimientos, las Cuentas Corrientes de socios y organizaciones con su saldo, y el tablero específico de Cuentas Corrientes de Destinatarios de Cancelaciones con su detalle de movimientos exportable.
/movimientos/listado, /cuentas-corrientes/socios/listado, /cuentas-corrientes/organizaciones/listado, /cuentas-corrientes-destinatarios/listado·Solo lectura. Los saldos de cuenta corriente se calculan sumando movimientos al vuelo, no se leen de un campo materializado.
Estados
- pendiente
- Estado de Pago. El pago existe y espera ser agrupado en un lote. Es el único estado desde el que se puede seleccionar en la bandeja. También es el estado al que vuelve un pago reversado.
- enviado
- Estado de Pago. El lote que lo contiene fue finalizado, el archivo se generó y se subió a S3. La plata todavía no se movió en el sistema: no hay Movimiento.
- pagado
- Estado de Pago. El banco confirmó la acreditación y el sistema escribió el Movimiento contra la cuenta corriente del destinatario. Si es un pago de préstamo, la Operación también pasó a 'pagado'.
- reversadofinal
- Estado de Pago declarado en el enum PAGO_ESTADOS y ofrecido en el formulario de edición, pero que NINGÚN servicio escribe: al reversar un lote los pagos vuelven a 'pendiente', no a 'reversado'. Solo se puede alcanzar editando el pago a mano.
- borrador
- Estado de PagoMasivo. Es el default del modelo. El lote agrupa pagos pero todavía no tiene banco asignado ni archivo generado. Es un armado en curso, editable solo eliminándolo o finalizándolo.
- enviado
- Estado de PagoMasivo. Ya tiene banco asignado, fechaEnvio y archivoGenerado (clave S3). Desde acá se puede marcar como pagado o reversar.
- pagado
- Estado de PagoMasivo. El banco confirmó, se escribieron los Movimientos y se registró fechaPago. Todavía se puede reversar.
- reversadofinal
- Estado de PagoMasivo. Estado terminal: el lote queda anulado para siempre y sus pagos se liberaron a 'pendiente' para armar un lote nuevo. No hay forma de volver atrás.
- pendiente
- Estado de PagoMasivo documentado en el comentario del schema (prisma/schema.prisma:2155) pero que ninguna ruta activa escribe. Solo lo produce el servicio muerto crearPagoMasivo, que no está enganchado a ninguna pantalla.
Reglas que el sistema hace cumplir
- Un pago solo puede entrar a un lote si está en 'pendiente' y no pertenece ya a otro lote — se valida en el servicio (app/lib/tesoreria/crearPagoMasivoBorrador.ts:56 y :65) y además la UI solo permite tildar esos pagos (app/routes/pagos/list.tsx:324).
- Todos los pagos de un mismo lote deben compartir la misma moneda; si hay mezcla, el borrador se rechaza con el detalle de las monedas encontradas (app/lib/tesoreria/crearPagoMasivoBorrador.ts:74-80).
- El lote nace en 'borrador' con bancoId null y los pagos NO cambian de estado al agruparse: siguen en 'pendiente' hasta la finalización (app/lib/tesoreria/crearPagoMasivoBorrador.ts:86-100).
- Solo se pueden finalizar lotes en 'borrador'; cualquier otro estado devuelve error y la pantalla redirige a la vista del lote (app/lib/tesoreria/finalizarPagoMasivo.ts:47, app/routes/pagos-masivos/finalizar.tsx:57).
- Solo se puede pagar contra un banco con habilitadoPagarPrestamos = true; es la única validación de banco del circuito y se aplica tanto en el desplegable como en el servicio (app/lib/tesoreria/finalizarPagoMasivo.ts:73, app/routes/pagos-masivos/finalizar.tsx:60-63).
- El formato del archivo para el banco se arma con las columnas configuradas en CsvPagoCredito de ese banco; si el banco no tiene ninguna configurada, se usa un CSV por defecto con id, tipo, monto, moneda, cbu_destino, titular_destino y descripcion (app/lib/tesoreria/generarArchivoPago.ts:39-44 y :113-137).
- Si una columna configurada del banco apunta a un resolverKey inexistente, la generación del archivo lanza error y aborta toda la finalización (app/lib/tesoreria/generarArchivoPago.ts:81-88).
- El archivo se guarda en S3 bajo la clave pagos-masivos/pago_masivo_{id}_{timestamp}.csv y la clave queda en PagoMasivo.archivoGenerado; la descarga devuelve una URL firmada válida por 1 hora (app/lib/tesoreria/generarArchivoPago.ts:47, app/routes/api/pagos-masivos.descargar.ts:41).
- Un lote solo se puede marcar como pagado si está en 'enviado', tiene pagos y tiene banco asignado (app/lib/tesoreria/marcarPagoMasivoPagado.ts:90, :97, :104).
- Si el lote contiene pagos de cancelación de gasto, la configuración contable del asiento 'gasto_cancelacion_pago' debe estar completa antes de poder marcar el lote como pagado (app/lib/tesoreria/marcarPagoMasivoPagado.ts:111-120).
- Todo pago de cancelación debe tener destinatario resoluble desde el snapshot congelado en la aprobación; si falta, el lote entero se bloquea con el id del pago problemático (app/lib/tesoreria/marcarPagoMasivoPagado.ts:122-130 y app/lib/tesoreria/reversarPagoMasivo.ts:106-115).
- La cuenta destino se resuelve por tipo: los pagos de cancelación van a la cuenta corriente tipo CANCELACION del destinatario, y los PRESTAMO a la cuenta corriente tipo GENERAL del socio (app/lib/tesoreria/marcarPagoMasivoPagado.ts:241-255, app/lib/tesoreria/getOrCreateCuentaCorriente.ts:48).
- La cuenta corriente del socio se filtra explícitamente por tipo GENERAL para no chocar con las cuentas dedicadas de servicio social, que también cuelgan del mismo socio (app/lib/tesoreria/getOrCreateCuentaCorriente.ts:46-48).
- Las cuentas bancarias se crean de forma perezosa: si el banco no tiene cuenta en la moneda del pago, se crea junto con su cuenta contable con código BANCO-{id}-{moneda} y saldo inicial 0 (app/lib/tesoreria/getOrCreateCuentaBancaria.ts:22-49).
- El pago genera UN SOLO Movimiento tipo 'debito' con cuenta bancaria de origen y cuenta corriente de destino; no hay par de asientos ni campo contrapartidaId (app/lib/tesoreria/crearMovimientosPago.ts:25-37).
- La reversión genera UN SOLO Movimiento tipo 'credito' con cuenta corriente de origen y cuenta bancaria de destino, con el concepto prefijado 'Reversión: ' (app/lib/tesoreria/crearMovimientosPago.ts:46-67).
- La base impide que un movimiento tenga a la vez cuenta corriente y cuenta bancaria del mismo lado: hay CHECK constraints movimientos_origen_exclusive_check y movimientos_destino_exclusive_check (prisma/migrations/20260512120000_split_cuenta_bancaria_from_cuenta_contable/migration.sql:161-180).
- Marcar el lote como pagado también pasa la Operación asociada a estado 'pagado' (app/lib/tesoreria/marcarPagoMasivoPagado.ts:186-191).
- Solo se pueden reversar lotes en 'pagado' o 'enviado'; reversar un lote 'enviado' NO genera movimiento compensatorio porque nunca se movió plata (app/lib/tesoreria/reversarPagoMasivo.ts:82-89 y :121).
- Al reversar, los pagos vuelven a 'pendiente' y se les borra el pagoMasivoId, quedando disponibles para armar un lote nuevo; el lote queda en 'reversado', que es estado terminal (app/lib/tesoreria/reversarPagoMasivo.ts:140-155).
- La reversión de un pago individual solo aplica a pagos en estado 'pagado' y exige que el pago tenga banco de origen asignado (app/lib/tesoreria/reversarPagoMasivo.ts:198-211).
- El motivo de reversión es obligatorio y debe tener al menos 10 caracteres, pero NO se guarda en ninguna tabla (app/routes/pagos-masivos/reversar.tsx:53-59).
- No se puede eliminar un pago que pertenece a un lote: hay que sacarlo del lote primero (app/routes/pagos/delete.tsx:55-61).
- El saldo de las cuentas corrientes NO se lee del campo materializado CuentaCorriente.saldo: se calcula al vuelo sumando movimientos de origen menos movimientos de destino (app/lib/cuentas-corrientes/saldo.server.ts:12-35).
- Las cuentas corrientes de cancelación son cuentas de pasivo, así que su debe/haber no se deduce del sentido origen→destino sino de qué lado está el banco: banco de origen = debe (se salda), banco de destino = haber (revive la deuda) (app/lib/cuentas-corrientes/ladoCancelacion.ts:30-38).
- La bandeja de pagos exige PAGOS_READ para ver y PAGOS_WRITE para agrupar, finalizar, marcar pagado y reversar; eliminar exige PAGOS_DELETE, que el operador de tesorería NO tiene (app/routes/pagos/list.tsx:67 y :220, app/routes/pagos/delete.tsx:14, app/lib/auth/authorization.ts:102-104 y :372-377).
Dónde vive en el código
/Users/martin.long/Documents/work/rebl/iris/app/routes.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/pagos/list.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/pagos/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/pagos/update.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/pagos/delete.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/pagos/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/pagos-masivos/finalizar.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/pagos-masivos/marcar-pagado.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/pagos-masivos/reversar.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/pagos-masivos/view.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/api/pagos-masivos.descargar.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/pagos/estados.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/index.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/crearPagoMasivoBorrador.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/finalizarPagoMasivo.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/marcarPagoMasivoPagado.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/reversarPagoMasivo.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/crearMovimientosPago.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/generarArchivoPago.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/getOrCreateCuentaBancaria.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/getOrCreateCuentaCorriente.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/crearPagoMasivo.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cuentas-corrientes/saldo.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cuentas-corrientes/ladoCancelacion.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cuentas-corrientes-destinatarios/cuentasList.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/gastos/aplicarGastosAprobacion.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/aprobar-operacion/actions.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/movimientos/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/cuentas-corrientes-destinatarios/list.tsx/Users/martin.long/Documents/work/rebl/iris/app/lib/auth/authorization.ts/Users/martin.long/Documents/work/rebl/iris/prisma/schema.prisma/Users/martin.long/Documents/work/rebl/iris/docs/architecture.mdA tener en cuenta
- EL DOC ESTÁ DESACTUALIZADO EN LO MÁS IMPORTANTE: docs/architecture.md describe partida doble con dos movimientos espejados vinculados por contrapartidaId ('1. salida on Banco CuentaCorriente, 2. entrada on Cliente CuentaCorriente, both reference each other via contrapartidaId'). En el código real se crea UN SOLO Movimiento por pago, con cuentaBancariaOrigenId y cuentaCorrienteDestinoId en la misma fila. El campo contrapartidaId ni siquiera existe en prisma/schema.prisma. Cualquier decisión de negocio tomada leyendo ese doc sobre integridad contable parte de una premisa falsa.
- EL DOC OMITE EL ESTADO 'borrador', que es justamente el paso donde vive el operador. Los diagramas del doc muestran PagoMasivo arrancando en 'pendiente', pero el default del modelo es 'borrador' y es lo único que escribe el código vivo. El estado 'pendiente' de PagoMasivo figura en el comentario del schema pero ninguna ruta activa lo produce.
- EL DOC MENCIONA Banco.formatoPago, campo que ya no existe: se eliminó en el refactor de cuentas (docs/refactor-cuentas-plan.md) y la configuración del formato de archivo vive hoy en la tabla CsvPagoCredito (columnas nombre + resolverKey + orden por banco). El doc también describe pagos apuntando a Cliente cuando en realidad apuntan a Socio.
- CÓDIGO MUERTO: crearPagoMasivo y crearPagoMasivoFromFilters están exportados desde app/lib/tesoreria/index.ts y documentados en detalle en architecture.md (con toda la tabla de 'UI Selection Patterns': seleccionar por filtros, seleccionar todo excepto...), pero NINGUNA ruta ni test los usa. La funcionalidad de 'seleccionar todos los que matchean el filtro' que el doc promete no está enganchada a ninguna pantalla. Lo mismo con buildPagoWhereClause: la bandeja arma su where a mano en el loader en vez de usarlo.
- PUERTA TRASERA A LA MÁQUINA DE ESTADOS: el formulario /pagos/:id/modificar permite cambiar el estado del pago a cualquiera de los 4 valores del enum sin ninguna validación de transición (app/routes/pagos/validations.ts:23). Un operador con PAGOS_WRITE puede pasar un pago de 'pendiente' a 'pagado' directamente, sin generar ningún Movimiento y sin tocar la Operación. Todo el control de estados vive en los servicios, que este camino saltea.
- EL ESTADO 'reversado' DE PAGO ES INALCANZABLE por el circuito normal: al reversar, los pagos vuelven a 'pendiente' (no a 'reversado'). El valor existe en el enum, tiene su color en la UI y aparece en el desplegable de edición, pero ningún servicio lo escribe. O sobra el estado, o falta la transición.
- LA CONFIRMACIÓN DEL BANCO ES 100% MANUAL Y SIN EVIDENCIA: no hay import de respuesta del banco, ni webhook, ni cruce con el extracto para el paso 'marcar pagado'. Un click marca todo un lote como acreditado sin adjuntar comprobante ni número de lote bancario. Existe un módulo de Conciliaciones y Extractos Bancarios pero no está enganchado a este paso.
- EL MOTIVO DE REVERSIÓN SE PIERDE: se valida que tenga 10+ caracteres y después se descarta, nunca se persiste. Para una acción que revierte plata ya acreditada, no queda registrada la razón en ningún campo consultable.
- EL LOTE REVERSADO ES TERMINAL Y NO SE PUEDE CORREGIR: si un lote de 200 pagos se marcó pagado por error, la única salida es reversarlo entero y volver a armar el lote desde cero. No hay reversión selectiva de un pago dentro de un lote desde la UI (existe el servicio reversarPago para un pago individual, pero ninguna ruta lo expone).
- CuentaCorriente.saldo y CuentaBancaria.saldo son campos materializados que este circuito NUNCA actualiza: los pagos y reversiones escriben movimientos pero no tocan el saldo. Los listados de cuentas corrientes calculan el saldo sumando movimientos al vuelo, con lo cual el campo persistido queda desfasado. En cambio otros circuitos (conciliaciones, compra/venta/recompra de cartera) sí actualizan CuentaBancaria.saldo a mano. Convive un modelo calculado con uno materializado sobre las mismas tablas.
- LOS CONCEPTOS BANCARIOS NO SE USAN EN ESTE CIRCUITO: crearMovimientosPago acepta un conceptoBancarioId opcional, pero marcarPagoMasivoPagado lo pasa como undefined siempre. El formulario de alta manual de Movimientos tampoco ofrece elegir concepto (solo cuenta contable). El ABM de Conceptos Bancarios existe y los conceptos se usan en conciliaciones, pero los movimientos generados por pagos quedan sin clasificar.
- LOS MOVIMIENTOS SON EDITABLES Y BORRABLES A MANO desde /movimientos, incluso los generados automáticamente por un pago. Un usuario con MOVIMIENTOS_DELETE puede borrar el movimiento que respalda un pago acreditado y dejar el Pago en 'pagado' sin contrapartida. No hay guarda que proteja los movimientos con pagoId.
- NO SE NOTIFICA AL SOCIO cuando le acreditan el préstamo: hay un TODO comentado en marcarPagoMasivoPagado. El sistema tiene infraestructura de mail y jobs, pero este evento no la usa.
- La estructura Pago -> PagoOperacion es 1:1 estricta por unique en ambos lados, así que una operación no puede tener más de un pago. Si un desembolso hay que partirlo en dos transferencias (por tope bancario, por ejemplo), el modelo no lo soporta hoy.
- Solo el tipo PRESTAMO tiene resolución de cuenta destino implementada. Los tipos PROVEEDOR, COMISION, REINTEGRO, IMPUESTO y OTRO se pueden crear desde el alta manual, se pueden agrupar en un lote y se pueden enviar al banco, pero al marcar el lote como pagado el switch cae en default y devuelve error 'No se pudo determinar la cuenta destino', bloqueando el lote ENTERO. Un pago manual tipo OTRO metido en un lote de préstamos lo deja trabado y la única salida es reversarlo.
Cobranza mensual, imputación y mora
Cubre el ciclo mensual de cobranza de un crédito ya liquidado: cómo nace el plan de cuotas al aprobar la operación, cómo se descuenta la cuota del recibo de sueldo del socio a través del organismo o entidad recaudadora, y cómo vuelve esa información al sistema mediante el archivo de imputación (.xlsx) que Cobranzas sube en /cobranzas/imputaciones/importar. Cada fila válida se convierte en un RegistroCobro (el único registro de cobro del sistema), que después alimenta el motor de estadística de cobro para predecir en qué día del mes conviene presentar el descuento. En paralelo, un job diario calcula el interés moratorio de las cuotas impagas y las marca 'vencida'. Punto clave: hoy el import de imputación NO marca la cuota como pagada; esa transición sigue siendo manual desde /operaciones/:id/pagar-cuotas.
RegistroCobro por cada fila válida, pero no marca la cuota como pagada: hoy la única vía que cierra una cuota es la carga manual de Tesorería. Mientras tanto, el job de mora la sigue tratando como impaga y le acumula punitorios.Quién interviene
Qué lo dispara
- Aprobación de la operación en /operaciones/:id/aprobar-operacion: genera todas las CuotaCredito en estado 'pendiente' y, si hay pedidos de saldo de renovación/refinanciación vinculados, los imputa en la misma transacción.
- Carga manual del archivo de imputación (.xlsx) en /cobranzas/imputaciones/importar, en dos pasos: vista previa y confirmación.
- Registro manual del pago de cuotas en /operaciones/:id/pagar-cuotas (por múltiplos de cuotas acumuladas).
- Cron diario 'calculos-mora' a las 04:00 UTC (01:00 hora Argentina) que recalcula el interés moratorio de todas las cuotas impagas vencidas.
- Cron diario 'estadisticas-cobro' a las 05:00 UTC que recalcula el motor de estadística de cobro.
- Disparo manual del recálculo de estadísticas vía POST a /estadisticas-cobro/recalcular (solo encola el job, no calcula en la request).
- Alta, edición o carga masiva de feriados en /feriados/*: cambia el calendario de días hábiles que usan la estadística y la marca de anomalía de los cobros bancarios.
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Aprueba la operación. En la misma transacción se crea el Pago de desembolso y se genera la grilla completa de cuotas del crédito.
/operaciones/:operacionId/aprobar-operacion·Crea Pago + PagoOperacion y hace createMany de CuotaCredito con estado 'pendiente', fechaVencimiento, haber, valorCuota y gastoCobranza. Genera el asiento contable de alta.
Calcula la fecha de vencimiento de cada cuota según el día de vencimiento de la modalidad de cobro (25 por defecto), el plazo de gracia y el día de corte de alta, y deriva el haber (mes del sueldo del que se descuenta).
Cada cuota queda con fechaVencimiento normalizada al último día real del mes si el día configurado no existe, y con haber = primer día del mes anterior al vencimiento.
Se envía al organismo o entidad recaudadora el listado de descuentos del período. Este tramo NO está implementado en IRIS: no existe el modelo Envio y la columna RegistroCobro.envioId ni siquiera tiene foreign key.
Ningún efecto en la base de IRIS. La modalidad de cobro guarda los parámetros del envío (diaPresentacion, diaCobro, modalidadEnvio) pero nada los consume todavía.
Descuenta la cuota del recibo de sueldo del socio en el haber correspondiente y devuelve el archivo de rendición con lo efectivamente cobrado.
Ningún efecto en IRIS hasta que el archivo se sube.
Sube el archivo .xlsx de imputación eligiendo canal de cobro y concepto (Cuota o Cuota social) y pide la vista previa.
/cobranzas/imputaciones/importar·No escribe nada. Devuelve el conteo de filas válidas, filas con error con su motivo, y cuántas caen en día no hábil.
Parsea y valida cada fila: idOperacion entero, importe entero en centavos, fechaCobro en formato AAAAMMDD. Después resuelve a qué cuota se imputa cada fila usando un cursor por operación que consume las cuotas pendientes o vencidas de la más vieja a la más nueva.
Marca filas con error (ID_OPERACION_INVALIDO, IMPORTE_INVALIDO, FECHA_INVALIDA, OPERACION_INEXISTENTE, SIN_CUOTA_PENDIENTE, DUPLICADO) y arma la lista de filas OK con su cuota destino.
Revisa el detalle y presiona 'Confirmar e importar'. El archivo se vuelve a subir y se revalida entero.
/cobranzas/imputaciones/importar·Dispara procesarImputaciones. No hay tabla de historial del import: si se sube dos veces el mismo archivo, la segunda vez todas las filas salen como DUPLICADO.
Por cada fila válida registra un cobro llamando a registrarCobro(), cada uno en su propia transacción para que una fila fallida no tumbe las demás.
Crea RegistroCobro con origen 'ARCHIVO_IMPUTACION', resultado 'EXITOSO', el socio y la modalidad de cobro derivados de la operación, y la marca fechaNoHabil si el canal es bancario y la fecha cae en feriado o fin de semana. IMPORTANTE: la CuotaCredito NO cambia de estado.
Registra el pago de cuotas eligiendo banco destino, cantidad de cuotas (monto acumulado), fecha real de cobro y canal. Es hoy el único camino de la app que marca cuotas como pagadas.
/operaciones/:operacionId/pagar-cuotas·Marca las N cuotas más viejas como 'pagada' con fechaPago = fecha de cobro, crea un RegistroCobro por cuota con origen 'MANUAL', genera los Movimientos contra la cuenta bancaria y, si no quedan cuotas pendientes ni vencidas, pasa la operación a estado 'pagado'.
Todos los días a las 04:00 UTC busca las cuotas con fechaPago nula y vencimiento anterior a hoy (hora Argentina) y les calcula el interés moratorio.
Actualiza CuotaCredito.interesMoratorio y pone estado 'vencida'; hace upsert en HistorialCalculoMora por (cuota, fecha de cálculo) y escribe un AuditLog con operación 'calculo_mora' bajo el usuario 'system'.
Todos los días a las 05:00 UTC (o a pedido desde /estadisticas-cobro/recalcular) recalcula, para cada socio, operación y combinación socio+modalidad, en qué día del mes se le cobra habitualmente.
/estadisticas-cobro/recalcular·Lee solo cobros EXITOSO, no reversados, de canal DEBITO o PAGO_VOLUNTARIO dentro de la ventana configurada, y hace upsert en EstadisticaCobro de las dos variantes (DIA_HABIL y FECHA) por canal, incluso cuando no hay patrón.
Evalúa dos reglas sobre el historial mensual: CONSECUTIVO (3 meses contiguos con el mismo valor) y, si no aplica, PORCENTAJE (3 coincidencias sobre los últimos 5 meses). Si ninguna da, el criterio queda en 'NINGUNO'.
Persiste valorSugerido (día hábil N o día calendario), probabilidad y cantidad de muestras.
Proyecta el valor sugerido a una fecha concreta de cada haber futuro y la escribe en las asignaciones de fecha de cobro.
Actualiza AsignacionFechaCobro.fechaEstadistica solo de haberes del período actual en adelante y solo si el origen no es 'MANUAL'. Nunca crea asignaciones ni pisa fechaManual.
Mantiene el calendario de feriados (alta individual o carga masiva por Excel).
/feriados/listado, /feriados/alta, /feriados/carga-masiva·Cada fila de Feriado cuenta como día no hábil sin importar el tipo. Afecta la estadística (numeración de días hábiles y proyección) y la marca fechaNoHabil de los cobros bancarios, pero NO corre el vencimiento de las cuotas.
Cuando el crédito nuevo se aprueba y tiene pedidos de saldo de tipo RENOVACION o REFINANCIACION vinculados, se imputan automáticamente en la misma transacción de la aprobación.
/operaciones/:operacionId/aprobar-operacion·Crea un Pago nominal de cancelación, marca como 'pagada' las cuotas incluidas del crédito viejo, genera los movimientos internos, deja la operación vieja en 'cancelado_anticipadamente' o 'cancelado_anticipadamente_finalizado' y descuenta el saldo del desembolso del crédito nuevo.
Estados
- pendiente
- Estado inicial de toda CuotaCredito al generarse el plan en la aprobación. Todavía no venció, o venció pero el job de mora aún no la procesó.
- vencida
- CuotaCredito impaga cuya fecha de vencimiento ya pasó. La pone el job diario 'calculos-mora' junto con el interés moratorio acumulado.
- pagadafinal
- CuotaCredito cobrada. Solo la escriben el pago manual de cuotas y el cierre por cancelación/renovación; el import de imputación no la produce.
- EXITOSOfinal
- Resultado de un RegistroCobro cuando la plata efectivamente impactó. Es el único resultado que alimenta el motor de estadística.
- RECHAZADOfinal
- Resultado de un RegistroCobro rechazado por la entidad. Exige un código de rechazo existente y activo en el catálogo CodigoRechazo.
- ARCHIVO_IMPUTACIONfinal
- Origen del RegistroCobro creado por el import del archivo de rendición del organismo.
- MANUALfinal
- Origen del RegistroCobro creado por la carga manual desde pagar-cuotas.
- RESPUESTA_ENTIDADfinal
- Origen previsto para la respuesta de la entidad recaudadora. Solo existe el contrato de tipos: no hay parser ni ruta que lo produzca.
- API_LYRAfinal
- Origen previsto para tarjeta de débito vía API LYRA; es el único que exige informar la hora del impacto. Todavía sin implementación.
- CONSECUTIVO
- Criterio de la estadística cuando los últimos 3 meses contiguos cobraron el mismo día hábil o el mismo día calendario.
- PORCENTAJE
- Criterio de la estadística cuando un mismo valor aparece al menos 3 veces en los últimos 5 meses con cobro.
- NINGUNO
- Criterio de la estadística cuando no hay patrón suficiente. Se persiste igual, con valorSugerido nulo, para distinguir 'calculado sin patrón' de 'nunca calculado'.
- pagado
- Estado de la Operacion cuando ya no le quedan cuotas pendientes ni vencidas. También es el estado del crédito vigente ya liquidado que puede ser cancelado por renovación.
- cancelado_anticipadamente
- Estado de la Operacion vieja tras imputar un pedido de saldo de renovación/refinanciación quedando cuotas sin pagar.
- cancelado_anticipadamente_finalizadofinal
- Estado de la Operacion vieja tras la imputación de renovación cuando ya no le queda ninguna cuota sin pagar.
Reglas que el sistema hace cumplir
- El vencimiento de la primera cuota cae el mes siguiente a la aprobación, en el día que define la modalidad de cobro; si la modalidad no define uno se usa el día 25 (app/lib/prestamos/generarCuotasCredito.ts:61 y :254-276).
- Si el día de vencimiento configurado no existe en el mes destino, se normaliza al último día real del mes (por ejemplo 30 pasa a 28 o 29 en febrero) (app/lib/prestamos/generarCuotasCredito.ts:272-276).
- Si la aprobación supera estrictamente el día de corte de alta de la modalidad, todo el calendario de cuotas se corre un mes; aprobar el mismo día del corte todavía cuenta como el mes en curso (app/lib/prestamos/generarCuotasCredito.ts:218-224).
- El plazo de gracia difiere el calendario en plazoGraciaDias/30 meses sin capitalizar intereses: los montos de la grilla no cambian, solo las fechas (app/lib/prestamos/generarCuotasCredito.ts:263-268).
- El haber de cada cuota (el mes del sueldo del que se descuenta) es el primer día del mes ANTERIOR al vencimiento (app/lib/prestamos/generarCuotasCredito.ts:234-239).
- Toda cuota nace en estado 'pendiente' (app/lib/prestamos/generarCuotasCredito.ts:163).
- Lo que el socio realmente paga es valorCuota + gastoCobranza: el gasto de cobranza de la modalidad es un cargo plano por cuota que no capitaliza ni genera interés ni IVA (app/lib/prestamos/generarCuotasCredito.ts:143 y app/lib/tesoreria/registrarPagoCuotasOperacion.ts:83).
- El archivo de imputación debe traer las columnas idOperacion, importe y fechaCobro; se acepta solo .xlsx y se lee la primera hoja (app/lib/cobranzas/importarImputaciones.server.ts:43 y app/routes/cobranzas/imputaciones-importar.tsx:60-62).
- El importe viene como entero en centavos, sin coma decimal, y debe ser mayor a cero (app/lib/cobranzas/importarImputaciones.server.ts:78-81).
- La fecha de cobro viene en formato AAAAMMDD y se valida que sea una fecha real (app/lib/cobranzas/importarImputaciones.server.ts:63-76).
- Cada fila del archivo se imputa a la cuota pendiente o vencida más antigua de esa operación; si hay varias filas de la misma operación, la segunda toma la siguiente cuota y así sucesivamente (app/lib/cobranzas/importarImputaciones.server.ts:132-146 y :241-262).
- Con concepto CUOTA_SOCIAL la fila no va contra la operación sino contra la suscripción que esa operación originó, vía SuscripcionCuotaSocial.operacionOrigenId (app/lib/cobranzas/importarImputaciones.server.ts:154-190).
- Una fila se rechaza como DUPLICADO si ya existe un cobro EXITOSO para la misma cuota, canal y fecha, o si el mismo archivo la repite (app/lib/cobranzas/importarImputaciones.server.ts:280-296).
- El import no aborta ante una fila fallida: cada cobro corre en su propia transacción para que un error no revierta las filas ya procesadas (app/lib/cobranzas/importarImputaciones.server.ts:344-374).
- El import NO marca la cuota como pagada: registra el cobro y deja la transición de estado al módulo de imputación, que todavía no existe (app/lib/cobranzas/importarImputaciones.server.ts:10-12).
- registrarCobro() es el único punto de escritura de RegistroCobro en toda la aplicación (app/lib/cobranzas/registrarCobro.server.ts:1-10).
- La fecha del cobro es siempre la fecha REAL en la que impactó el dinero, nunca la fecha en que el operador cargó el dato: si el sueldo se acredita el 3 y se imputa el 7, la estadística usa el 3 (app/lib/cobranzas/registrarCobro.server.ts:6-7 y :56).
- No se admite fecha de cobro futura (app/lib/cobranzas/registrarCobro.server.ts:211-213).
- La fecha de cobro no puede ser anterior a la fecha de liquidación del préstamo; si la operación no tiene Pago propio (caso compra de cartera) ese piso no aplica (app/lib/cobranzas/registrarCobro.server.ts:150-157 y :218-226).
- El socio del cobro sale de la operación: si la operación es de titular externo y no tiene socio, el cobro se rechaza con SIN_SOCIO (app/lib/cobranzas/registrarCobro.server.ts:103-106).
- La modalidad de cobro se deriva en cascada: plan comercial, luego servicio social, luego organismo. Si ninguna la define, el cobro se rechaza con SIN_MODALIDAD (app/lib/cobranzas/registrarCobro.server.ts:108-117).
- Un cobro RECHAZADO exige un código de rechazo que exista y esté activo en el catálogo CodigoRechazo (app/lib/cobranzas/registrarCobro.server.ts:166-171 y :191-196).
- Los cobros de API LYRA son los únicos que exigen la hora del impacto en formato HH:mm (app/lib/cobranzas/registrarCobro.server.ts:199-206).
- Un cobro por canal bancario (DEBITO o TARJETA_DEBITO) en día no hábil NO se bloquea: se registra con la marca fechaNoHabil para que cobranzas lo revise, porque suele indicar una fecha mal informada (app/lib/cobranzas/registrarCobro.server.ts:70 y :228-231).
- La clave de deduplicación de cobros es operación + cuota de crédito + cuota social + fecha + canal + resultado, respaldada por un índice único con NULLS NOT DISTINCT (app/lib/cobranzas/registrarCobro.server.ts:233-245 y prisma/schema.prisma:3182).
- Un cobro reversado no se borra: se marca reversado con su fecha de reverso, para dejar la traza de que existió y después se cayó (app/lib/cobranzas/reversarCobro.server.ts:37-40).
- El job de mora toma las cuotas con fechaPago nula y vencimiento anterior al inicio del día en hora Argentina (app/lib/mora/calcularInteresMoratorio.ts:14-20).
- El interés moratorio se calcula como valorCuota x (TNA punitoria / 100 / 365) x días de mora; la tasa sale del plan comercial y, si no hay, se usa 50% TNA por defecto (app/lib/mora/calcularInteresMoratorio.ts:6, :35 y :42-43).
- El cálculo de mora es idempotente por día: hace upsert en HistorialCalculoMora con clave (cuota, fecha de cálculo), así que re-correr el job el mismo día no duplica (app/lib/mora/calcularInteresMoratorio.ts:54-60 y prisma/schema.prisma:2589).
- Cada cálculo de mora deja un AuditLog con operación 'calculo_mora' bajo el usuario 'system' (app/lib/mora/calcularInteresMoratorio.ts:77-93).
- El pago manual toma siempre las N cuotas pendientes/vencidas más viejas y las marca 'pagada' con fechaPago igual a la fecha de cobro informada (app/lib/tesoreria/registrarPagoCuotasOperacion.ts:35-71).
- El pago manual genera un RegistroCobro por cada cuota, no uno agregado, para mantener poblado el vínculo a la cuota (app/lib/tesoreria/registrarPagoCuotasOperacion.ts:73-88).
- Si tras el pago no quedan cuotas pendientes ni vencidas, la operación pasa a estado 'pagado' (app/lib/tesoreria/registrarPagoCuotasOperacion.ts:131-145).
- En pago voluntario se genera un único movimiento por el total; en descuento de haberes, un movimiento por cuota (app/lib/tesoreria/registrarPagoCuotasOperacion.ts:92-129).
- El banco destino del pago tiene que estar habilitado para pagar préstamos (app/lib/tesoreria/registrarPagoCuotasOperacion.ts:26-33).
- El motor de estadística solo considera cobros EXITOSO, no reversados, de canal DEBITO o PAGO_VOLUNTARIO y dentro de la ventana configurada (app/lib/envios/recalcularEstadisticas.server.ts:59-67 y app/lib/envios/constants.ts:36).
- La ventana por defecto es de 5 meses y el tipo de coincidencia preferido por defecto es DIA_HABIL, ambos configurables en la tabla singleton ConfiguracionEstadisticaCobro (app/lib/envios/recalcularEstadisticas.server.ts:302-305 y app/lib/envios/constants.ts:17).
- De cada mes se toma un solo cobro, el primero; un mes sin cobro no genera período y es lo que corta la consecutividad (app/lib/envios/estadisticaCobro.ts:40-55).
- Regla CONSECUTIVO: 3 períodos mensuales contiguos con el mismo valor de día (app/lib/envios/constants.ts:10 y app/lib/envios/estadisticaCobro.ts:70-78).
- Regla PORCENTAJE: 3 coincidencias sobre los últimos 5 períodos; con ese umbral no puede haber empate (app/lib/envios/constants.ts:13-14 y app/lib/envios/estadisticaCobro.ts:81-91).
- Un cobro caído en feriado o fin de semana no aporta a la variante DIA_HABIL pero sí cuenta en la variante FECHA (app/lib/envios/diasHabiles.ts:31-33 y app/lib/envios/estadisticaCobro.ts:124-129).
- Al proyectar por DIA_HABIL, si el mes tiene menos días hábiles que el valor sugerido se usa el último día hábil del mes: la proyección nunca se va al mes siguiente por falta de días hábiles (app/lib/envios/diasHabiles.ts:45-69).
- Al proyectar por FECHA se acota al último día del mes y después se corre al siguiente día hábil, y ahí sí puede caer en el mes siguiente (app/lib/envios/estadisticaCobro.ts:156-167).
- El job solo actualiza la fechaEstadistica de asignaciones cuyo origen no sea 'MANUAL' y cuyo haber sea del período actual en adelante; nunca toca fechaManual ni crea asignaciones (app/lib/envios/recalcularEstadisticas.server.ts:233-256 y :282).
- Los alcances se procesan en orden SOCIO, OPERACION, SOCIO_MODALIDAD para que la proyección más específica pise a la más general sobre la misma asignación (app/lib/envios/recalcularEstadisticas.server.ts:315-317).
- Para el calendario cuenta cualquier fila de Feriado sin filtrar por tipo (NACIONAL, PROVINCIAL, BANCARIO o NO_LABORABLE) (app/lib/envios/recalcularEstadisticas.server.ts:90-95 y app/lib/feriados/tipos.ts:1).
- Es día hábil todo día que no sea sábado ni domingo y que no figure en la tabla Feriado (app/lib/envios/diasHabiles.ts:19-23).
- La imputación de renovación exige pedido de tipo RENOVACION o REFINANCIACION, en estado 'generado' o 'confirmado', mismo socio o titular externo entre ambos créditos, crédito viejo en estado 'pagado' y ninguna cuota incluida ya pagada (app/lib/cancelaciones/imputarRenovacion.ts:58-118).
- La imputación de renovación es idempotente solo si el pedido ya fue imputado por renovación; si fue pagado por otro medio, falla (app/lib/cancelaciones/imputarRenovacion.ts:69-88).
- El número de referencia de la imputación de renovación se arma como REN-{operacionRenovacionId}-{pedidoSaldoId} y es único (app/lib/cancelaciones/imputarRenovacion.ts:120 y prisma/schema.prisma:405).
- La renovación no genera comunicación al cliente: comunicacionEnviada queda explícitamente en false (app/lib/cancelaciones/imputarRenovacion.ts:161).
- Al cerrar un crédito por pedido de saldo, si no quedan cuotas sin pagar la operación queda 'cancelado_anticipadamente_finalizado'; si quedan, 'cancelado_anticipadamente' (app/lib/cancelaciones/cerrarCreditoPorPedido.ts:65-78).
- El cron de mora corre a las 04:00 UTC y el de estadísticas a las 05:00 UTC, en ese orden a propósito (app/jobs/queue.ts:291-307 y :324-340).
- El disparo manual del recálculo solo encola el job y vuelve; un GET a esa ruta redirige al inicio porque todavía no hay pantalla (app/routes/estadisticas-cobro/recalcular.tsx:12-24).
- Importar imputaciones requiere el permiso cobranzas_imputaciones:importar, que tienen admin, supervisor_area_cobranzas y operador_area_cobranzas (app/lib/auth/authorization.ts:235, :332 y :419).
- Recalcular estadísticas requiere estadisticas_cobro:recalcular, que solo tienen admin y supervisor_area_cobranzas: el operador de cobranzas puede leer pero no forzar el recálculo (app/lib/auth/authorization.ts:232 y :331).
- Pagar cuotas requiere pagos:write, que tienen admin, supervisor_area_tesoreria y operador_area_tesoreria; cobranzas NO puede marcar cuotas como pagadas (app/lib/auth/authorization.ts:103, :269 y :377).
- Editar o borrar feriados requiere feriados:write y feriados:delete (admin y supervisor_area_cobranzas); el operador de cobranzas solo tiene feriados:read (app/lib/auth/authorization.ts:323-325 y :413).
- Ver el detalle de cuotas de una operación está abierto a todos los roles operativos de OPERACIONES_ROLES, sin permiso granular (app/routes/operaciones/[id]-ver-operacion/cuotas.tsx:15 y app/lib/auth/roles.ts:75-88).
Dónde vive en el código
/Users/martin.long/Documents/work/rebl/iris/app/routes.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/cobranzas/imputaciones-importar.tsx/Users/martin.long/Documents/work/rebl/iris/app/lib/cobranzas/importarImputaciones.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cobranzas/registrarCobro.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cobranzas/reversarCobro.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cobranzas/contratos-envios.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-ver-operacion/cuotas.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-ver-operacion/pagar-cuotas.tsx/Users/martin.long/Documents/work/rebl/iris/app/lib/tesoreria/registrarPagoCuotasOperacion.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/prestamos/generarCuotasCredito.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/aprobar-operacion/actions.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/mora/calcularInteresMoratorio.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/envios/recalcularEstadisticas.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/envios/estadisticaCobro.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/envios/diasHabiles.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/envios/constants.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/estadisticas-cobro/recalcular.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/feriados/carga-masiva.tsx/Users/martin.long/Documents/work/rebl/iris/app/lib/feriados/tipos.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cancelaciones/imputarRenovacion.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cancelaciones/cerrarCreditoPorPedido.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cancelaciones/revertirImputacionRenovacion.ts/Users/martin.long/Documents/work/rebl/iris/app/jobs/queue.ts/Users/martin.long/Documents/work/rebl/iris/app/jobs/worker.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/auth/authorization.ts/Users/martin.long/Documents/work/rebl/iris/prisma/schema.prisma/Users/martin.long/Documents/work/rebl/iris/docs/plan-alta-cobros.md/Users/martin.long/Documents/work/rebl/iris/docs/plan-calculos-mora.md/Users/martin.long/Documents/work/rebl/iris/docs/plan-motor-estadistica-cobro.md/Users/martin.long/Documents/work/rebl/iris/docs/plan-renovacion-refinanciacion-imputacion.mdA tener en cuenta
- El canal de vuelta desde el organismo todavía no existe como módulo: hay contratos de tipos sin implementación (
app/lib/cobranzas/contratos-envios.ts:1-7), así que hoy la única puerta de entrada de cobros es el import manual. - GAP PRINCIPAL: el ciclo no cierra. El import de imputación registra el cobro pero deja la cuota en 'pendiente' o 'vencida'; el propio código lo declara fuera de alcance a la espera del 'módulo de imputación'. Hoy la única forma de marcar una cuota como pagada es la carga manual de tesorería o el cierre por cancelación/renovación.
- Consecuencia directa: una cuota ya cobrada e imputada por archivo sigue con fechaPago nula, así que el job de mora la sigue tomando como impaga y le acumula interés moratorio día a día. El interés moratorio de la cartera puede estar inflado.
- Otra consecuencia: la próxima corrida del import vuelve a apuntar el cursor a la misma cuota (sigue pendiente/vencida). Solo lo salva el dedup por fecha; si el organismo informa el mismo cobro con otra fecha, entra un cobro duplicado sobre la misma cuota.
- El tramo 'envío al organismo' no existe en el sistema: no hay modelo Envio y RegistroCobro.envioId está declarado sin foreign key, con el comentario de que se conecta cuando se cree. Los parámetros de envío de la modalidad de cobro (diaPresentacion, diaCobro, modalidadEnvio) no los consume nadie.
- Los orígenes RESPUESTA_ENTIDAD (F3) y API_LYRA (F4) están solo como contratos de tipos en app/lib/cobranzas/contratos-envios.ts: no hay parser, ni ruta, ni cliente. Hoy solo entran cobros por ARCHIVO_IMPUTACION y MANUAL.
- No hay ninguna pantalla de estadísticas de cobro ni de registros de cobro. La única ruta es un POST a /estadisticas-cobro/recalcular y su GET redirige al inicio: el negocio no puede ver el resultado del motor desde la app.
- reversarCobro() está implementado y testeado pero ningún código de producción lo llama: no hay ruta ni botón para reversar un cobro mal imputado.
- El calendario de feriados NO afecta el vencimiento de las cuotas: generarCuotasCredito no consulta la tabla Feriado. Los feriados solo pesan en la estadística de cobro y en la marca de anomalía fechaNoHabil. Si el negocio espera que un vencimiento en feriado se corra al día hábil siguiente, hoy no ocurre.
- La estadística de cobro solo se calcula para los canales DEBITO y PAGO_VOLUNTARIO. El canal COD_DESCUENTO, que es justamente el descuento por recibo de sueldo del organismo (el volumen principal del negocio), queda afuera del motor.
- AsignacionFechaCobro nunca se crea: el job solo hace UPDATE de asignaciones existentes y ningún proceso del código las da de alta, así que hoy la proyección de fechas no impacta en nada real.
- El desempate entre canales cuando un sujeto tiene estadística en DEBITO y en PAGO_VOLUNTARIO (gana la de mayor probabilidad y, a igualdad, la de más muestras) está marcado en el propio código como pendiente de validar con el cliente.
- La base de cálculo de la mora es solo valorCuota: no incluye el gasto de cobranza de la modalidad ni el IVA, y no capitaliza. Además pisa interesMoratorio en cada corrida en vez de acumular, así que si el job no corre un día no se recupera nada y si cambia la tasa se recalcula todo el período con la tasa nueva.
- Una cuota que pasó a 'vencida' nunca vuelve a 'pendiente' aunque se regularice: si se paga queda 'pagada', y el único camino de vuelta a 'pendiente' es la reversión de una imputación de renovación.
- El job de mora trae todas las cuotas vencidas en una sola query con include de operación y plan comercial, sin paginado ni lotes. Con una cartera grande es un riesgo de memoria y de duración.
- El import no guarda historial: no hay tabla de corridas ni archivo de control descargable. Los errores se muestran en pantalla y se pierden al recargar. Volver a subir el mismo archivo devuelve todo como DUPLICADO, lo cual funciona como red de seguridad pero no como trazabilidad.
- El default de tasa punitoria cuando el plan comercial no la define es 50% TNA hardcodeado en el código, no configurable desde la aplicación.
- La operación pasa a 'pagado' cuando se salda la última cuota, pero 'pagado' es también el estado del crédito vigente ya liquidado (es el estado que exige imputarRenovacion para poder cancelar). El mismo literal cubre dos situaciones de negocio distintas.
- La separación de permisos deja un circuito partido entre áreas: Cobranzas importa el archivo pero no puede cerrar la cuota, y Tesorería cierra la cuota pero no participa del import. Con el módulo de imputación faltante, alguien tiene que replicar a mano en pagar-cuotas lo que el archivo ya informó.
Cancelación anticipada
Permite que un socio con un crédito ya liquidado pague por adelantado todo o parte de lo que debe, cerrando el préstamo antes de término. El sistema calcula un "saldo de cancelación" (capital, intereses, gastos, seguros, punitorios y compensatorios de las cuotas impagas, más la comisión BCRA por precancelación), lo congela en un Pedido de Saldo con fecha de validez, permite negociarlo con bonificaciones y ajustes, lo documenta con un Certificado de Deuda, y al cobrarlo marca las cuotas como pagadas, genera los movimientos en la cuenta corriente y deja la operación en estado cancelada. El mismo motor se usa cuando la cancelación no se paga con plata del cliente sino con un crédito nuevo (renovación o refinanciación), caso en el que la imputación es automática al aprobar el crédito nuevo.
generado se puede bonificar y ajustar; al confirmarlo con el cliente queda cerrado a cambios. El mismo motor cierra el crédito viejo cuando la cancelación no se paga con plata sino con un crédito nuevo: ahí la imputación es automática al aprobar la renovación.Quién interviene
Qué lo dispara
- El socio pide el saldo para cancelar y un operador entra a la operación (estado 'pagado') y toca 'Generar Saldo' → /cancelaciones/alta/:operacionId
- En el alta de un crédito nuevo, el paso 'Cancelaciones' del wizard: el operador elige uno o varios créditos vigentes del socio para renovar o refinanciar y genera el pedido de saldo atado al crédito nuevo
- La aprobación del crédito nuevo (evento APROBAR): dispara la deducción del saldo del desembolso y la imputación automática de los pedidos de renovación vinculados
- La apertura del listado o de la ficha de un pedido: dispara el vencimiento automático (lazy) de los pedidos cuya fecha de validez ya pasó
- La cancelación o desistimiento del crédito nuevo: revierte la imputación de renovación y reabre el crédito viejo
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Desde la ficha de la operación (solo si está en estado 'pagado') entra a Generar Saldo. La pantalla carga cuotas, socio, plan comercial y organismo, y avisa si el organismo requiere notificación de la cancelación.
/cancelaciones/alta/:operacionId·Ninguno, solo lectura. Si la operación no está en 'pagado' redirige a la ficha de la operación (create.tsx:49)
Elige el tipo (CANCELACION_TOTAL, CANCELACION_PARCIAL, RENOVACION o REFINANCIACION), los días de validez (30 por defecto) y destilda hasta 2 cuotas que no quiere incluir. Las cuotas vencidas no se pueden destildar. Toca 'Simular Saldo'.
/cancelaciones/alta/:operacionId (intent=simular)·No persiste nada. Devuelve el detalle cuota por cuota, el monto de cuotas, la comisión de cancelación anticipada con su IVA, el total y la fecha de vencimiento del saldo
Calcula el saldo: toma las cuotas no pagadas, arma capital + interés + IVA + gastos administrativos + seguro de vida + IVA, y para las vencidas suma punitorios y compensatorios por días de mora. Resuelve las tasas del plan comercial, y si no están, del organismo, y si tampoco, de los defaults (50% punitoria, 30% compensatoria, 1% comisión).
Marca cada cuota como incluida o no y como esCuotaEnVuelo si es pendiente y vence dentro del mes de cálculo
Aplica la comisión BCRA de cancelación anticipada: solo corresponde si al momento de cancelar NO transcurrió el mayor entre un cuarto del plazo original y 180 días desde la aprobación. Se calcula como porcentaje sobre el capital incluido, más 21% de IVA.
Suma comisionCancelacion e ivaComision al total del pedido
Toca 'Generar Pedido de Saldo'. El sistema recalcula (no reutiliza la simulación) y persiste el pedido con sus detalles.
/cancelaciones/alta/:operacionId (intent=crear)·Crea PedidoSaldo en estado 'generado' con montoTotal, fechaEmisión, fechaVencimiento, comisión, IVA y flag alertaCancelacion, más un DetallePedidoSaldo por cada cuota no pagada. Todo queda auditado. Redirige a la ficha del pedido
Si hace falta negociar, aplica bonificaciones sobre PUNITORIOS, COMPENSATORIOS, GASTOS o CAPITAL, por monto o por porcentaje, a una cuota puntual (POR_CUOTA) o repartida proporcionalmente sobre todas las incluidas (MASIVA).
/cancelaciones/aplicar-bonificacion·Crea BonificacionSaldo con el usuario autorizante, baja el montoFinal de cada detalle y recalcula el montoTotal del pedido. Bonificar CAPITAL exige el permiso adicional cancelaciones:bonificar_capital
Alternativamente ajusta a mano el valor crudo de punitorios, compensatorios o gastos adicionales de una cuota, indicando motivo obligatorio.
/cancelaciones/ajustar-detalle·Crea AjusteDetalleSaldo con valor anterior, valor nuevo, motivo y usuario; recalcula subtotal, montoFinal y montoTotal. Queda el historial visible en la ficha
Descarga el Certificado de Deuda en PDF y se lo entrega al cliente por fuera del sistema.
/api/cancelaciones/certificado-deuda?pedidoSaldoId=·Genera el PDF en el momento con @react-pdf/renderer. Solo disponible si el pedido está en 'generado', 'confirmado' o 'pagado'. No registra el envío: los campos comunicacionEnviada y canalComunicacion quedan sin usar
Con el cliente de acuerdo, toca 'Confirmar con Cliente'. A partir de acá el pedido queda congelado: no se pueden aplicar más bonificaciones ni ajustes.
/cancelaciones/:pedidoSaldoId/confirmar·PedidoSaldo pasa a 'confirmado' y se registra confirmadoPorId. Los pedidos de RENOVACION y REFINANCIACION no se pueden confirmar por esta vía
Cuando entra la plata, registra el pago eligiendo la cuenta bancaria destino y el monto recibido.
/cancelaciones/:pedidoSaldoId/registrar-pago·Valida que el pedido esté 'confirmado' y que el monto no difiera del esperado en más de $0,01. Crea un Pago tipo CANCELACION_PRESTAMO en estado 'pagado', lo vincula al pedido vía PagoPedidoSaldo, y pone el pedido en 'pagado' con fechaImputacion, imputadoPorId, numeroReferencia PAGO-<id> y origenImputacion 'PAGO'
Cierra el crédito: agrupa los montos por cuenta corriente, genera un movimiento de crédito por cada una, marca como 'pagada' cada cuota incluida y define el estado final de la operación.
Movimientos con estadoConciliacion 'pendiente' atados al pedido y al pago. Cuotas incluidas con estado 'pagada' y fechaPago. Si no quedan cuotas impagas la operación pasa a 'cancelado_anticipadamente_finalizado'; si quedan (las destildadas o en vuelo) pasa a 'cancelado_anticipadamente'
Si la operación quedó finalizada o cerrada, descarga el Libre Deuda en PDF para el cliente.
/api/cancelaciones/libre-deuda?pedidoSaldoId=·Genera el PDF. Exige pedido en 'pagado' y operación en 'cancelado_anticipadamente_finalizado' o 'cerrado'. Si la operación quedó en 'cancelado_anticipadamente' el botón no aparece
Rama de vencimiento: al abrir el listado o la ficha de un pedido, todos los pedidos en 'generado' o 'confirmado' cuya fecha de validez ya pasó se marcan 'vencido'.
/cancelaciones/listado·updateMany masivo en el loader del listado y update puntual en el loader de la ficha. No hay job programado: si nadie entra a la pantalla, el pedido sigue figurando vigente en la base
Rama de reactivación: un pedido vencido se puede reactivar mientras esté dentro del período de gracia del organismo (diasGraciaSaldo, 5 días por defecto).
/cancelaciones/:pedidoSaldoId/reactivar·Recalcula el saldo a la fecha de hoy conservando las cuotas excluidas, BORRA todas las bonificaciones y ajustes previos, reemplaza los detalles y deja el pedido en 'generado' con nueva fecha de vencimiento
Rama de anulación: si el cliente se arrepiente o el saldo está mal, anula el pedido con motivo obligatorio.
/cancelaciones/:pedidoSaldoId/anular·PedidoSaldo pasa a 'anulado' y el motivo PISA el campo observaciones (se pierde lo que hubiera antes). Estado terminal, no se puede deshacer
Rama renovación/refinanciación: en el paso 'Cancelaciones' del alta del crédito nuevo elige los créditos vigentes del socio a cancelar y genera el pedido de saldo atado al crédito nuevo.
/operaciones/:id/alta-operacion (intent=crearPedidoSaldo)·Crea el PedidoSaldo en 'generado' con operacionRenovacionId apuntando al crédito nuevo, previa validación de exclusividad (un solo pedido de renovación activo por crédito viejo, con lock FOR UPDATE). La pantalla muestra capital solicitado menos sumatoria de cancelaciones igual a total neto
Aprueba el crédito nuevo. En la misma transacción el sistema descuenta del desembolso la sumatoria de los pedidos de renovación vinculados y luego los imputa.
/operaciones/:id/aprobar·El Pago del préstamo se crea por el neto (capital menos saldos de renovación). Si la sumatoria supera el capital disponible, la aprobación falla con error de negocio
Imputa la renovación: cierra el crédito viejo sin ingreso de fondos externos, usando el mismo cierre que el pago normal.
Valida mismo socio, crédito viejo aún en 'pagado' y sin cuotas incluidas ya pagadas. Crea un Pago nominal marcado efectivo (por restricción de la base), marca las cuotas pagadas, genera los movimientos y deja el pedido en 'pagado' con numeroReferencia REN-<creditoNuevo>-<pedido> y origenImputacion 'RENOVACION'
Rama de reversa: si el crédito nuevo se cancela o se desiste después de aprobado, se revierte todo lo imputado.
Reabre las cuotas del crédito viejo ('vencida' si el vencimiento ya pasó, 'pendiente' si no), borra los movimientos y el pago nominal, deja el pedido nuevamente en 'generado' y la operación vieja de vuelta en 'pagado'
Estados
- borrador
- PedidoSaldo. Es el default del modelo en la base, pero ningún flujo actual lo produce: tanto el alta desde cobranzas como el alta desde el wizard de renovación crean el pedido directamente en 'generado'. Solo sobrevive como estado aceptado por las validaciones de bonificación, ajuste y eliminación.
- generado
- PedidoSaldo. El saldo está calculado y congelado, con fecha de validez. Es el único estado donde se puede negociar: bonificar, ajustar, anular o eliminar. Ya permite descargar el Certificado de Deuda.
- confirmado
- PedidoSaldo. El cliente aceptó el importe. Queda cerrado a modificaciones (no se aceptan más bonificaciones ni ajustes) y habilita el registro del pago. Los pedidos de renovación/refinanciación nunca pasan por acá.
- pagadofinal
- PedidoSaldo. El saldo fue cobrado (origenImputacion 'PAGO') o imputado contra un crédito nuevo (origenImputacion 'RENOVACION'). Las cuotas incluidas quedaron pagadas y la operación cambió de estado. Terminal salvo reversa de renovación.
- vencido
- PedidoSaldo. Pasó la fecha de validez sin que se cobrara. No es terminal: se puede reactivar dentro del período de gracia del organismo, lo que recalcula el saldo a hoy y borra las bonificaciones.
- anuladofinal
- PedidoSaldo. Anulado a mano con motivo obligatorio desde 'generado' o 'confirmado'. Terminal, no se revierte.
- pagado
- Operación (crédito). Estado del que parte todo el flujo: el crédito está liquidado y cobrándose. Es requisito duro para poder calcular un saldo de cancelación.
- cancelado_anticipadamente
- Operación. La cancelación se cobró pero quedaron cuotas impagas: típicamente la cuota del mes en curso 'en vuelo' (ya mandada a cobro al organismo) que el operador destildó. El crédito no queda cerrado del todo y no se puede emitir Libre Deuda.
- cancelado_anticipadamente_finalizado
- Operación. La cancelación cubrió todas las cuotas impagas: el crédito quedó saldado. Habilita el Libre Deuda. Sigue pendiente el cierre contable/conciliación.
- cerradofinal
- Operación. Cierre definitivo tras la conciliación. También habilita el Libre Deuda. Hoy ningún flujo del sistema dispara este pasaje: el evento CERRAR existe en la máquina de estados pero nadie lo emite.
Reglas que el sistema hace cumplir
- Solo se puede cancelar anticipadamente un crédito ya liquidado: la operación debe estar en estado 'pagado' (app/lib/cancelaciones/calcularSaldo.ts:60 y app/routes/cancelaciones/create.tsx:49)
- Se pueden dejar afuera del saldo como máximo 2 cuotas (la constante MAX_EXCLUSIONES vale 2 en app/lib/cancelaciones/calcularSaldo.ts:11, validado en :72)
- Las cuotas vencidas impagas nunca son excluibles: el cálculo rechaza el pedido si se intenta (app/lib/cancelaciones/calcularSaldo.ts:85) y el checkbox aparece deshabilitado en la UI (app/routes/cancelaciones/create.tsx:340)
- Las cuotas ya pagadas quedan fuera del detalle del pedido (app/lib/cancelaciones/calcularSaldo.ts:116)
- Las tasas se resuelven en cascada plan comercial → organismo → default (50% TNA punitoria, 30% TNA compensatoria, 1% comisión de cancelación) (app/lib/cancelaciones/calcularSaldo.ts:96-107)
- Punitorios y compensatorios se calculan solo sobre cuotas vencidas, como capital × (TNA/100/365) × días de mora (app/lib/cancelaciones/calcularSaldo.ts:313 y :331)
- La comisión BCRA por cancelación anticipada corresponde únicamente si NO transcurrió el mayor entre un cuarto del plazo (plazo/4 × 30 días) y 180 días desde la fecha de aprobación; se aplica sobre el capital incluido más 21% de IVA (app/lib/cancelaciones/calcularSaldo.ts:384-398)
- En renovación y refinanciación el saldo se arma distinto: las cuotas vencidas van capital + interés + IVA (sin gastos ni seguros) y las cuotas a vencer van solo capital (app/lib/cancelaciones/calcularSaldo.ts:240-254)
- En renovación, cantidadCuotasAVencerRenovacion (plan comercial u organismo) limita cuántas cuotas a vencer entran al saldo; el resto queda excluida (app/lib/cancelaciones/calcularSaldo.ts:189-195)
- Una cuota se considera 'en vuelo' si está pendiente y vence dentro del mes de cálculo; la definición está marcada como TODO abierto en el código (app/lib/cancelaciones/calcularSaldo.ts:347-357)
- Los pedidos de RENOVACION y REFINANCIACION no se confirman a mano: se imputan solos al aprobar el crédito nuevo (app/lib/cancelaciones/gestionarEstadoPedido.ts:18)
- Confirmar exige que el pedido esté en 'generado' (app/lib/cancelaciones/gestionarEstadoPedido.ts:11)
- Anular exige motivo y solo se permite desde 'generado' o 'confirmado'; el motivo sobreescribe el campo observaciones (app/lib/cancelaciones/gestionarEstadoPedido.ts:39 y :46-56)
- El pago solo se registra sobre pedidos 'confirmado' y con una tolerancia de conciliación de $0,01 entre lo esperado y lo recibido (app/lib/cancelaciones/registrarPago.ts:40 y :44)
- El cierre del crédito genera un movimiento de crédito por cada cuenta corriente involucrada, siempre con estadoConciliacion 'pendiente' (app/lib/cancelaciones/cerrarCreditoPorPedido.ts:41-55)
- Si tras el cobro no queda ninguna cuota impaga la operación pasa a 'cancelado_anticipadamente_finalizado'; si queda alguna, a 'cancelado_anticipadamente' (app/lib/cancelaciones/cerrarCreditoPorPedido.ts:65-73)
- La reactivación de un pedido vencido solo procede dentro de fechaVencimiento + diasGraciaSaldo del organismo (default 5 días, prisma/schema.prisma:780) (app/lib/cancelaciones/reactivarPedido.ts:32 y :43)
- Reactivar borra todas las bonificaciones y ajustes ya aplicados y recalcula el saldo a la fecha de hoy (app/lib/cancelaciones/reactivarPedido.ts:69-75)
- Bonificaciones y ajustes solo se aceptan en pedidos 'borrador' o 'generado' (app/lib/cancelaciones/aplicarBonificacion.ts:67 y app/lib/cancelaciones/ajustarDetalle.ts:60)
- Cada usuario tiene un tope de bonificación delegado: si el porcentaje efectivo de la bonificación supera limiteBonificacionCancelacionPorcentaje, se rechaza (app/lib/cancelaciones/aplicarBonificacion.ts:80-104)
- Bonificar CAPITAL requiere el permiso extra cancelaciones:bonificar_capital, que hoy solo tiene el rol ADMIN (app/routes/cancelaciones/aplicar-bonificacion.tsx:24)
- Todo ajuste manual de punitorios, compensatorios o gastos adicionales exige motivo y queda registrado con valor anterior, valor nuevo y usuario (app/lib/cancelaciones/ajustarDetalle.ts:45 y :68-77)
- El vencimiento del pedido es perezoso: se aplica al abrir el listado (updateMany) o la ficha, no hay job programado (app/routes/cancelaciones/list.tsx:91 y app/routes/cancelaciones/view.tsx:93)
- Un crédito no puede tener dos renovaciones/refinanciaciones activas a la vez: se valida con un lock FOR UPDATE sobre la operación para serializar altas concurrentes (app/lib/cancelaciones/validarExclusividadRenovacion.ts:18-32)
- El saldo de renovación se descuenta del desembolso del crédito nuevo y no puede superar el capital disponible (app/lib/cancelaciones/calcularMontoDesembolsoConRenovacion.ts:37-42)
- La imputación de renovación exige que el crédito viejo siga en 'pagado' y que ninguna cuota incluida esté ya pagada (app/lib/cancelaciones/imputarRenovacion.ts:105 y :112)
- La imputación es idempotente solo si el pedido ya fue imputado por renovación; si fue pagado con plata real devuelve error (app/lib/cancelaciones/imputarRenovacion.ts:69-88)
- Cancelar o desistir el crédito nuevo revierte la imputación: reabre las cuotas (como 'vencida' si el vencimiento ya pasó), borra movimientos y pago, y devuelve el pedido a 'generado' (app/lib/operations/transitionEstado.server.ts:136 y app/lib/cancelaciones/revertirImputacionRenovacion.ts:41-89)
- El Certificado de Deuda solo se emite con el pedido en 'generado', 'confirmado' o 'pagado' (app/routes/api/cancelaciones/certificado-deuda.ts:8 y :52)
- El Libre Deuda exige pedido en 'pagado' Y operación en 'cancelado_anticipadamente_finalizado' o 'cerrado' (app/routes/api/cancelaciones/libre-deuda.ts:8, :52 y :59)
- Un crédito con pedido de renovación activo no ocupa cupo de modalidad de cobro para la validación de múltiples créditos (app/lib/operations/validateMultiplesCreditos.ts:101-107)
- Los pedidos de saldo solo se pueden eliminar físicamente desde el wizard de alta y únicamente en 'borrador' o 'generado' (app/routes/operaciones/[id]-alta-operacion/actions.ts:1108)
- Todos los modelos del flujo (PedidoSaldo, DetallePedidoSaldo, BonificacionSaldo, AjusteDetalleSaldo, PagoPedidoSaldo) están auditados con estado antes/después (app/lib/db.server.ts:63 y :80-83)
Dónde vive en el código
app/routes.tsapp/routes/cancelaciones/list.tsxapp/routes/cancelaciones/create.tsxapp/routes/cancelaciones/view.tsxapp/routes/cancelaciones/confirm.tsxapp/routes/cancelaciones/cancel.tsxapp/routes/cancelaciones/register-payment.tsxapp/routes/cancelaciones/reactivar.tsxapp/routes/cancelaciones/aplicar-bonificacion.tsxapp/routes/cancelaciones/ajustar-detalle.tsxapp/routes/cancelaciones/validations.tsapp/routes/cancelaciones/components/BonificacionPanel.tsxapp/routes/cancelaciones/components/AjustePanel.tsxapp/lib/cancelaciones/calcularSaldo.tsapp/lib/cancelaciones/crearPedidoSaldo.tsapp/lib/cancelaciones/gestionarEstadoPedido.tsapp/lib/cancelaciones/registrarPago.tsapp/lib/cancelaciones/cerrarCreditoPorPedido.tsapp/lib/cancelaciones/reactivarPedido.tsapp/lib/cancelaciones/aplicarBonificacion.tsapp/lib/cancelaciones/ajustarDetalle.tsapp/lib/cancelaciones/imputarRenovacion.tsapp/lib/cancelaciones/revertirImputacionRenovacion.tsapp/lib/cancelaciones/validarExclusividadRenovacion.tsapp/lib/cancelaciones/calcularMontoDesembolsoConRenovacion.tsapp/lib/cancelaciones/types.tsapp/lib/cancelaciones/documentos/certificado-deuda.tsxapp/lib/cancelaciones/documentos/libre-deuda.tsxapp/routes/api/cancelaciones/certificado-deuda.tsapp/routes/api/cancelaciones/libre-deuda.tsapp/routes/planes-comerciales/cancelacion-config.tsxapp/routes/operaciones/[id]-alta-operacion/actions.tsapp/routes/operaciones/[id]-alta-operacion/components/Cancelaciones.tsxapp/routes/operaciones/aprobar-operacion/actions.tsapp/lib/operations/transitionEstado.server.tsapp/lib/operations/estados.tsapp/lib/operations/validateMultiplesCreditos.tsapp/state/operacionEstado/machine.tsapp/state/operacionEstado/types.tsapp/lib/auth/authorization.tsapp/lib/db.server.tsprisma/schema.prismadocs/estado-cancelacion-prestamo.mddocs/plan-renovacion-refinanciacion-imputacion.mddocs/plan-cuentas-corrientes-destinatarios.mdA tener en cuenta
- La operación queda colgada en 'cancelado_anticipadamente': el evento FINALIZAR_CANCELACION existe en la máquina de estados (app/state/operacionEstado/machine.ts:251 y :288) pero NINGÚN flujo lo emite. Es decir, si el operador destilda la cuota en vuelo, el crédito queda para siempre en 'cancelado_anticipadamente' aunque después esa cuota se cobre, y por lo tanto nunca se le puede emitir el Libre Deuda. Lo mismo con CERRAR hacia 'cerrado': nadie lo dispara.
- El estado de la operación se escribe directo con un update de Prisma en cerrarCreditoPorPedido.ts:75, sin pasar por la máquina de estados ni por transitionEstado.server.ts. La máquina existe pero el flujo de cancelación la esquiva, así que no hay guardas de rol ni registro de transición para este pasaje.
- La definición de 'cuota en vuelo' es un TODO explícito en el código (calcularSaldo.ts:347). Hoy simplemente marca la cuota pendiente que vence dentro del mes de cálculo, y el flag esCuotaEnVuelo NO se usa para ninguna decisión: solo pinta la fila de naranja y pone un asterisco en la UI. Excluirla o no queda 100% a criterio del operador.
- No hay advertencia al operador de que al destildar la cuota del mes en curso el crédito NO va a quedar cancelado del todo. Se entera después, cuando el botón de Libre Deuda no aparece.
- Anular pisa el campo observaciones con el motivo (gestionarEstadoPedido.ts:55), perdiendo lo que el operador hubiera escrito al generar el pedido. Solo se recupera del audit log.
- La reactivación borra silenciosamente todas las bonificaciones y ajustes (reactivarPedido.ts:69-75) y no persiste ni quién reactivó ni cuándo: no hay campos de trazabilidad, queda solo en el audit log genérico.
- El vencimiento de pedidos no tiene job: depende de que alguien abra el listado o la ficha. Un pedido puede estar realmente vencido y figurar 'confirmado' en la base y en cualquier reporte que no pase por esas pantallas.
- Inconsistencia de base de cálculo de mora: el job calculos-mora calcula interesMoratorio sobre valorCuota (app/lib/mora/calcularInteresMoratorio.ts:42), mientras que el saldo de cancelación calcula punitorios y compensatorios sobre capital/amortización (calcularSaldo.ts:257 y :261). Dos números distintos de mora para la misma cuota.
- Punitorios y compensatorios se calculan con la MISMA fórmula sobre la MISMA base, solo cambia la tasa, y se suman ambos. Es una doble imposición sobre el mismo capital que conviene validar con negocio.
- El estado 'borrador' es letra muerta: es el default del modelo en la base pero ningún flujo lo produce. Las validaciones de bonificación, ajuste y eliminación lo siguen aceptando por las dudas.
- No hay comunicación al cliente: el Certificado de Deuda se descarga y se manda a mano. Los campos comunicacionEnviada y canalComunicacion del modelo están sin usar (imputarRenovacion.ts:161 los setea explícitamente en false y nada más los toca), pese a que existe infraestructura de email en app/lib/email/.
- La comisión de cancelación anticipada no es editable en pantalla al generar el saldo: sale siempre de la config del plan comercial u organismo. El proceso de negocio documentado pedía que fuera editable.
- registrarPago no verifica ni genera la comisión de cancelación por modalidad de cobro, y tampoco distingue el canal del ingreso (débito automático, transferencia, convenio): solo guarda la cuenta bancaria destino. El movimiento queda 'pendiente' de conciliación sin ningún flujo de verificación de Tesorería asociado.
- No hay asiento contable de baja del préstamo al cancelar. Sí existe asiento en el circuito de cancelación automática vía gastos (asientoAprobacionCancelacion), que es otra cosa.
- En la imputación por renovación se crea un Pago marcado esEfectivo:true aunque no haya ingreso real de fondos, solo para satisfacer un constraint de la base (imputarRenovacion.ts:134-137). Ensucia cualquier reporte que cuente pagos en efectivo.
- El flag alertaCancelacion (organismos que requieren aviso, tipo ejército) es puramente informativo: se muestra en pantalla y en el certificado, pero no genera ninguna notificación ni baja en el sistema del organismo.
- Las 'cuentas corrientes de destinatarios' (CuentaCorriente tipo 'CANCELACION') NO pertenecen a este flujo pese al nombre: las alimenta el circuito de gastos con cancelacionAutomatica al aprobar una operación, para pagarle a un tercero al que se le salda una deuda del socio. Son dos conceptos distintos con el mismo nombre, fuente segura de confusión.
- La comisión BCRA se calcula contra operacion.fechaAprobacion, y si esa fecha o el plazo están en null la comisión directamente no se cobra (calcularSaldo.ts:382), sin ningún aviso al operador.
- El certificado de deuda se puede emitir con el pedido 'generado' (todavía sin confirmar) e incluso 'pagado', o sea que se puede entregar al cliente un certificado de un saldo que después se anula o se recalcula.
- El pago exige coincidencia casi exacta ($0,01): no hay circuito para pagos parciales ni para diferencias por gastos bancarios; cualquier desvío obliga a rehacer el pedido.
Conciliación bancaria
Permite cruzar lo que dice el banco contra lo que dice IRIS. Tesorería importa el extracto del banco en CSV, el sistema lo parsea con el mapeo de columnas configurado para ese banco y lo convierte en movimientos bancarios. Después se arma una Conciliación para una cuenta bancaria y un período, se elige el orden de las reglas de matching y se ejecuta el motor, que empareja automáticamente los movimientos del banco con los Movimientos del sistema. El cierre marca cada movimiento como conciliado o no_identificado y recalcula el saldo de la cuenta bancaria a partir de lo conciliado.
no_identificado y una conciliación posterior sobre la misma cuenta y período lo vuelve a levantar.Quién interviene
Qué lo dispara
- Acción manual de usuario: subida del archivo CSV del banco en /extractos-bancarios/importar
- Acción manual de usuario: alta de una Conciliación en /conciliaciones/alta para una cuenta bancaria y un período
- Acción manual de usuario: confirmación en /conciliaciones/:id/ejecutar, que corre el motor de matching de forma sincrónica
- Precondición externa al flujo: el alta de un Banco con su mapeo de columnas CSV de extracto (/bancos/:id/modificar) y el alta de Reglas de Conciliación activas
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Configura, en la ficha del banco, qué columna del CSV del banco corresponde a cada campo destino (fecha, monto, detalle, referencia, tipo, saldo) y de qué tipo de dato es (texto, numérico, fecha). Sin esta configuración el banco no puede recibir extractos.
/bancos/:id/modificar·Reemplaza por completo las filas de CsvExtractoBancario del banco (borra todas y las vuelve a crear) dentro de una transacción.
Da de alta las Reglas de Conciliación: nombre único, tipo (match exacto, por documento, con tolerancia, uno a muchos, muchos a uno), su configuración específica en JSON, el orden por defecto y si está activa.
/reglas-conciliacion/alta·Crea ReglaConciliacion. El JSON de configuración se valida contra el esquema del tipo elegido antes de guardar.
Sube el archivo CSV del extracto: elige la cuenta bancaria, opcionalmente un Concepto Bancario, y adjunta el archivo.
/extractos-bancarios/importar·Valida que la cuenta tenga banco asociado y que el banco tenga mapeo de columnas CSV. Si falta algo, corta y muestra el error sin escribir nada.
Parsea el CSV: verifica que todas las columnas configuradas existan en el encabezado, interpreta fechas argentinas (DD/MM/AAAA, DD-MM-AAAA o ISO) y montos con formato argentino (1.234,56). Determina entrada/salida por la columna tipo o, si no hay, por el signo del monto. Guarda el monto siempre en valor absoluto.
No escribe en base. Devuelve la lista de movimientos parseados y la lista de errores fila por fila. Si hay al menos un error, la importación se aborta entera.
Sube el archivo original a S3 y crea el Extracto Bancario junto con todos sus movimientos, derivando el período desde la fecha mínima y máxima de las filas parseadas.
Crea ExtractoBancario con estado 'procesado' y N MovimientoExtractoBancario con estadoConciliacion 'pendiente'. Si se eligió Concepto Bancario, se aplica el mismo a todos los movimientos del archivo. Todo queda auditado (ExtractoBancario y MovimientoExtractoBancario están en AUDITED_MODELS).
Da de alta la Conciliación eligiendo cuenta bancaria y período (desde/hasta).
/conciliaciones/alta·Verifica que exista al menos un extracto en estado 'procesado' de esa cuenta que se solape con el período; si no, no deja crear. Crea Conciliacion en estado 'borrador' y copia automáticamente TODAS las reglas activas a ConfiguracionReglaConciliacion respetando su orden por defecto.
Ajusta el plan de reglas de esa conciliación: sube o baja el orden de ejecución, saca reglas que no quiere y agrega reglas activas que no estén incluidas.
/conciliaciones/:id/configurar·Actualiza, borra o crea filas de ConfiguracionReglaConciliacion. El orden define la prioridad: la regla que corre primero se queda con el movimiento.
Confirma la ejecución del proceso.
/conciliaciones/:id/ejecutar·Llama a runReconciliation de forma sincrónica dentro del request HTTP. Si sale bien redirige a la vista de resultados; si falla, vuelve a la pantalla de configuración con el mensaje de error.
Abre una transacción, marca la conciliación como en proceso, arma el pipeline dinámico (cargar datos, las reglas en el orden configurado, persistir, cerrar) y lo ejecuta paso a paso dejando log de cuántos matches encontró cada regla.
Conciliacion pasa a estado 'en_proceso'. Todo el pipeline corre en una sola transacción: o queda todo, o no queda nada.
Carga los dos lados a comparar: del lado banco, los movimientos del extracto de esa cuenta con fecha dentro del período y estado 'pendiente' o 'no_identificado'; del lado sistema, los Movimientos que entran o salen de esa cuenta bancaria en el mismo período y con el mismo estado, trayendo además el Pago, la Operación y el CUIL/DNI del socio.
Sólo llena memoria de trabajo. Lo ya conciliado queda afuera, por eso una re-ejecución nunca deshace matches previos.
Aplica cada regla en orden sobre lo que todavía quedó sin emparejar. Match exacto compara los campos elegidos; match por documento busca CUIL/DNI/CBU dentro del texto del detalle bancario y lo cruza contra el socio o el CBU destino del pago; match con tolerancia acepta diferencias de monto y de días; uno a muchos junta varios movimientos del sistema que sumen un movimiento del banco; muchos a uno hace lo inverso.
Va acumulando matches en memoria y sacando de la bolsa de pendientes tanto el movimiento del banco como el del sistema, para que no los tome otra regla posterior.
Persiste todos los matches encontrados.
Crea un ConciliacionMatch por cada par (con el tipo de match, el monto conciliado, la diferencia por tolerancia y el grupo si es match múltiple), pone estadoConciliacion 'conciliado' en el movimiento del banco (con el método usado) y en el Movimiento del sistema, y guarda en cada regla configurada cuántos matches encontró y cuándo se ejecutó.
Cierra la conciliación, etiqueta lo que quedó suelto del lado del banco y recalcula el saldo de la cuenta.
Conciliacion pasa a 'completada' con fecha de cierre y los totales (movimientos del banco, conciliados, no identificados). Los movimientos del banco sin par pasan a 'no_identificado'. El saldo de la CuentaBancaria se recalcula como saldo inicial + créditos conciliados - débitos conciliados (sobre TODOS los movimientos conciliados de la cuenta, no sólo los del período).
Revisa el resultado en la vista de la conciliación: tarjetas de resumen con porcentajes, el detalle de matches con la regla que los generó, y las solapas de pendientes del lado banco y del lado sistema.
/conciliaciones/:id/ver·Sólo lectura. No hay acción de conciliar manualmente ni de desconciliar desde esta pantalla.
Estados
- borrador
- Conciliacion.estado inicial al crearse. Es el único estado en el que la UI habilita configurar reglas y ejecutar, y uno de los dos en los que se puede eliminar.
- en_proceso
- Conciliacion.estado mientras corre el motor. Es un estado transitorio dentro de la misma transacción: en la práctica el usuario casi nunca lo ve, salvo que el proceso quede colgado.
- completadafinal
- Conciliacion.estado final feliz. Tiene completedAt y los totales cargados (totalMovimientosBanco, totalConciliados, totalNoIdentificados).
- canceladafinal
- Conciliacion.estado al que se la lleva cuando el motor falla. Se escribe fuera de la transacción fallida, así que los matches no quedan. Se puede eliminar.
- procesadofinal
- ExtractoBancario.estado. Es el único estado que el código asigna: la importación siempre crea el extracto ya procesado, porque el parseo ocurre antes de escribir en base.
- pendiente
- Valor por defecto de ExtractoBancario.estado en el schema. Ningún camino del código lo deja en este estado.
- errorfinal
- Estado previsto para ExtractoBancario en el schema (junto con errorMensaje, que la vista sabe mostrar). Ningún camino del código lo asigna: si el parseo falla, no se crea el extracto.
- pendiente
- MovimientoExtractoBancario.estadoConciliacion y Movimiento.estadoConciliacion al nacer. Significa 'todavía no lo miró ninguna conciliación'. Es lo que el motor levanta para comparar.
- conciliadofinal
- MovimientoExtractoBancario.estadoConciliacion y Movimiento.estadoConciliacion cuando quedaron emparejados. Es el estado que suma al saldo recalculado de la cuenta bancaria y el que bloquea el borrado del extracto.
- no_identificado
- MovimientoExtractoBancario.estadoConciliacion de los movimientos del banco que la conciliación no logró emparejar. No es final: una conciliación posterior los vuelve a levantar junto con los pendientes.
- descartadofinal
- Estado contemplado en el mapa de colores de la vista del extracto, pensado para movimientos bancarios que se deciden ignorar. Ningún camino del código lo asigna hoy.
Reglas que el sistema hace cumplir
- Un banco sin mapeo de columnas CSV no puede recibir extractos: la importación corta con un mensaje que manda a configurar las columnas en la edición del banco (app/routes/extractos-bancarios/import.tsx:119-125).
- La importación es todo o nada: si el parser devuelve aunque sea un error de una fila, no se crea ni el extracto ni ningún movimiento (app/routes/extractos-bancarios/import.tsx:131-138 y app/lib/conciliaciones/mappers/dynamic-mapper.ts:103-106).
- Antes de leer filas se valida que todas las columnas configuradas para el banco existan en el encabezado del CSV; el error lista las columnas disponibles para facilitar la corrección (app/lib/conciliaciones/mappers/dynamic-mapper.ts:96-101).
- El período del extracto no lo carga el usuario: se deriva de la fecha mínima y máxima de los movimientos parseados (app/routes/extractos-bancarios/import.tsx:148-151).
- Los montos se guardan siempre en valor absoluto; la dirección del dinero queda expresada en el campo tipo ('entrada' o 'salida'), que sale de la columna tipo si el banco la manda o, si no, del signo del monto (app/lib/conciliaciones/mappers/dynamic-mapper.ts:141-155 y 172).
- El parser entiende formato argentino: fechas DD/MM/AAAA y DD-MM-AAAA con fallback a ISO, y montos con punto de miles y coma decimal (app/lib/conciliaciones/mappers/dynamic-mapper.ts:4-51).
- El Concepto Bancario elegido en la importación se aplica en bloque a todos los movimientos del archivo; no hay clasificación por fila (app/routes/extractos-bancarios/import.tsx:187).
- No se puede armar una conciliación de un período sin datos: se exige al menos un ExtractoBancario en estado 'procesado' de esa cuenta que se solape con el rango pedido (app/routes/conciliaciones/create.tsx:61-76).
- Al crear la conciliación se copian automáticamente TODAS las reglas activas del catálogo, con su orden por defecto; el usuario después saca las que no quiera (app/routes/conciliaciones/create.tsx:79-103).
- El orden de las reglas es la prioridad de negocio: la primera regla que engancha un movimiento se lo lleva y lo saca de la bolsa, las reglas siguientes ya no lo ven (app/lib/conciliaciones/engine/runner.ts:66-74 y, por ejemplo, app/lib/conciliaciones/engine/interactors/exact-match.ts:70-71).
- El motor sólo compara lo no resuelto: del lado banco toma movimientos de esa cuenta con fecha en el período y estado 'pendiente' o 'no_identificado'; del lado sistema, Movimientos con esa cuenta bancaria como origen o destino, mismo período y mismos estados (app/lib/conciliaciones/engine/interactors/load-data.ts:33-48).
- El match por documento sólo puede actuar sobre Movimientos que tengan un Pago ligado a una Operación con socio; extrae CUIL (11 dígitos, con o sin guiones), DNI (7 u 8 dígitos) o CBU (22 dígitos) del texto del detalle bancario (app/lib/conciliaciones/engine/interactors/document-match.ts:29-67 y app/lib/conciliaciones/engine/helpers.ts:20-57).
- Los matches agrupados (uno a muchos y muchos a uno) sólo se aceptan si el subconjunto tiene 2 o más movimientos y la suma cae dentro de la tolerancia de monto; el algoritmo es voraz, ordena de mayor a menor y corta al llegar al máximo configurado (app/lib/conciliaciones/engine/interactors/one-to-many.ts:113-141 y many-to-one.ts:111-139).
- Cuando hay diferencia de monto tolerada, se guarda explícitamente en diferenciaTolerancia del match, para que quede trazabilidad del desvío aceptado (app/lib/conciliaciones/engine/interactors/tolerance-match.ts:40 y 60).
- Al persistir, se marcan como 'conciliado' los dos lados del par y se registra en el movimiento bancario el método de match usado; además se guarda por regla configurada cuántos matches encontró y cuándo corrió (app/lib/conciliaciones/engine/interactors/persist-matches.ts:38-66).
- Lo que quedó sin par del lado del banco se marca 'no_identificado' al cerrar. Lo que quedó sin par del lado del sistema NO cambia de estado: sigue en 'pendiente' (app/lib/conciliaciones/engine/interactors/update-stats.ts:44-50).
- El impacto de tesorería es el recálculo del saldo de la cuenta bancaria: saldo inicial más la suma de los movimientos conciliados que entran, menos la suma de los que salen. El cálculo NO se limita al período de la conciliación, toma todo lo conciliado histórico de esa cuenta (app/lib/conciliaciones/engine/interactors/update-stats.ts:58-86).
- Todo el pipeline corre dentro de una única transacción de base de datos; si algo falla no queda ningún match a medias y la conciliación se marca 'cancelada' (app/lib/conciliaciones/engine/runner.ts:22-27 y 152-169).
- Sólo se pueden eliminar conciliaciones en estado 'borrador' o 'cancelada'; el control está tanto al abrir la pantalla como al confirmar (app/routes/conciliaciones/delete.tsx:40-42 y 61-67).
- No se puede borrar un extracto que tenga movimientos conciliados; el mensaje pide desconciliar primero, aunque no existe pantalla para desconciliar (app/routes/extractos-bancarios/delete.tsx:54-68 y 121-129).
- No se puede borrar una Regla de Conciliación que ya haya sido usada en alguna conciliación (app/routes/reglas-conciliacion/delete.tsx:54-60).
- El nombre de la Regla de Conciliación es único y la configuración JSON se valida contra el esquema del tipo elegido antes de guardar (app/routes/reglas-conciliacion/create.tsx:57-69 y app/routes/reglas-conciliacion/validations.ts:56-76).
- Valores por defecto de las reglas al crearlas: match exacto compara fecha+monto+referencia; match por documento usa CUIL y exige que coincida el monto; tolerancia arranca en 0,01 peso y 1 día; los agrupados permiten hasta 10 movimientos con 0,01 de tolerancia (app/routes/reglas-conciliacion/validations.ts:78-91).
- La ejecución y la configuración están separadas por permiso: conciliaciones:write habilita crear/configurar/eliminar, conciliaciones:execute sólo habilita disparar el proceso. El operador de tesorería puede ejecutar pero no crear ni configurar (app/lib/auth/authorization.ts:112-119 y 364-385).
- Todas las entidades del circuito están bajo auditoría automática con estado antes/después: ExtractoBancario, MovimientoExtractoBancario, Conciliacion, ReglaConciliacion y ConciliacionMatch (app/lib/db.server.ts:65-70).
Dónde vive en el código
/Users/martin.long/Documents/work/rebl/iris/app/routes.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/extractos-bancarios/import.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/extractos-bancarios/view.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/extractos-bancarios/list.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/extractos-bancarios/delete.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/extractos-bancarios/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/mappers/dynamic-mapper.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/mappers/types.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/bancos/components/CsvExtractoEditor.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/bancos/update.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/reglas-conciliacion/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/reglas-conciliacion/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/reglas-conciliacion/update.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/reglas-conciliacion/delete.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/conciliaciones/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/conciliaciones/configure.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/conciliaciones/execute.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/conciliaciones/view.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/conciliaciones/delete.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/conciliaciones/list.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/conciliaciones/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/conciliaciones/components/ReconciliationSummary.tsx/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/runner.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/types.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/helpers.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/interactors/index.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/interactors/load-data.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/interactors/exact-match.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/interactors/document-match.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/interactors/tolerance-match.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/interactors/one-to-many.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/interactors/many-to-one.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/interactors/persist-matches.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/conciliaciones/engine/interactors/update-stats.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/auth/authorization.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/auth/roles.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/db.server.ts/Users/martin.long/Documents/work/rebl/iris/app/layout/AppSidebar.tsx/Users/martin.long/Documents/work/rebl/iris/prisma/schema.prismaA tener en cuenta
- NO EXISTE el matching manual. El permiso conciliaciones:manual_match está definido y asignado a admin, supervisor y operador de tesorería (authorization.ts:115, 277, 382), y la base tiene los campos preparados (ConciliacionMatch.esManual, MovimientoExtractoBancario.matchManual, observaciones), pero ningún archivo de app/routes ni de app/lib los usa. La pantalla /conciliaciones/:id/ver es de sólo lectura: no hay forma de emparejar a mano lo que el motor no encontró, ni de romper un match equivocado.
- Tampoco existe la desconciliación. El borrado del extracto dice 'debe desconciliar los movimientos antes de eliminar el extracto' (extractos-bancarios/delete.tsx:125-127), pero no hay ninguna acción que devuelva un movimiento de 'conciliado' a 'pendiente'. En la práctica, un extracto con al menos un movimiento conciliado no se puede borrar nunca.
- Los movimientos 'no_identificado' desaparecen de la vista. La solapa 'Pendientes Banco' filtra estrictamente por estadoConciliacion 'pendiente' (conciliaciones/view.tsx:83), así que apenas termina la ejecución los movimientos sin par salen de esa solapa y no aparecen en ninguna otra. Sólo se ven como número en la tarjeta 'No Identificados' y en la vista del extracto.
- El conteo de conciliados puede inflarse. totalConciliados se calcula como la cantidad de filas de match (update-stats.ts:11), no como cantidad de movimientos bancarios distintos. En un match uno a muchos, un solo movimiento del banco emparejado contra 4 del sistema suma 4 al total, y también a totalMovimientosBanco. Los porcentajes del resumen quedan distorsionados en cuanto se usan reglas de agrupación.
- Ni la configuración ni la ejecución validan el estado en el servidor. configure.tsx no chequea que la conciliación esté en 'borrador' antes de agregar, quitar o reordenar reglas, y execute.tsx no chequea el estado antes de correr el motor: la protección es sólo visual (la UI esconde los botones). Un POST directo permite reconfigurar o relanzar una conciliación ya completada, lo que pisaría los totales y el completedAt.
- El estado 'error' del extracto es letra muerta. El schema lo prevé junto con errorMensaje, y la vista sabe mostrarlo, pero la importación sólo crea el extracto cuando el parseo salió perfecto, siempre con estado 'procesado' (import.tsx:172). Nunca queda registro persistido de un archivo que falló. Lo mismo con el estado 'descartado' de los movimientos, que sólo existe en el mapa de colores de la vista.
- El recálculo del saldo bancario ignora el período. updateStats suma TODOS los movimientos conciliados históricos de la cuenta (update-stats.ts:60-86), no los del período conciliado. Es consistente si la cuenta se concilia siempre por IRIS, pero conciliar un período viejo recalcula igual el saldo actual, y ese saldo depende de que saldoInicial esté bien cargado.
- La conciliación no genera asientos contables. El vínculo con contabilidad es indirecto: el Concepto Bancario apunta a una Cuenta Contable y se muestra en la vista del extracto, pero nada en app/lib/contabilidad se dispara desde el motor. El impacto real es únicamente sobre el saldo de la CuentaBancaria (tesorería).
- El Concepto Bancario se aplica a todo el archivo por igual (import.tsx:187). Como el concepto es lo que determina la cuenta contable del movimiento bancario, un extracto mensual mezclado (acreditaciones de cuotas, comisiones, impuestos, transferencias) queda todo clasificado con el mismo concepto o sin ninguno. No hay forma de reclasificar un movimiento después de importado.
- La moneda del extracto no se toma de la cuenta. ExtractoBancario.moneda tiene default 'ARS' y la importación nunca lo setea, así que un extracto de una cuenta en dólares queda igual guardado como ARS. Las reglas de matching tampoco comparan moneda entre los dos lados.
- La subida a S3 ocurre dentro de la transacción de base (import.tsx:156-162). Es I/O externo bloqueando una transacción: con un archivo grande o S3 lento se alarga la transacción, y si S3 falla se revierte todo (correcto), pero si la transacción falla después de subir, el archivo queda huérfano en S3.
- El motor corre entero en el request HTTP, sin job en background. Un extracto de varios miles de movimientos multiplica las consultas: persistMatches hace 3 escrituras por match, una por una, y las reglas de agrupación son O(n·m). Escala mal y puede llegar a timeout sin ningún feedback de progreso al usuario.
- El menú lateral sólo muestra Extractos, Conciliaciones y Reglas al rol admin (AppSidebar.tsx:105-107), aunque supervisor y operador de tesorería tienen los permisos del circuito. Hoy esos roles no tienen cómo llegar a las pantallas salvo escribiendo la URL.
- Las reglas 'uno a muchos' y 'muchos a uno' usan un algoritmo voraz que ordena por monto descendente y corta al primer subconjunto que cierre dentro de la tolerancia. No busca la mejor combinación ni detecta ambigüedad: si dos combinaciones distintas suman lo mismo, se queda con la primera que encuentra, sin marcarla como dudosa.
- El match 'uno a muchos' filtra candidatos sólo por monto (que sea menor o igual al del banco), sin mirar fecha ni dirección del dinero. Puede llegar a agrupar un cobro con un pago si los montos cierran (one-to-many.ts:39-42).
- La conciliación siempre copia todas las reglas activas al crearse (create.tsx:79-103). Si el catálogo crece, cada conciliación nueva arranca con el pipeline completo y hay que ir sacando reglas a mano; no existe el concepto de plantilla o perfil de conciliación por cuenta o por banco.
Contabilidad, gastos y comisiones
Este flujo cubre cómo IRIS convierte automáticamente ciertos hechos económicos (aprobación de un préstamo, gastos con cancelación automática, pago de esos gastos por Tesorería, confirmación y cobro de una venta de cartera) en asientos contables partida doble, y cómo Contabilidad los revisa, exporta y confirma por período. El puente entre el hecho y el asiento es la Configuración Contable híbrida: el código es dueño de la estructura del asiento (qué líneas, de qué lado y cómo se calcula cada monto, garantizando que Debe = Haber), y Contabilidad solo elige qué cuenta contable usa cada rol de cada tipo de asiento, identificado por su origenTipo. En paralelo, el módulo de Comisiones liquida por período lo que se le debe a cada organización comercial, con un circuito propio de borrador, envío, aprobación o rechazo y marcado como pagado, que impacta cuentas corrientes y genera pagos, pero que hoy NO produce asientos contables.
Quién interviene
Qué lo dispara
- Aprobación de una operación de préstamo desde /operaciones/:id/aprobar-operacion: genera el asiento operacion_alta y, si la operación tiene gastos con cancelación automática, un asiento gasto_cancelacion_aprobacion por cada gasto.
- Tesorería marca un lote de pagos como pagado (/pagos-masivos/:id/marcar-pagado): genera un asiento gasto_cancelacion_pago por cada pago que corresponda a una cancelación de gasto.
- Confirmación de una venta de cartera (/ventas-cartera/:id/confirmar): intenta generar el asiento venta_cartera_confirmacion.
- Cobro de una venta de cartera (/ventas-cartera/:id/cobrar): intenta generar el asiento venta_cartera_cobro.
- Confirmación de una recompra de cartera (/recompra-cartera/:id/confirmar): intenta generar el asiento recompra_cartera_confirmacion, que hoy siempre falla por diseño (matriz contable pendiente).
- Acción manual de Contabilidad: confirmar un asiento individual o confirmar todo un período desde /contabilidad/asientos/listado.
- Acción manual de Contabilidad: reasignar la cuenta de un rol en /contabilidad/configuracion/:origenTipo/editar.
- Alta manual de una liquidación de comisiones eligiendo período desde /comisiones/alta.
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Da de alta el plan de cuentas: cada Cuenta Contable con nombre, código opcional y flag activo. Solo las cuentas activas aparecen después para asignar.
/cuentas-contables/listado y /cuentas-contables/alta·Crea filas en CuentaContable. Modelo auditado.
Entra a Configuración Contable y ve el catálogo fijo de tipos de asiento que define el código, con un semáforo de Configurado o Faltan cuentas según cuántos roles de la plantilla ya tienen cuenta asignada.
/contabilidad/configuracion/listado·Solo lectura: cruza el catálogo en código (PLANTILLAS) contra TipoAsiento y TipoAsientoCuenta.
Para un origenTipo concreto, asigna una cuenta contable a cada rol de la plantilla. No puede agregar ni quitar líneas, ni elegir Debe o Haber, ni tocar los montos: eso lo fija el código.
/contabilidad/configuracion/:origenTipo/editar·Upsert de TipoAsiento (activo = true) y de un TipoAsientoCuenta por rol, con llamadas explícitas dentro de una transacción para que quede auditado. Valida que todas las cuentas estén activas y que los roles compartidos entre tipos apunten a la misma cuenta.
Da de alta los Gastos del catálogo: descripción, cuenta contable de imputación, si incluye IVA, si suma al CFT, si es cancelación automática (con cuenta bancaria y destinatario) y si es integración de capital.
/gastos/listado, /gastos/alta, /gastos/:id/modificar·Crea o actualiza Gasto. Si es cancelación automática, además asegura la cuenta corriente del destinatario. Modelo auditado.
Asocia cada gasto a los planes comerciales, definiendo si el monto es FIJO (pesos) o PORCENTUAL (porcentaje 0 a 100 sobre el capital) y si impacta la liquidación del desembolso.
/planes-comerciales/:planId/modificar·Crea o actualiza filas GastoPlanComercial con tipoMonto, monto e impactaLiquidacion. Modelo auditado.
Al cargar una operación materializa los gastos del plan y del servicio social en la operación, congelando un snapshot completo de la configuración del gasto y resolviendo el monto: los porcentuales se estiman con el capital solicitado.
/operaciones/:operacionId/alta-operacion·Crea, actualiza o borra OperacionGasto con monto y snapshotConfig. Si un gasto aplicable no tiene monto configurado, aborta con un error dirigido a Contabilidad.
Al aprobar la operación, el sistema primero verifica que la configuración contable de operacion_alta esté completa, y si la operación tiene gastos con cancelación automática también la de gasto_cancelacion_aprobacion. Si falta algo, la aprobación se rechaza con un mensaje explícito.
/operaciones/:operacionId/aprobar-operacion·Chequeo de solo lectura antes de abrir la transacción. Devuelve HTTP 400 y no cambia nada si la configuración está incompleta.
Recalcula el monto en pesos de los gastos porcentuales con el capital otorgado final, sin volver a leer el plan comercial (la lista de gastos y sus flags quedaron congelados en la carga).
/operaciones/:operacionId/aprobar-operacion·Actualiza OperacionGasto.monto de las filas cuyo snapshot dice PORCENTUAL.
Aplica los gastos: por cada gasto con cancelación automática y destinatario crea el pago pendiente al tercero, su vínculo con la operación, el movimiento de tesorería con el desglose Neto e IVA, y el asiento de aprobación de la cancelación. Los demás gastos generan solo un movimiento de imputación.
/operaciones/:operacionId/aprobar-operacion·Crea Pago en estado pendiente, PagoCancelacionGasto, Movimiento de débito y un Asiento por gasto con origenTipo gasto_cancelacion_aprobacion. Devuelve además cuánto capital se integra y cuánto se descuenta del desembolso al socio.
Genera el asiento de alta del préstamo con el capital, el interés total de las cuotas, el monto efectivamente desembolsado y la acción cooperativa (diferencia entre capital y desembolso).
/operaciones/:operacionId/aprobar-operacion·Crea un Asiento origenTipo operacion_alta en estado pendiente, o devuelve el existente si ya se había generado para esa operación.
Para cualquiera de estos hitos, resuelve la configuración del origenTipo, calcula el monto de cada línea de la plantilla con el contexto del evento y crea el asiento validando que Debe iguale Haber y tomando un número correlativo único de una secuencia de base de datos.
Crea Asiento (numero, fecha, concepto, origenTipo, origenId, total, estado pendiente) y una AsientoLinea por cada línea de la plantilla. Ambos modelos auditados.
Marca el lote de pagos como pagado. Antes valida que la configuración contable de gasto_cancelacion_pago esté completa y que cada pago de cancelación tenga destinatario; si no, aborta el lote entero.
/pagos-masivos/:id/marcar-pagado·Crea movimientos bancarios, pasa cada Pago a pagado y genera un Asiento origenTipo gasto_cancelacion_pago por cada cancelación, que netea la Caja de Cancelaciones contra el banco.
Confirma la cesión y luego registra el cobro. Cada hito intenta su asiento; si la configuración falta, la operación igual se completa y solo queda un aviso en pantalla y un error en el log.
/ventas-cartera/:id/confirmar y /ventas-cartera/:id/cobrar·Crea Asiento venta_cartera_confirmacion (neto a cobrar contra baja de capital e interés cedidos) y venta_cartera_cobro (banco contra cancelación del neto a cobrar), o los omite silenciosamente.
Consulta el libro de asientos con filtros por número, tipo de asiento, estado, mes y año o rango de fechas, y entra al detalle para ver las líneas con su cuenta, Debe y Haber, y el link al documento de origen.
/contabilidad/asientos/listado y /contabilidad/asientos/:id/ver·Solo lectura.
Descarga los asientos filtrados en Excel: listado (una fila por asiento), resumen (totales de Debe y Haber acumulados por cuenta) o detallado (una fila por línea).
/contabilidad/asientos/exportar y /contabilidad/asientos/descargar·Genera un xlsx. No modifica datos.
Confirma un asiento individual desde su ficha, o confirma en bloque todos los pendientes de un período (mes y año o rango de fechas, opcionalmente filtrado por tipo de asiento).
/contabilidad/asientos/:id/ver y /contabilidad/asientos/confirmar-periodo·Pasa los asientos de pendiente a confirmado y registra quién confirmó y cuándo. No hay acción de reversión.
Genera la liquidación eligiendo período desde y hasta. El sistema valida que no exista otra liquidación superpuesta que no esté rechazada.
/comisiones/alta·Si hay superposición, muestra el error con link a la liquidación existente y no crea nada.
Toma todas las operaciones con fecha de aprobación dentro del período y estado aprobado, pagado o desistido, y por cada una calcula la comisión propia de la organización vendedora leyendo el convenio (porcentaje, base de cálculo y excepción vigente) y, si corresponde, un único tramo de comisión para la organización padre.
/comisiones/alta·Crea LiquidacionComision en estado borrador y un DetalleLiquidacionComision por cada par operación y organización beneficiaria, marcando esDesistido cuando la operación se cayó.
Revisa la liquidación en dos solapas: el detalle transaccional operación por operación y el resumen ejecutivo por organización con cantidad de operaciones, capital, porcentaje promedio ponderado, comisión neta, anticipos, gastos adicionales y total a pagar.
/comisiones/:liquidacionId/ver·Solo lectura. El resumen se calcula al vuelo, no se persiste.
Envía la liquidación a aprobación.
/comisiones/:liquidacionId/enviar·Pasa el estado de borrador a pendiente_aprobacion. Si no estaba en borrador, redirige sin hacer nada.
Aprueba la liquidación. Se registra quién aprobó y cuándo, y se debita la cuenta corriente de cada organización por su comisión neta.
/comisiones/:liquidacionId/aprobar·Estado aprobado, aprobadoPorId y fechaAprobacion. Crea un Movimiento de débito y decrementa el saldo de la CuentaCorriente de cada organización con comisión neta positiva.
Alternativamente rechaza la liquidación indicando obligatoriamente un motivo.
/comisiones/:liquidacionId/rechazar·Estado rechazado y motivoRechazo guardado. Una liquidación rechazada deja libre el período para volver a generarlo.
Marca la liquidación como pagada una vez que Tesorería transfirió.
/comisiones/:liquidacionId/marcar-pagado·Estado pagado y alta de un Pago tipo COMISION en estado pendiente por cada organización con comisión neta positiva, con su CBU y titular.
Exporta la liquidación a CSV, en versión detalle o resumen, con BOM UTF-8 para que abra bien en Excel.
/comisiones/:liquidacionId/exportar·Genera un CSV. No modifica datos.
Estados
- pendiente
- Asiento generado automáticamente por el sistema y todavía no revisado ni cerrado por Contabilidad. Es el estado inicial de todo asiento.
- confirmadofinal
- Asiento cerrado por Contabilidad, con constancia de quién lo confirmó y cuándo. No existe ninguna ruta ni acción para volverlo a pendiente.
- borrador
- Liquidación de comisiones recién generada a partir del período elegido. Se puede revisar, exportar y eliminar.
- pendiente_aprobacion
- Liquidación de comisiones enviada al aprobador. Todavía se puede eliminar.
- aprobado
- Liquidación aprobada: quedó registrado el aprobador y la fecha, y ya se debitó la cuenta corriente de cada organización. No se puede eliminar ni volver atrás.
- rechazadofinal
- Liquidación rechazada con motivo obligatorio. Libera el período para generar una nueva liquidación y se puede eliminar.
- pagadofinal
- Liquidación marcada como pagada: se generaron los pagos tipo COMISION para cada organización. Estado terminal.
Reglas que el sistema hace cumplir
- El asiento no lo diseña el usuario: la estructura (qué líneas, de qué lado y con qué fórmula) vive en el catálogo fijo de plantillas del código, una por origenTipo (app/lib/contabilidad/plantillas.ts:23-162). El usuario solo elige la cuenta de cada rol.
- Un asiento nunca se guarda desbalanceado: si la suma del Debe difiere de la del Haber en más de 0,01 se lanza un error y la transacción entera se cae (app/lib/contabilidad/crearAsiento.ts:22 y crearAsiento.ts:28-32).
- El número de asiento sale de una secuencia de base de datos (asiento_numero_seq), no de un contador en aplicación, para que no haya huecos ni duplicados bajo concurrencia (app/lib/contabilidad/crearAsiento.ts:34-37 y prisma/migrations/20260512120000_asiento_numero_sequence/migration.sql:2).
- Todo asiento nace en estado pendiente (prisma/schema.prisma:2602).
- Para generar un asiento tienen que cumplirse tres condiciones: que exista plantilla para el origenTipo, que haya un TipoAsiento activo para ese origenTipo, y que TODOS los roles de la plantilla tengan cuenta asignada; si falta alguna, el error nombra las líneas sin cuenta (app/lib/contabilidad/generarAsientoDesdeConfig.ts:36-64).
- La aprobación de una operación se BLOQUEA con HTTP 400 si la configuración contable de operacion_alta está incompleta, y también si la operación tiene gastos con cancelación automática y falta la configuración de gasto_cancelacion_aprobacion (app/routes/operaciones/aprobar-operacion/actions.ts:220-231 y actions.ts:246-261).
- La generación de asientos es idempotente por par origenTipo y origenId: si el asiento ya existe se devuelve el existente en vez de duplicarlo (app/lib/contabilidad/asientoAltaOperacion.ts:28-37, app/lib/gastos/asientoAprobacionCancelacion.ts:37-46, app/lib/gastos/asientoPagoCancelacion.ts:36-45).
- El asiento de alta de préstamo balancea por construcción: Deudores = capital + interés en el Debe, y en el Haber Banco = monto desembolsado, Intereses no devengados = interés y Capital social = acción cooperativa, donde acción = capital otorgado menos monto desembolsado (app/lib/contabilidad/plantillas.ts:24-53 y app/lib/contabilidad/asientoAltaOperacion.ts:44-50).
- El rol caja_cancelaciones tiene que apuntar a la MISMA cuenta contable en gasto_cancelacion_aprobacion y en gasto_cancelacion_pago; si no, cada asiento balancea por separado pero la caja nunca vuelve a cero. La validación cruzada rechaza el guardado y la UI muestra la nota de qué otro tipo debe coincidir (app/lib/contabilidad/rolesCompartidos.ts:14-21 y app/routes/contabilidad/configuracion/update.tsx:101-110).
- Solo se pueden asignar cuentas contables activas; si alguna está inactiva o no existe, el formulario devuelve error (app/routes/contabilidad/configuracion/update.tsx:86-96).
- Guardar la configuración de un origenTipo lo activa automáticamente: hace upsert de TipoAsiento con activo = true (app/routes/contabilidad/configuracion/update.tsx:112-117).
- Confirmar un período exige acotar por mes y año válidos o por rango desde y hasta; sin período la acción se rechaza porque confirmar es irreversible y arrastraría todos los pendientes del sistema (app/routes/contabilidad/asientos.confirmar-periodo.tsx:36-44).
- La confirmación individual solo actualiza asientos que estén en pendiente y trata la carrera o el doble submit como idempotente (app/routes/contabilidad/asientos.view.tsx:79-86 y asientos.view.tsx:88-91).
- Un gasto debe incluir CFT salvo que sea cancelación automática o integración de capital, y esos dos tipos NUNCA pueden incluir CFT (app/routes/gastos/validations.ts:47-61).
- Un gasto con cancelación automática exige obligatoriamente cuenta bancaria y organización destinataria (app/routes/gastos/validations.ts:63-77).
- En el plan comercial cada gasto se configura como FIJO (importe en pesos) o PORCENTUAL (porcentaje 0 a 100 aplicado sobre el capital base) y con el flag impactaLiquidacion (prisma/schema.prisma:2723-2725 y app/lib/gastos/resolverGastosPlan.ts:163-170).
- En la carga de la operación el monto porcentual se estima con el capital solicitado; en la aprobación se recalcula con el capital otorgado final SIN volver a leer el plan comercial, para que la lista de gastos y sus flags queden congelados desde la carga (app/lib/gastos/sincronizarGastosOperacion.ts:105-137).
- Si un gasto porcentual no tiene capital base disponible, se omite de la operación con un warning en vez de bloquear (app/lib/gastos/sincronizarGastosOperacion.ts:72-81); si un gasto FIJO no tiene monto configurado, la carga se aborta pidiendo a Contabilidad que lo cargue (app/lib/gastos/sincronizarGastosOperacion.ts:44-53).
- Se descuentan del desembolso al socio los gastos que sean integración de capital, los de cancelación automática con destinatario, y los marcados impactaLiquidacion, contando cada gasto una sola vez aunque tenga varios flags (app/lib/gastos/aplicarGastosAprobacion.ts:81-83).
- El IVA de un gasto se desagrega al 21 por ciento fijo para el concepto del movimiento; el monto guardado sigue siendo el total (app/lib/gastos/splitMontoIva.ts:1-10 y app/lib/gastos/aplicarGastosAprobacion.ts:119).
- Un gasto no se puede eliminar si ya generó pagos de cancelación o si está aplicado en operaciones aprobadas: hay que desactivarlo con el flag activo, porque borrarlo dejaría vivo un Pago huérfano capaz de abortar un lote entero de tesorería (app/lib/gastos/gasto-delete-guards.ts:17-24 y gasto-delete-guards.ts:42-53).
- Tesorería no puede marcar un lote como pagado si la configuración de gasto_cancelacion_pago está incompleta o si algún pago de cancelación quedó sin destinatario: aborta el lote completo (app/lib/tesoreria/marcarPagoMasivoPagado.ts:111-131).
- No puede haber dos liquidaciones de comisiones con períodos superpuestos, salvo que la anterior esté rechazada (app/lib/comisiones/validarPeriodo.ts:3-14 y app/routes/comisiones/create.tsx:50-59).
- La liquidación toma operaciones con fechaAprobacion dentro del período, estado aprobado, pagado o desistido, y con organización asignada (app/lib/comisiones/calcular.ts:23-44).
- La comisión propia se lee del snapshot del convenio de la operación (porcentaje, base capital_solicitado o capital_otorgado y excepción), sin ningún fallback a los campos de la organización (app/lib/comisiones/calcular.ts:66-89).
- El porcentaje de excepción se aplica si la fechaSolicitud de la operación cae dentro de la ventana de excepción; si no, se usa el porcentaje normal (app/lib/comisiones/calcular.ts:69-75 y calcular.ts:137-155).
- La comisión ascendente es de UN SOLO nivel: se le paga al padre inmediato de la organización del convenio, con el porcentaje porcentajeComisionOrgAscendente; los abuelos y niveles más arriba no cobran nada (app/lib/comisiones/calcular.ts:91-108).
- Las operaciones desistidas se cargan igual como detalle pero marcadas esDesistido y se RESTAN de la comisión neta de la organización (app/lib/comisiones/calcular.ts:58 y app/lib/comisiones/liquidar.ts:36-40).
- Al aprobar, solo se generan movimientos para organizaciones con comisión neta mayor a cero; las que quedan en cero o negativo se saltean sin dejar rastro (app/lib/comisiones/liquidar.ts:47-48).
- Al marcar como pagada se crea un Pago tipo COMISION en estado pendiente por organización, con el CBU y el nombre de la organización como titular destino (app/lib/comisiones/liquidar.ts:118-132).
- El total a pagar del resumen ejecutivo es comisión neta menos saldo de anticipos menos gastos adicionales, y nunca puede ser negativo: se trunca en cero (app/lib/comisiones/resumen.ts:71 y resumen.ts:85).
- Los gastos adicionales del resumen se identifican por el texto exacto del concepto del movimiento: solo cuentan los conceptos Retencion Impositiva y Gasto Adicional (app/lib/comisiones/resumen.ts:113-129).
- Todos los modelos de este dominio están auditados con estado antes y después: Asiento, AsientoLinea, TipoAsiento, TipoAsientoCuenta, CuentaContable, Gasto, OperacionGasto, GastoPlanComercial, PagoCancelacionGasto, LiquidacionComision y DetalleLiquidacionComision (app/lib/db.server.ts:23-141).
Dónde vive en el código
app/lib/contabilidad/plantillas.tsapp/lib/contabilidad/generarAsientoDesdeConfig.tsapp/lib/contabilidad/crearAsiento.tsapp/lib/contabilidad/rolesCompartidos.tsapp/lib/contabilidad/asientoAltaOperacion.tsapp/lib/contabilidad/asientos-labels.tsapp/lib/contabilidad/exportarAsientos.tsapp/routes/contabilidad/configuracion/list.tsxapp/routes/contabilidad/configuracion/update.tsxapp/routes/contabilidad/configuracion/validations.tsapp/routes/contabilidad/configuracion/components/TipoAsientoForm.tsxapp/routes/contabilidad/asientos.list.tsxapp/routes/contabilidad/asientos.view.tsxapp/routes/contabilidad/asientos.exportar.tsxapp/routes/contabilidad/asientos.descargar.tsxapp/routes/contabilidad/asientos.confirmar-periodo.tsxapp/routes/cuentas-contables/list.tsxapp/routes/cuentas-contables/create.tsxapp/routes/cuentas-contables/delete.tsxapp/lib/tesoreria/cuenta-contable-delete-guards.tsapp/lib/gastos/resolverGastosPlan.tsapp/lib/gastos/sincronizarGastosOperacion.tsapp/lib/gastos/aplicarGastosAprobacion.tsapp/lib/gastos/asientoAprobacionCancelacion.tsapp/lib/gastos/asientoPagoCancelacion.tsapp/lib/gastos/resolveGastoConfig.tsapp/lib/gastos/resolverDestinoCancelacion.tsapp/lib/gastos/splitMontoIva.tsapp/lib/gastos/gasto-delete-guards.tsapp/lib/gastos/getOrCreateCuentaCorrienteCancelacion.tsapp/routes/gastos/list.tsxapp/routes/gastos/create.tsxapp/routes/gastos/validations.tsapp/routes/gastos/components/GastoForm.tsxapp/lib/tesoreria/marcarPagoMasivoPagado.tsapp/routes/operaciones/aprobar-operacion/actions.tsapp/lib/ventas-cartera/contabilidad/asientoConfirmacion.tsapp/lib/ventas-cartera/contabilidad/asientoCobro.tsapp/lib/ventas-cartera/confirmarVentaCartera.tsapp/lib/ventas-cartera/registrarCobroVentaCartera.tsapp/lib/recompra-cartera/contabilidad/asientoRecompra.tsapp/lib/comisiones/calcular.tsapp/lib/comisiones/liquidar.tsapp/lib/comisiones/resumen.tsapp/lib/comisiones/validarPeriodo.tsapp/lib/comisiones/types.tsapp/routes/comisiones/list.tsxapp/routes/comisiones/create.tsxapp/routes/comisiones/view.tsxapp/routes/comisiones/submit.tsxapp/routes/comisiones/approve.tsxapp/routes/comisiones/reject.tsxapp/routes/comisiones/mark-paid.tsxapp/routes/comisiones/export.tsxapp/routes/comisiones/delete.tsxapp/lib/auth/authorization.tsapp/lib/auth/roles.tsapp/layout/AppSidebar.tsxapp/lib/db.server.tsprisma/schema.prismadocs/plan-configuracion-contable-hibrida.mddocs/plan-asiento-contable.mddocs/comisiones.mdA tener en cuenta
- La cobranza NO genera asientos. El catálogo tiene solo seis origenTipo (operacion_alta, venta_cartera_confirmacion, venta_cartera_cobro, recompra_cartera_confirmacion, gasto_cancelacion_aprobacion y gasto_cancelacion_pago). El cobro de cuotas, la mora y la cancelación anticipada de un préstamo no producen ningún asiento hoy: contablemente el préstamo se registra al alta y después no se devenga ni se cancela.
- La recompra de cartera tiene plantilla en el catálogo pero el código nunca la usa: app/lib/recompra-cartera/contabilidad/asientoRecompra.ts:26 lanza siempre un error ('Matriz contable para recompra de cartera no definida') antes de llegar al crearAsiento, y el llamador lo traga con un warning. En Configuración Contable el tipo aparece configurable y puede quedar en verde 'Configurado' aunque nunca genere nada.
- Los asientos de venta de cartera son tolerantes al fallo por decisión explícita: si la configuración está incompleta, la confirmación y el cobro igual se completan y solo queda un aviso en la URL más un error en el log. Es el opuesto de la aprobación de operaciones, que sí bloquea. Riesgo: una venta puede quedar registrada sin su asiento y nadie se entera salvo que mire el log.
- El documento docs/plan-asiento-contable.md describe un diseño para venta de cartera que NO está implementado (asiento único al cobro, con IVA crédito fiscal, percepciones, sellos y cuentas configurables por EntidadCompradora, compatible con Xubio). El modelo EntidadCompradoraCuentaContable no existe en el schema. Leer ese doc como intención futura, no como el sistema actual.
- El documento docs/comisiones.md está desactualizado en su parte central: describe un modelo ComisionOrganizacion con comisiones multinivel para todos los ancestros, que no existe en el schema. El código real lee todo del snapshot del Convenio y paga UN SOLO nivel ascendente, al padre inmediato.
- Otra desviación del doc de comisiones: dice que el porcentaje efectivo se determina por la fecha de aprobación del crédito, pero el código evalúa la ventana de excepción contra operacion.fechaSolicitud (app/lib/comisiones/calcular.ts:74). La fecha de aprobación solo se usa para filtrar qué operaciones entran al período.
- El permiso comisiones:write no está asignado a ningún rol salvo admin. En la práctica solo un admin puede generar, enviar o eliminar una liquidación; supervisor_area_cobranzas puede aprobar y marcar pagado pero no puede crear la liquidación que después aprueba. El doc afirma que 'manager' tiene los tres permisos, lo cual ya no es cierto.
- La separación de funciones en comisiones es débil: admin puede generar, enviar, aprobar y marcar como pagado sin que intervenga nadie más, y no hay ninguna validación que impida que quien envió sea quien aprueba.
- El estado pendiente_aprobacion permite eliminar la liquidación (DELETABLE_STATES en app/routes/comisiones/delete.tsx:12) pero el mensaje de error que se muestra en el caso contrario habla solo de borrador y rechazado. Inconsistencia entre la regla implementada y lo que se le comunica al usuario.
- Rechazar una liquidación no la devuelve al circuito: no existe transición de rechazado a borrador ni a pendiente_aprobacion. El flujo real es rechazar, eliminar y volver a generar. El diagrama del doc sugiere un retorno que no está implementado.
- Aprobar una liquidación de comisiones debita la cuenta corriente de cada organización pero NO genera ningún asiento contable. El gasto por comisiones no entra al libro de asientos, lo que deja un agujero entre lo que ve Tesorería y lo que ve Contabilidad.
- Confirmar un asiento es irreversible por diseño y no hay ninguna ruta de reversión ni de anulación. Si se confirma un período por error, la única salida es la base de datos. La única protección es la guarda que obliga a acotar el período.
- Las guardas de borrado de una Cuenta Contable (app/lib/tesoreria/cuenta-contable-delete-guards.ts) cuentan conceptos bancarios, cuentas bancarias y movimientos, pero NO cuentan líneas de asiento ni asignaciones en la configuración contable. La FK de TipoAsientoCuenta es Restrict, así que la base frena el borrado, pero el usuario recibe el mensaje genérico 'Error al eliminar la cuenta contable' en vez de una explicación útil.
- Ni el nombre del rol ni su significado contable están documentados en la UI más allá del label de la plantilla: quien configura tiene que saber de contabilidad para elegir bien. La única ayuda contextual existente es la nota de roles compartidos para la Caja de Cancelaciones.
- La secuencia de números de asiento avanza aunque la transacción falle después (nextval no se revierte con rollback), así que pueden aparecer saltos en la numeración. Es aceptable técnicamente pero puede sorprender a Contabilidad si audita correlatividad.
- El resumen ejecutivo de comisiones identifica los gastos adicionales comparando el concepto del movimiento contra dos textos literales, 'Retencion Impositiva' y 'Gasto Adicional'. Cualquier variación de tipeo o de mayúsculas hace que el gasto no se descuente. Es deuda técnica clara.
- Los asientos generados no llevan referencia al centro de costos, sucursal ni organización: solo origenTipo y origenId. Cualquier apertura analítica por sucursal u organización hay que reconstruirla desde el documento de origen.
Venta de cartera
Es el circuito por el cual IRIS vende (o cede en garantía) un paquete de préstamos vivos a una entidad compradora externa, cobrando hoy el valor actual descontado de las cuotas futuras. Arranca con un asistente de 3 pasos (configuración de filtros y tasas, selección de la cartera elegible, resumen) que arma el paquete y calcula capital cedido, interés cedido, valor actual y neto a cobrar. Después sigue el ida y vuelta con la entidad: envío del listado, carga de los rechazos que devuelve, generación y descarga de los legajos PDF por operación, subida del contrato firmado, confirmación (que marca cada préstamo como cedido) y registro del cobro contra una cuenta bancaria. En cualquier momento previo al cobro la cesión se puede anular.
Quién interviene
Qué lo dispara
- Alta manual de una cesión desde /ventas-cartera/alta (no hay cron ni automatismo que la cree).
- Retomar una cesión existente desde el listado o el detalle, entrando al asistente en /ventas-cartera/:id/asistente.
- Recepción del Excel de rechazos que devuelve la entidad compradora, importado en /ventas-cartera/:id/cargar-rechazos.
- Recepción del Excel de caída de cuotas para cesiones de tipo prestamo_garantia, importado en /ventas-cartera/:id/caida-garantia.
- Acción manual 'Generar legajos' desde el detalle, que encola un job por operación aceptada.
- Aviso de acreditación del neto por parte de la entidad, que habilita el registro del cobro.
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Precondición: la entidad compradora tiene que existir y estar activa, con su porcentaje de IVA, percepción, otros gastos, formato de archivo de envío y plantilla de nombre de legajo cargados. Esos parámetros son los que después definen el neto a cobrar y el nombre de los PDF.
/entidades-compradoras/alta·Crea EntidadCompradora y sus vínculos con modalidades de cobro, organismos y cuenta contable.
Precondición opcional: marcar préstamos como no vendibles (por problemas de documentación, temas legales o situación del cliente) para que nunca entren en una cesión.
/creditos-no-vendibles/alta·Crea CreditoNoVendible ligado a la operación; el filtro de elegibilidad la excluye para siempre.
Da de alta la cesión eligiendo entidad compradora, tipo de venta, modalidad, fecha de venta y al menos una tasa (con rango de plazo opcional), más los filtros de armado: días post liquidación, rango de cuotas, cuotas cobradas, días de mora, situación BCRA, estado de préstamo y si admite clientes duplicados.
/ventas-cartera/alta·Crea VentaCartera en estado 'borrador' con numeroCesion autoincremental y N registros VentaCarteraTasa. Redirige directo al paso 2 del asistente.
Paso 1 del asistente: revisa o corrige la configuración (entidad, tipo, modalidad, fecha, tasas y filtros). Si la cesión ya salió de 'borrador', el sistema avisa que tocar filtros o tasas recalcula la cartera.
/ventas-cartera/:id/asistente/configuracion·Actualiza VentaCartera, borra y recrea todas las VentaCarteraTasa. Si el estado no es 'borrador', vuelve a correr la selección para reconciliar la cartera (conserva las exclusiones manuales sobre operaciones que siguen siendo elegibles).
Paso 2 del asistente: ve la lista de operaciones elegibles calculadas al vuelo (capital cedido, interés cedido y valor actual por operación) más los totales y el neto a cobrar. Guarda la selección para persistirla.
/ventas-cartera/:id/asistente/seleccion·Upsert de VentaCarteraDetalle con origen 'filtro' por cada operación elegible, borrado de los detalles de origen 'filtro' que ya no califican, y recálculo de totalCapitalCedido, totalInteresCedido, totalValorActual y netoACobrar sobre la VentaCartera.
Depura la cartera: excluye o reincluye operaciones de a una, o usa el botón de excluir/incluir todas.
/ventas-cartera/:id/asistente/seleccion·Marca VentaCarteraDetalle.excluido y recalcula los totales de la cesión en la misma transacción. Los detalles excluidos no suman al neto.
Agrega operaciones que el filtro no trajo, de a una por número de préstamo o en masa por Excel. Estas altas NO aplican los filtros de la cesión: se valorizan con todas las cuotas pendientes de pago.
/ventas-cartera/:id/agregar-operaciones·Crea VentaCarteraDetalle con origen 'manual' o 'carga_masiva' y recalcula totales. Rechaza operaciones inexistentes, ya cedidas en una venta confirmada o cobrada, marcadas como no vendibles, duplicadas en la cesión o sin capital pendiente.
Confirma la selección para pasar al resumen.
/ventas-cartera/:id/asistente/seleccion·Sincroniza detalles, recalcula totales y, solo si venía de 'borrador', cambia el estado a 'seleccion'. Nunca degrada un estado posterior. Redirige al paso 3.
Paso 3 del asistente: revisa el resumen de la cesión (entidad, tipo, modalidad, fecha, los cuatro totales y el conteo de operaciones aceptadas, excluidas y rechazadas) con el detalle de las aceptadas.
/ventas-cartera/:id/asistente/resumen·Solo lectura. El botón de envío queda deshabilitado si la selección no está confirmada o si no hay ninguna operación aceptada.
Descarga los archivos para mandarle a la entidad: listado, stock excluido, Anexo I, Anexo II y el archivo de envío con el formato de columnas configurado en la entidad compradora.
/ventas-cartera/:id/exportar?tipo=envio-entidad·Genera y descarga un Excel. No modifica nada en base.
Envía la cartera a la entidad compradora.
/ventas-cartera/:id/enviar·Recalcula totales y pasa el estado a 'enviada'. No manda ningún mail ni archivo automáticamente: el envío real es manual, fuera del sistema.
Dispara la generación de los legajos PDF de las operaciones aceptadas. El botón devuelve cuántos se encolaron y el procesamiento sigue en segundo plano.
/ventas-cartera/:id/legajos/generar·Encola un job en la cola 'legajo' con tipo 'venta-cartera' por cada detalle no excluido y sin rechazo. El worker mergea documentos de la operación, del socio y formularios generados en un único PDF, lo sube a S3 bajo ventas-cartera/legajos/{entidad}/{venta}/{operacion}/ y hace upsert de LegajoVentaCartera.
Consulta el avance de la generación y descarga los legajos, de a uno o todos juntos en un ZIP.
/ventas-cartera/:id/legajos/estado y /ventas-cartera/:id/legajos/descargar·El endpoint de estado devuelve JSON con generado/pendiente por operación. La descarga individual redirige a una URL prefirmada de S3 válida 5 minutos; la masiva arma un ZIP en el momento.
Carga el Excel de rechazos que devolvió la entidad (columnas numero_operacion y motivo_rechazo).
/ventas-cartera/:id/cargar-rechazos·Por cada fila válida crea un VentaCarteraRechazo (pisando el rechazo previo de esa operación), marca el VentaCarteraDetalle como excluido con el motivo del rechazo y recalcula los totales. Devuelve un resumen de filas leídas, aplicadas, repetidas y con error.
Solo para cesiones de tipo prestamo_garantia: importa el Excel de caída con el cronograma de cuotas (número, vencimiento, capital, interés, IVA, percepción y total).
/ventas-cartera/:id/caida-garantia·Sube el archivo a S3, borra por completo la caída anterior y crea CaidaPrestamoGarantia con sus CaidaPrestamoGarantiaCuota en estado 'pendiente'. Si algo falla, borra el archivo recién subido.
Sube el contrato de cesión firmado (PDF, DOC o DOCX).
/ventas-cartera/:id/subir-contrato·Sube el archivo a S3 y guarda contratoS3Key y contratoFilename en la VentaCartera. Solo se admite en estados 'enviada' o 'confirmada'.
Confirma la cesión.
/ventas-cartera/:id/confirmar·Marca cada operación aceptada con estadoCesion 'vendida' (o 'cedida_en_garantia' si el tipo es prestamo_garantia), le setea ventaCarteraId y fechaCesion, y pasa la cesión a 'confirmada' guardando quién confirmó. Intenta generar el asiento contable de confirmación; si falla, igual confirma y avisa en pantalla.
Registra el cobro: ingresa el importe neto efectivamente cobrado, la diferencia contra el neto esperado (sugerida automáticamente pero editable) y elige la cuenta bancaria receptora.
/ventas-cartera/:id/cobrar·Pasa la cesión a 'cobrada' guardando importe, diferencia, banco, fecha y usuario; crea un Movimiento de tesorería tipo 'credito' con conciliación pendiente; incrementa el saldo de la cuenta bancaria; intenta el asiento contable de cobro y avisa si falla.
Si la entidad devuelve la cartera para retrabajarla, la vuelve al paso de selección.
/ventas-cartera/:id/volver-a-seleccion·Devuelve el estado de 'enviada' a 'seleccion'. Solo funciona desde 'enviada'.
Anula la cesión.
/ventas-cartera/:id/anular·Pasa el estado a 'anulada'. Si venía de 'confirmada', además desvincula todas las operaciones (limpia estadoCesion, ventaCarteraId y fechaCesion) y deja un AuditLog con operación 'anulacion_confirmada'. Si ya estaba 'cobrada' o 'anulada' no hace nada.
Estados
- borrador
- La cesión existe con su configuración y tasas, pero la cartera todavía no se persistió. Es totalmente editable y aún no ocupa las operaciones.
- seleccion
- La selección ya está confirmada y persistida en VentaCarteraDetalle, con totales y neto a cobrar calculados. Lista para enviarse a la entidad; sigue siendo editable.
- enviada
- La cartera fue puesta a consideración de la entidad compradora. Sigue admitiendo cambios (rechazos, exclusiones, altas, cambios de configuración) porque la entidad puede aceptar o rechazar operaciones. Acá se suben el contrato y la caída de garantía.
- confirmada
- Punto de no retorno: las operaciones quedaron marcadas como cedidas y la cesión ya no se puede editar. Solo resta cobrarla o anularla.
- cobradafinal
- El neto se acreditó, hay movimiento de tesorería y el saldo bancario quedó actualizado. Estado final: no admite ninguna transición, ni siquiera la anulación.
- anuladafinal
- La cesión quedó sin efecto. Si estaba confirmada, las operaciones volvieron a quedar libres. Estado final.
Reglas que el sistema hace cumplir
- Los seis estados posibles de una cesión son 'borrador', 'seleccion', 'enviada', 'confirmada', 'cobrada' y 'anulada' (app/lib/ventas-cartera/estados.ts:3; el default en base es 'borrador', prisma/schema.prisma:1828).
- La matriz de transiciones válidas es: borrador a seleccion o anulada; seleccion a enviada o anulada; enviada a seleccion, confirmada o anulada; confirmada a cobrada o anulada; cobrada y anulada no transicionan a nada (app/lib/ventas-cartera/estados.ts:51-59).
- La cartera es editable en 'borrador', 'seleccion' y 'enviada'. El punto de no retorno es 'confirmada': 'enviada' sigue admitiendo cambios porque la entidad puede aceptar o rechazar operaciones (app/lib/ventas-cartera/estados.ts:17).
- Una operación es elegible solo si su estado es 'liquidado' o 'pagado' (según el filtro estadoPrestamo de la cesión), no está marcada como crédito no vendible y no figura como detalle no excluido en otra cesión ya 'confirmada' o 'cobrada' (app/lib/ventas-cartera/filtrarOperaciones.ts:6, 28 y 33). Consecuencia: la misma operación puede aparecer simultáneamente en varias cesiones en borrador, selección o enviada.
- Se descartan las operaciones sin socio o con capital cedido menor o igual a cero (app/lib/ventas-cartera/buildOperacionesElegiblesSeleccion.ts:135).
- Si la cesión no admite clientes duplicados, de cada socio se conserva únicamente la operación con mayor capital cedido (app/lib/ventas-cartera/buildOperacionesElegiblesSeleccion.ts:138-139 y app/lib/ventas-cartera/filtrarOperaciones.ts:46-55).
- Los filtros de cuotas cobradas y días de mora se aplican sobre la operación completa, y el rango desdeCuota/hastaCuota recorta qué cuotas se ceden; la fecha de corte para considerar una cuota pagada es la fecha en que la operación entró a la cesión (app/lib/ventas-cartera/filtrarOperaciones.ts:63-91).
- El valor actual se calcula con descuento compuesto en períodos de 30 días sobre la cuota financiera (capital más interés, sin IVA, seguro ni gastos), corriendo el vencimiento al siguiente día hábil y, si calculaVaFinMes está activo, al último día del mes: VA = cuota / (1 + TNA*30/36500)^(días/30) (app/lib/cartera/calcularVA.ts:26-35).
- La tasa aplicable se resuelve por el plazo de la operación contra los rangos desdePlazo/hastaPlazo cargados; si no matchea ninguno, usa la primera tasa de la lista (app/lib/cartera/calcularVA.ts:6-18).
- El neto a cobrar sale de: valor actual menos IVA sobre el spread, menos percepción de IVA sobre el spread, menos otros gastos, donde spread = capital cedido + interés cedido - valor actual. Los tres porcentajes salen de la entidad compradora (app/lib/cartera/calcularNetoOperacionCartera.ts:12-18).
- Los totales de la cesión solo suman los detalles NO excluidos (app/lib/ventas-cartera/recalcularTotalesVentaCartera.ts:21-24).
- Guardar la selección nunca degrada un estado posterior: solo avanza si la cesión estaba exactamente en 'borrador' (app/lib/ventas-cartera/seleccionVentaCartera.ts:176-180).
- Editar la configuración cuando la cesión ya no está en 'borrador' fuerza una reconciliación automática de la cartera; conserva las exclusiones manuales sobre operaciones que sigan siendo elegibles (app/routes/ventas-cartera/asistente/configuracion.tsx:130-145).
- Toda cesión requiere al menos una tasa cargada y, si se informan ambos, desdeCuota no puede ser mayor que hastaCuota (app/lib/ventas-cartera/createVentaCartera.ts:34 y app/lib/ventas-cartera/parseVentaCarteraForm.ts:76).
- No se puede enviar a la entidad si no hay al menos una operación no excluida (app/routes/ventas-cartera/enviar.tsx:69).
- Las operaciones agregadas manualmente o por Excel se valorizan con TODAS sus cuotas pendientes, sin aplicar los filtros de la cesión (app/lib/ventas-cartera/calcularDetalleManual.ts:22-45), y quedan marcadas con origen 'manual' o 'carga_masiva'.
- Cada rechazo cargado crea un VentaCarteraRechazo y además excluye el detalle correspondiente con el motivo del rechazo, disparando el recálculo de totales (app/lib/ventas-cartera/aplicarRechazosDesdeExcel.ts:100-120).
- Los rechazos solo se pueden cargar en 'borrador', 'seleccion' o 'enviada' (app/lib/ventas-cartera/estados.ts:12).
- Los legajos solo se generan en 'seleccion', 'enviada', 'confirmada' o 'cobrada' (app/lib/ventas-cartera/estados.ts:5-10) y únicamente para detalles no excluidos y sin rechazo en esa misma cesión (app/routes/ventas-cartera/legajos/generar.tsx:65-77).
- El nombre de cada legajo sale de la plantilla configurada en la entidad compradora con los tokens {codigo}, {fechaVenta}, {nroOperacion}, {apellidoNombre} y {dni}; si no hay plantilla usa '{nroOperacion}_{apellidoNombre}.pdf' (app/lib/ventas-cartera/legajos/resolverNombreLegajo.ts:13 y 31-45).
- El contrato solo se puede subir en estados 'enviada' o 'confirmada' y únicamente en formato PDF, DOC o DOCX (app/routes/ventas-cartera/subir-contrato.tsx:25 y 31).
- Para confirmar hacen falta las cuatro condiciones: estado 'enviada', contrato cargado, caída de garantía cargada si el tipo es prestamo_garantia, y al menos una operación aceptada (app/lib/ventas-cartera/confirmarVentaCartera.ts:37-61).
- Al confirmar, cada operación aceptada queda con estadoCesion 'cedida_en_garantia' si el tipo es prestamo_garantia, o 'vendida' en cualquier otro caso, más su ventaCarteraId y la fecha de cesión (app/lib/ventas-cartera/confirmarVentaCartera.ts:63-75).
- La caída de garantía solo se admite para cesiones de tipo 'prestamo_garantia', y cada nueva carga reemplaza por completo la caída anterior (app/lib/ventas-cartera/cargarCaidaGarantiaDesdeExcel.ts:40 y 76-82).
- El cobro exige estado 'confirmada', contrato cargado, importe mayor a cero y una cuenta bancaria válida; el cambio de estado se hace con un update condicional que falla si otro proceso ya movió la cesión (app/lib/ventas-cartera/registrarCobroVentaCartera.ts:39-52 y app/lib/ventas-cartera/prepararCobroVentaCartera.ts:47-56).
- El cobro crea un Movimiento de tesorería tipo 'credito' con estado de conciliación 'pendiente' e incrementa el saldo de la cuenta bancaria destino (app/lib/ventas-cartera/registrarCobroVentaCartera.ts:55-67 y 100-105).
- La anulación es no-operativa si la cesión ya está 'cobrada' o 'anulada'; y si venía de 'confirmada' desvincula las operaciones y escribe un AuditLog con operación 'anulacion_confirmada' (app/lib/ventas-cartera/anularVentaCartera.ts:26-58).
- Los asientos contables de confirmación y de cobro NO son bloqueantes: si fallan, la operación de negocio igual se completa y solo se muestra un aviso en el detalle (app/lib/ventas-cartera/confirmarVentaCartera.ts:85-99 y app/lib/ventas-cartera/registrarCobroVentaCartera.ts:83-98).
- VentaCartera, VentaCarteraDetalle, VentaCarteraTasa, VentaCarteraRechazo, LegajoVentaCartera, EntidadCompradora y CreditoNoVendible están bajo auditoría automática (app/lib/db.server.ts:93-109).
Dónde vive en el código
/Users/martin.long/Documents/work/rebl/iris/app/routes.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/estados.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/seleccionVentaCartera.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/buildOperacionesElegiblesSeleccion.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/filtrarOperaciones.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/recalcularTotalesVentaCartera.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cartera/calcularVA.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cartera/calcularNetoOperacionCartera.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/confirmarVentaCartera.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/registrarCobroVentaCartera.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/prepararCobroVentaCartera.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/anularVentaCartera.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/aplicarRechazosDesdeExcel.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/agregarOperacionesVentaCartera.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/calcularDetalleManual.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/cargarCaidaGarantiaDesdeExcel.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/legajos/generarLegajoVentaCarteraOperacion.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/legajos/resolverNombreLegajo.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/legajos/descargarLegajosVentaCartera.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ventas-cartera/exportar.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/asistente.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/asistente/configuracion.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/asistente/seleccion.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/asistente/resumen.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/components/VentaCarteraStepper.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/enviar.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/volver-a-seleccion.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/cargar-rechazos.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/caida-garantia.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/agregar-operaciones.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/subir-contrato.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/confirmar.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/cobrar.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/anular.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/view.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/list.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/legajos/generar.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/legajos/estado.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/exportar.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/entidades-compradoras/validations.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/creditos-no-vendibles/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/lib/creditos-no-vendibles/motivos.ts/Users/martin.long/Documents/work/rebl/iris/app/jobs/queue.ts/Users/martin.long/Documents/work/rebl/iris/app/jobs/worker.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/auth/authorization.ts/Users/martin.long/Documents/work/rebl/iris/prisma/schema.prismaA tener en cuenta
- En la práctica todo el flujo operativo lo puede ejecutar únicamente el rol 'admin': ventas_cartera:write, :confirmar, :cobrar, :delete, legajos:generate y caida_garantia:write no están asignados a ningún otro rol (app/lib/auth/authorization.ts:247 le da a admin todos los permisos; el resto solo tiene ventas_cartera:read). No hay separación real de funciones entre quien arma, quien confirma y quien cobra.
- La matriz contable no está configurada: resolverCuentasConfirmacion siempre lanza excepción (app/lib/ventas-cartera/contabilidad/matrizCuentas.ts:7-12). Por eso confirmación y cobro atrapan el error, siguen adelante y solo muestran un aviso. Hoy las cesiones se confirman y cobran sin asiento contable.
- El helper puedeTransicionarVentaCartera define la máquina de estados formal pero no lo usa ningún código de producción: solo lo consumen los tests. Cada action valida el estado a mano, así que la matriz declarada y el comportamiento real pueden divergir sin que nada lo detecte.
- La caída de garantía no valida el estado de la cesión: solo chequea que el tipo sea 'prestamo_garantia'. Se puede reemplazar la caída de una cesión ya 'cobrada' o 'anulada' (app/lib/ventas-cartera/cargarCaidaGarantiaDesdeExcel.ts:37-41).
- imputarCobroContraCaida está implementado y tiene tests, pero no lo llama ningún flujo: el cobro de una cesión en garantía no imputa contra el cronograma de caída cargado. Es funcionalidad huérfana.
- La anulación de una cesión 'confirmada' desvincula las operaciones pero no revierte nada más: no borra legajos ni contrato. Y desde 'cobrada' directamente no se puede anular, con lo cual un cobro mal registrado (con su movimiento de tesorería y el saldo bancario ya impactado) no tiene marcha atrás por la aplicación.
- La diferencia de cobro es un campo libre: el sistema sugiere el cálculo (cobrado menos neto esperado) pero acepta cualquier valor que el usuario escriba, sin tope ni validación (app/lib/ventas-cartera/prepararCobroVentaCartera.ts:60-68).
- El action de enviar carga un query pesado con todos los detalles, sus cuotas y el formato de archivo de la entidad, pero no lo usa: solo recalcula totales y cambia el estado. El archivo de envío se descarga aparte desde /exportar?tipo=envio-entidad. Es trabajo muerto y sugiere que el envío automático quedó a medio hacer.
- No hay ningún envío automático a la entidad: no se manda mail ni se sube nada a un servicio de la contraparte. Todo el intercambio (archivo de envío, rechazos, contrato) es manual por fuera del sistema.
- Los rechazos se pueden cargar en estado 'borrador', antes de haber enviado nada a la entidad (app/lib/ventas-cartera/estados.ts:12). Conceptualmente un rechazo solo debería existir después del envío.
- app/lib/ventas-cartera/calcularVA.ts y app/lib/ventas-cartera/calcularNetoACobrar.ts son copias de los módulos de app/lib/cartera/ y no las importa ningún código de producción, solo los tests. Riesgo concreto de tocar la copia equivocada al ajustar una fórmula.
- Cuando falla una guarda de confirmación (falta contrato, falta caída, no hay aceptadas), el sistema no muestra un error: redirige silenciosamente a otra pantalla. Para el usuario parece que el botón 'no hizo nada'.
- Una misma operación puede estar simultáneamente en varias cesiones en 'borrador', 'seleccion' o 'enviada': el bloqueo recién aplica cuando alguna llega a 'confirmada' o 'cobrada'. Con dos cesiones abiertas en paralelo se puede ofrecer el mismo préstamo a dos entidades.
- Las rutas /ventas-cartera/:id/modificar y /ventas-cartera/:id/seleccion ya no tienen lógica propia: son redirects de compatibilidad hacia el asistente. No hay baja física de una cesión; la única baja es lógica vía 'anulada'.
- El paso 'Resumen' del asistente solo es accesible mientras la cesión está en 'borrador' o 'seleccion'; una vez enviada, toda la operatoria (contrato, rechazos, legajos, confirmar, cobrar) se hace desde la pantalla de detalle /ver, no desde el asistente.
Compra y recompra de cartera
Este flujo cubre las dos puntas "inversas" del negocio de cesiones. COMPRAR CARTERA es adquirir préstamos originados por otra financiera (Entidad Vendedora): se recibe un Excel con los créditos ofrecidos, se valúa a valor actual, se paga un neto a esa entidad y los créditos se migran al sistema como operaciones propias con titulares externos. VENDER CARTERA (módulo aparte, /ventas-cartera) es lo opuesto: ceder préstamos propios a una Entidad Compradora a cambio de cobrar un neto, marcando las operaciones con estadoCesion 'vendida' o 'cedida_en_garantia'. RECOMPRAR CARTERA es deshacer una venta previa: se le paga a la Entidad Compradora para que las operaciones cedidas vuelvan al stock propio, limpiando la marca de cesión y reincorporando las cuotas que todavía no estaban cobradas a la fecha de recompra.
Quién interviene
Qué lo dispara
- Alta manual de una compra de cartera por parte de un usuario habilitado en /compra-cartera/alta (típicamente al cerrar el acuerdo con una financiera vendedora).
- Recepción del archivo Excel (.xlsx) de cartera ofrecida enviado por la Entidad Vendedora, que se sube en la pantalla de selección.
- Recepción de los legajos PDF de la Entidad Vendedora (upload masivo, matcheo por DNI en el nombre del archivo).
- Alta manual de una recompra en /recompra-cartera/alta más el Excel enviado por la Entidad Compradora con las operaciones que devuelve.
- Marcado manual o carga masiva de Créditos No Vendibles, que bloquea que esas operaciones entren en futuras ventas de cartera.
- No hay ningún disparador automático: no existen crons, jobs ni webhooks para este dominio.
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Da de alta la Entidad Vendedora con sus datos fiscales (% IVA, % percepción IVA, otros gastos), el 'diseño de registros' JSON que describe cómo viene el Excel de esa financiera y el formato de nombre de legajo (ej. {dni}.pdf).
/entidades-vendedoras/alta·Crea EntidadVendedora. Esos porcentajes son los que después definen el neto a pagar de todas sus compras.
Da de alta los Motivos de Recompra (nombre, descripción, activo). Sin al menos un motivo activo no se puede importar ninguna recompra.
/motivos-recompra/alta·Crea MotivoRecompra. El Excel de recompra referencia estos motivos por nombre exacto (case-insensitive).
Marca una operación propia como Crédito No Vendible eligiendo motivo (problemas_documentacion, legales o situacion_cliente). También existe carga masiva por Excel con columnas numero_operacion y motivo.
/creditos-no-vendibles/alta y /creditos-no-vendibles/carga-masiva·Crea CreditoNoVendible (uno por operación, operacionId es único). Esa operación queda excluida de la selección y del agregado manual en VENTAS de cartera. No afecta compra ni recompra.
Da de alta la compra de cartera: elige entidad vendedora, plan comercial, modalidad (sin_recurso, con_recurso o prestamo_garantia), fecha de compra, parámetros de filtro (rango de cuotas, situación BCRA, días de mora, estado del préstamo) y la grilla de tasas de descuento por plazo.
/compra-cartera/alta·Crea CompraCartera en estado 'borrador' con numeroCesion autoincremental, más N filas de CompraCarteraTasa. Redirige directo a la pantalla de selección.
Sube el Excel de cartera ofrecida que mandó la financiera vendedora. El parser usa el diseño de registros de la entidad o, si no hay, encabezados canónicos con alias (dni, capital, interés, cuotas, vencimientos).
/compra-cartera/:id/seleccion (intent upload_cartera)·Borra los detalles previos de origen 'filtro' sin operación asociada y crea un CompraCarteraDetalle por crédito, calculando capital cedido, interés cedido y VALOR ACTUAL descontado a la tasa que corresponde al plazo. Recalcula totales y neto a pagar de la cabecera.
Opcionalmente agrega más créditos con una carga masiva adicional, que suma en vez de reemplazar.
/compra-cartera/:id/agregar-operaciones·Crea detalles con origen 'carga_masiva' y recalcula totales. Solo habilitado en estados 'borrador' y 'analizada'.
Revisa la grilla y excluye o reincluye créditos uno por uno (por ejemplo los que no quiere comprar).
/compra-cartera/:id/seleccion (intent toggle_exclusion)·Marca excluido=true con motivoExclusion 'Excluido manualmente' y recalcula totales. Los excluidos no suman al neto a pagar ni se migran.
Exporta a Excel el detalle o los anexos I y II de la cesión para revisión o para adjuntar al contrato.
/compra-cartera/:id/exportar?tipo=detalle|anexo-i|anexo-ii·Genera un .xlsx con exceljs. No modifica nada.
Confirma la selección de cartera.
/compra-cartera/:id/seleccion (intent confirm_selection)·Valida que haya plan comercial y al menos un crédito no excluido; recalcula totales y pasa la compra a estado 'analizada' guardando confirmadoPorId. A partir de acá ya no se puede tocar la selección.
Migra la cartera al sistema. Es el paso pesado: convierte cada crédito comprado en una operación propia.
/compra-cartera/:id/migrar·Por cada detalle no excluido con DNI válido crea o actualiza un TitularExterno (clave: DNI único global), una CuentaCorriente, una Operacion con origen 'comprada' y estado 'aprobada' (numeroDeOperacion del contador general), y todas las CuotaCredito pendientes en estado 'pendiente'. Vincula detalle -> operación. Si creó al menos una operación, la compra pasa a 'migrada'.
Sube el contrato de cesión firmado en PDF.
/compra-cartera/:id/subir-contrato·Guarda el archivo en S3 bajo compra-cartera/contratos, persiste contratoS3Key y contratoFilename y pasa la compra a estado 'contrato_cargado'. Si falla el update en base, borra el objeto de S3.
Sube en lote los legajos PDF de los clientes comprados. El sistema extrae el DNI del nombre del archivo usando el patrón configurado en la entidad vendedora.
/compra-cartera/:id/legajos/upload·Por cada PDF que matchea un DNI con operación ya migrada de esa compra, sube a S3 y crea un DocumentoOperacion con documentType 'legajo_compra_cartera'. Devuelve un reporte de matched / unmatched / error. No cambia el estado de la compra.
Registra el pago a la entidad vendedora: elige banco, método (transferencia, cheque, efectivo o reintegro_comercializador), CBU destino si es transferencia, y confirma el importe neto pagado (puede diferir del calculado).
/compra-cartera/:id/pagar·En una transacción: pasa la compra a 'pagada' guardando importeNetoPagado, diferencia, banco, fechaPago y pagadoPorId; crea un Pago tipo COMPRA_CARTERA; crea un Movimiento de débito con estadoConciliacion 'pendiente'; y decrementa el saldo de la cuenta bancaria. Estado final.
Anula la compra si algo salió mal y todavía no se pagó.
/compra-cartera/:id/anular·Pasa la compra a 'anulada', cancela todas las operaciones ya migradas (estado 'cancelado') y deja un AuditLog con specialEvent 'compra_cartera_anulada'. No se puede anular una compra 'pagada'.
Da de alta la recompra: elige la entidad compradora (a la que en su momento se le vendió) y la fecha de recompra.
/recompra-cartera/alta·Crea RecompraCartera en estado 'borrador' con numeroRecompra autoincremental. Redirige a la pantalla de importación.
Importa el Excel de la entidad compradora, que debe traer las columnas id_operacion (o numero_operacion), importe_recompra y motivo_de_recompra.
/recompra-cartera/:id/importar·Borra el detalle previo y crea un RecompraCarteraDetalle por fila válida, guardando snapshot de trazabilidad (venta de origen, número de cesión, banco de origen, estadoCesion original). Auto-excluye las operaciones que no están cedidas o que ya están en otra recompra activa. Deja la recompra en estado 'validada' con el importeTotalRecompra (solo suma las no excluidas).
Revisa en el detalle qué operaciones fueron aceptadas y cuáles quedaron excluidas con su motivo.
/recompra-cartera/:id/ver·Solo lectura.
Confirma la recompra. Es el momento en el que las operaciones vuelven a ser cartera propia.
/recompra-cartera/:id/confirmar·Por cada detalle aceptado cuenta las cuotas NO pagadas a la fecha de recompra y guarda cuotasReincorporadas y montoCuotasReincorporadas; limpia estadoCesion, ventaCarteraId y fechaCesion de la operación y setea recompraCarteraId y fechaRecompra. Pasa la recompra a 'confirmada' con el monto total reincorporado y deja AuditLog. Intenta un asiento contable que hoy siempre falla y se loguea como warning.
Registra el pago a la entidad compradora: elige cuenta bancaria, método (transferencia, cheque o efectivo) y CBU si corresponde. El importe se calcula solo: total del archivo más el cargo extra configurado en la entidad (porcentaje o monto fijo).
/recompra-cartera/:id/pagar·Crea un Pago tipo RECOMPRA_CARTERA, pasa la recompra a 'pagada' guardando importeExtraEntidad, importeAPagar, importeNetoPagado, diferencia contra lo reincorporado, banco, método y fechaPago; crea un Movimiento de débito pendiente de conciliación y decrementa el saldo de la cuenta bancaria. Estado final.
Exporta el listado de la recompra, el listado de excluidos o el anexo I.
/recompra-cartera/:id/exportar?tipo=listado|excluidos|anexo-i·Genera .xlsx. En pantalla el botón solo aparece con la recompra 'confirmada' o 'pagada'.
Anula la recompra mientras no esté pagada.
/recompra-cartera/:id/anular·Pasa a 'anulada'. Si estaba 'confirmada', revierte cada operación a su estado de cesión original (estadoCesion, ventaCarteraId y fechaCesion tomada de la venta de origen) y limpia recompraCarteraId y fechaRecompra, dejando AuditLog. Desde 'borrador' o 'validada' no hay nada que revertir.
Estados
- borrador
- COMPRA — Recién dada de alta. Están cargados los parámetros y las tasas pero todavía no se confirmó qué créditos se compran. Se puede subir/reemplazar el Excel, excluir créditos y cambiar el plan comercial.
- analizada
- COMPRA — Selección confirmada. Los totales (capital cedido, interés cedido, valor actual y neto a pagar) quedaron fijados y hay al menos un crédito incluido y un plan comercial. Único estado desde el que se puede migrar.
- migrada
- COMPRA — Los créditos comprados ya existen en IRIS como operaciones propias (origen 'comprada', estado 'aprobada') con sus titulares externos y su plan de cuotas. Falta el contrato.
- contrato_cargado
- COMPRA — El contrato de cesión firmado está guardado en S3. Es el requisito para poder pagar.
- pagadafinal
- COMPRA — Se le pagó a la entidad vendedora. Hay Pago, Movimiento de débito pendiente de conciliar y saldo bancario descontado. Estado terminal: no se puede anular.
- anuladafinal
- COMPRA — Compra dada de baja antes del pago. Las operaciones que se hubieran migrado quedan en estado 'cancelado'. Estado terminal.
- borrador
- RECOMPRA — Recién creada con entidad compradora y fecha. Todavía no se importó el archivo con las operaciones a recomprar.
- validada
- RECOMPRA — Archivo importado y cruzado contra las operaciones cedidas. Ya se sabe qué operaciones entran, cuáles quedaron excluidas y cuál es el importe total pedido por la entidad.
- confirmada
- RECOMPRA — Las operaciones volvieron al stock propio: se les borró la marca de cesión y se contabilizaron las cuotas pendientes reincorporadas. Falta pagarle a la entidad.
- pagadafinal
- RECOMPRA — Se le pagó a la entidad compradora el importe del archivo más su cargo extra. Estado terminal.
- anuladafinal
- RECOMPRA — Recompra dada de baja antes del pago. Si estaba confirmada, las operaciones vuelven a quedar marcadas como cedidas a su venta de origen. Estado terminal.
Reglas que el sistema hace cumplir
- Los estados válidos de una compra y sus transiciones son borrador -> analizada -> migrada -> contrato_cargado -> pagada, con salida a anulada desde cualquiera salvo pagada (app/lib/compra-cartera/estados.ts:49-55).
- La selección de cartera (subir Excel, excluir créditos, recalcular totales) solo se admite en estados 'borrador' y 'analizada' (app/lib/compra-cartera/seleccionCompraCartera.ts:60).
- Para confirmar la selección la compra debe tener plan comercial y al menos un crédito no excluido; recién ahí pasa a 'analizada' y se graba confirmadoPorId (app/lib/compra-cartera/seleccionCompraCartera.ts:297, :298, :309).
- El archivo de cartera ofrecida debe ser .xlsx; el parser usa el 'diseño de registros' JSON de la Entidad Vendedora y, si no está, alias canónicos de encabezado (app/lib/compra-cartera/seleccionCompraCartera.ts:195, :198).
- El neto a pagar sale de la fórmula valorActual - IVA*(capital+interés-valorActual) - PercepciónIVA*(capital+interés-valorActual) - otrosGastos, usando los porcentajes de la Entidad Vendedora (app/lib/cartera/calcularNetoOperacionCartera.ts:12-18).
- Los totales solo suman los detalles con excluido=false (app/lib/compra-cartera/recalcularTotalesCompraCartera.ts:50-51).
- Solo se puede migrar una compra en estado 'analizada' y con plan comercial asignado (app/lib/compra-cartera/migrarCompraCartera.ts:71, :73).
- Los detalles cuyo DNI no tenga al menos 7 dígitos se saltean silenciosamente en la migración: no se crean ni se reportan (app/lib/compra-cartera/migrarCompraCartera.ts:87).
- Las operaciones compradas nacen en estado 'aprobada' con origen 'comprada', sin socio y colgadas de un TitularExterno; consumen el mismo contador numeroDeOperacion que las operaciones originadas (app/lib/compra-cartera/migrarCompraCartera.ts:124-137).
- La compra pasa a 'migrada' solo si se creó al menos una operación nueva; si todas ya estaban migradas queda en 'analizada' (app/lib/compra-cartera/migrarCompraCartera.ts:197-202).
- El plan comercial de la compra solo puede modificarse mientras esté en 'borrador' o 'analizada' (app/routes/compra-cartera/view.tsx:182-183).
- El contrato solo se puede subir con la compra en 'migrada' o 'contrato_cargado', debe ser PDF, y al guardarlo deja la compra en 'contrato_cargado' (app/routes/compra-cartera/subir-contrato.tsx:21, :47, :66).
- No se puede registrar el pago sin contrato cargado (app/lib/compra-cartera/prepararPagoCompraCartera.ts:53).
- Los métodos de pago admitidos en compra son transferencia, cheque, efectivo y reintegro_comercializador; si es transferencia el CBU debe tener exactamente 22 dígitos (app/lib/compra-cartera/prepararPagoCompraCartera.ts:62, :65-70).
- El pago de compra se aplica con un updateMany condicionado a estado='contrato_cargado': si otro usuario ya pagó, tira CompraCarteraEstadoInvalidoError en vez de duplicar el egreso (app/lib/compra-cartera/registrarPagoCompraCartera.ts:47-61).
- Pagar una compra crea un Pago tipo COMPRA_CARTERA, un Movimiento de débito con estadoConciliacion 'pendiente' y decrementa el saldo de la cuenta bancaria, todo en la misma transacción (app/lib/compra-cartera/registrarPagoCompraCartera.ts:63-98).
- Una compra 'pagada' no se puede anular; anular una compra migrada cancela todas sus operaciones (estado 'cancelado') y deja AuditLog con specialEvent 'compra_cartera_anulada' (app/lib/compra-cartera/anularCompraCartera.ts:37-39, :50-53, :56-69).
- Los legajos PDF se matchean por DNI extraído del nombre del archivo según el patrón formatoNombreLegajo de la Entidad Vendedora, y solo se aceptan si existe una operación ya migrada de esa compra para ese DNI (app/routes/compra-cartera/legajos-upload.tsx:54, :67, :76 y app/lib/compra-cartera/migrarCompraCartera.ts:210-215).
- Los estados válidos de una recompra son borrador -> validada -> confirmada -> pagada, con salida a anulada desde cualquiera salvo pagada (app/lib/recompra-cartera/estados.ts:13-19).
- El Excel de recompra debe traer id_operacion (o numero_operacion), importe_recompra y motivo_de_recompra; el motivo se resuelve por nombre exacto contra los MotivoRecompra activos (app/lib/recompra-cartera/parseExcelRecompra.ts:20-33 y app/lib/recompra-cartera/importarDetallesRecompra.ts:109-117).
- Solo se puede importar con la recompra en 'borrador', y cada importación reemplaza todo el detalle anterior (app/lib/recompra-cartera/importarDetallesRecompra.ts:32, :35).
- Si no hay ningún motivo de recompra activo, la importación se rechaza entera (app/lib/recompra-cartera/importarDetallesRecompra.ts:73).
- Se auto-excluye la operación que no esté cedida (estadoCesion null) o que ya pertenezca a otra recompra activa; las excluidas no suman al importe total (app/lib/recompra-cartera/importarDetallesRecompra.ts:127-133, :150-155).
- Al importar, la recompra pasa automáticamente a 'validada' y se persiste el importeTotalRecompra con la suma de las aceptadas (app/lib/recompra-cartera/importarDetallesRecompra.ts:158-164).
- Confirmar una recompra solo es posible desde 'validada' y con al menos un detalle no excluido (app/lib/recompra-cartera/confirmarRecompra.ts:29, :55).
- La reincorporación cuenta únicamente las cuotas que NO estaban pagadas a la fechaRecompra: las ya cobradas por la entidad quedan como estaban (app/lib/recompra-cartera/confirmarRecompra.ts:61-63 y app/lib/ventas-cartera/cuotas.ts:17-23).
- Confirmar limpia estadoCesion, ventaCarteraId y fechaCesion de la operación y setea recompraCarteraId y fechaRecompra: ahí la operación vuelve a ser cartera propia (app/lib/recompra-cartera/confirmarRecompra.ts:76-85).
- El importe a pagar de la recompra es el total del archivo más el extra de la Entidad Compradora, que puede ser 'porcentaje' o 'monto' fijo; la diferencia contra lo reincorporado se persiste pero no bloquea (app/lib/recompra-cartera/prepararPagoRecompra.ts:34-46, :82-84).
- Los métodos de pago admitidos en recompra son transferencia, cheque y efectivo (sin reintegro_comercializador), con CBU de 22 dígitos si es transferencia (app/lib/recompra-cartera/prepararPagoRecompra.ts:89-97).
- El pago de recompra se aplica con updateMany condicionado a estado='confirmada' y crea Pago tipo RECOMPRA_CARTERA más Movimiento de débito pendiente de conciliación (app/lib/recompra-cartera/registrarPagoRecompra.ts:58-73, :75-97).
- Anular una recompra 'confirmada' restaura estadoCesion, ventaCarteraId y fechaCesion (tomada de la fechaVenta de la venta de origen) desde el snapshot guardado en el detalle (app/lib/recompra-cartera/anularRecompra.ts:35-66).
- Los motivos de Crédito No Vendible son exactamente problemas_documentacion, legales y situacion_cliente; el parser acepta también las etiquetas en castellano con y sin acentos (app/lib/creditos-no-vendibles/motivos.ts:1-30).
- Una operación solo puede marcarse una vez como no vendible: operacionId es único y el alta valida el duplicado (prisma/schema.prisma:1991 y app/routes/creditos-no-vendibles/create.tsx:59-66).
- Los créditos no vendibles se excluyen del filtro automático y del agregado manual de VENTAS de cartera, no de compras ni recompras (app/lib/ventas-cartera/filtrarOperaciones.ts:33 y app/lib/ventas-cartera/agregarOperacionesVentaCartera.ts:37).
- La marca de cesión que la recompra revierte se pone al confirmar una VENTA: 'cedida_en_garantia' si el tipo de venta es prestamo_garantia, si no 'vendida' (app/lib/ventas-cartera/confirmarVentaCartera.ts:63-69 y prisma/schema.prisma:2856).
- Los permisos del dominio son compra_cartera:read/write/pagar/delete y recompra_cartera:read/write/confirmar/pagar/delete (app/lib/auth/authorization.ts:181-191); write, pagar y confirmar los tienen admin y supervisor_area_cobranzas (app/lib/auth/authorization.ts:313-320), mientras que los delete (anular) y todos los ABM de Entidades Vendedoras, Motivos de Recompra y Créditos No Vendibles quedan solo para admin (app/lib/auth/authorization.ts:246).
- CompraCartera, CompraCarteraDetalle, CompraCarteraTasa, TitularExterno, RecompraCartera, RecompraCarteraDetalle, MotivoRecompra, EntidadVendedora y CreditoNoVendible están todos bajo auditoría automática (app/lib/db.server.ts:92-115).
Dónde vive en el código
app/routes.tsapp/routes/compra-cartera/create.tsxapp/routes/compra-cartera/create.server.tsapp/routes/compra-cartera/seleccion.tsxapp/routes/compra-cartera/seleccion.server.tsapp/routes/compra-cartera/agregar-operaciones.tsxapp/routes/compra-cartera/migrar.tsxapp/routes/compra-cartera/subir-contrato.tsxapp/routes/compra-cartera/legajos-upload.tsxapp/routes/compra-cartera/pagar.tsxapp/routes/compra-cartera/anular.tsxapp/routes/compra-cartera/exportar.tsxapp/routes/compra-cartera/view.tsxapp/routes/compra-cartera/list.tsxapp/lib/compra-cartera/estados.tsapp/lib/compra-cartera/types.tsapp/lib/compra-cartera/createCompraCartera.tsapp/lib/compra-cartera/createCompraCarteraUseCase.tsapp/lib/compra-cartera/seleccionCompraCartera.tsapp/lib/compra-cartera/parseExcelCarteraOfrecida.tsapp/lib/compra-cartera/recalcularTotalesCompraCartera.tsapp/lib/compra-cartera/migrarCompraCartera.tsapp/lib/compra-cartera/prepararPagoCompraCartera.tsapp/lib/compra-cartera/registrarPagoCompraCartera.tsapp/lib/compra-cartera/anularCompraCartera.tsapp/lib/compra-cartera/exportar.tsapp/lib/cartera/calcularNetoOperacionCartera.tsapp/lib/cartera/calcularVA.tsapp/routes/recompra-cartera/create.tsxapp/routes/recompra-cartera/importar.tsxapp/routes/recompra-cartera/confirmar.tsxapp/routes/recompra-cartera/pagar.tsxapp/routes/recompra-cartera/anular.tsxapp/routes/recompra-cartera/exportar.tsxapp/routes/recompra-cartera/view.tsxapp/routes/recompra-cartera/list.tsxapp/lib/recompra-cartera/estados.tsapp/lib/recompra-cartera/parseExcelRecompra.tsapp/lib/recompra-cartera/importarDetallesRecompra.tsapp/lib/recompra-cartera/confirmarRecompra.tsapp/lib/recompra-cartera/prepararPagoRecompra.tsapp/lib/recompra-cartera/registrarPagoRecompra.tsapp/lib/recompra-cartera/anularRecompra.tsapp/lib/recompra-cartera/exportar.tsapp/lib/recompra-cartera/contabilidad/asientoRecompra.tsapp/routes/creditos-no-vendibles/create.tsxapp/routes/creditos-no-vendibles/carga-masiva.tsxapp/routes/creditos-no-vendibles/delete.tsxapp/routes/creditos-no-vendibles/list.tsxapp/lib/creditos-no-vendibles/motivos.tsapp/routes/entidades-vendedoras/create.tsxapp/routes/motivos-recompra/create.tsxapp/lib/ventas-cartera/confirmarVentaCartera.tsapp/lib/ventas-cartera/filtrarOperaciones.tsapp/lib/ventas-cartera/agregarOperacionesVentaCartera.tsapp/lib/ventas-cartera/cuotas.tsapp/lib/auth/authorization.tsapp/layout/AppSidebar.tsxapp/lib/db.server.tsprisma/schema.prismadocs/compra-cartera-plan.mddocs/plan-recompra-cartera.mddocs/smoke-compra-cartera.mdA tener en cuenta
- Diferencia de negocio en una línea: COMPRAR cartera = plata sale, operaciones entran (créditos de terceros que pasan a ser propios). VENDER cartera = plata entra, operaciones se marcan como cedidas pero siguen en la base. RECOMPRAR cartera = plata sale, operaciones que estaban cedidas vuelven a ser propias. Compra y venta trabajan contra entidades distintas (EntidadVendedora vs EntidadCompradora); la recompra siempre trabaja contra la EntidadCompradora a la que se le vendió.
- El nombre 'Créditos No Vendibles' es literal: solo bloquea VENTAS. Se verifica en app/lib/ventas-cartera/filtrarOperaciones.ts:33 y agregarOperacionesVentaCartera.ts:37. Nada impide comprar o recomprar una operación marcada como no vendible.
- La contabilidad de la recompra está deliberadamente desactivada: app/lib/recompra-cartera/contabilidad/asientoRecompra.ts:26 hace un throw incondicional con el TODO de la matriz contable, y confirmarRecompra.ts:101-116 lo envuelve en try/catch degradando a logger.warn. Hoy no se genera ningún asiento de recompra. La compra de cartera directamente no tiene módulo de asientos: solo Pago y Movimiento.
- anularCompraCartera NO usa la función puedeTransicionarCompraCartera: solo chequea que no esté 'pagada' ni 'anulada'. En cambio anularRecompra sí valida contra la tabla de transiciones. Es una asimetría entre los dos módulos.
- En la migración, los detalles cuyo DNI no tenga al menos 7 dígitos se descartan en silencio (migrarCompraCartera.ts:87): no se cuentan en 'creadas' ni en 'yaMigradas' y no aparecen en ningún reporte de error. Un archivo con DNIs mal formateados puede migrar de menos sin que nadie se entere.
- TitularExterno tiene DNI único global. Si el DNI ya existía por una compra anterior, la migración PISA nombre, apellido, CUIT, email y teléfono con los datos del Excel nuevo (migrarCompraCartera.ts:94-106). No hay merge ni alerta de conflicto.
- Las operaciones compradas se crean directamente en estado 'aprobada' con fechaAprobacion = ahora, sin pasar por análisis de riesgo, scoring ni BCRA. Es correcto para cartera ya originada por un tercero, pero implica que el circuito de aprobación queda by-pasado por completo.
- La ruta /compra-cartera/:id/legajos/upload no valida el estado de la compra (ni el loader ni el action lo chequean), a diferencia de contrato, migración y pago. Se pueden subir legajos sobre una compra anulada.
- El nombre de la ruta /compra-cartera/:id/agregar-operaciones es engañoso: no agrega operaciones propias del sistema, hace una carga masiva adicional de Excel externo (modo 'agregar_carga_masiva'). El equivalente en ventas sí agrega operaciones propias.
- La importación de recompra deja la cabecera en 'validada' aunque el archivo traiga filas con error. Los errores se muestran una sola vez en la respuesta del action y no se persisten en ningún lado, así que no hay forma de auditar después qué filas se cayeron.
- La recompra no permite editar el detalle: la única corrección es volver a importar el archivo entero (que borra todo con deleteMany), pero eso solo se puede hacer desde 'borrador' y la importación ya deja la recompra en 'validada'. En la práctica, una vez importada no hay reimportación posible sin anular y empezar de cero.
- No existen pantallas de edición ni borrado para CompraCartera ni RecompraCartera. Lo único modificable después del alta es el plan comercial de la compra, y solo antes de migrar. Cualquier otro error de carga obliga a anular.
- La 'diferencia' entre lo efectivamente pagado y el neto calculado (compra) o entre lo pagado y lo reincorporado (recompra) se persiste pero no dispara ninguna validación, alerta ni aprobación. Un pago por el doble del neto se registra sin fricción.
- Solo el rol admin puede anular. En una operatoria real, el supervisor de cobranzas que arma la compra no puede corregirla si se equivoca: depende de un admin.
- No hay jobs asíncronos ni crons en este dominio. La migración de cartera y la subida masiva de legajos corren sincrónicamente dentro del request HTTP, lo que puede ser un problema de timeout con carteras grandes (la migración crea, por crédito, un titular, una cuenta corriente, una operación y N cuotas, todo en una sola transacción).
- CompraCartera tiene el campo tipoCompra pero el único valor posible es 'compra_cartera' (COMPRA_CARTERA_TIPOS). En ventas, en cambio, hay tres tipos. La modalidad 'prestamo_garantia' existe en compra pero no cambia ningún comportamiento del código: es solo informativa.
- Existe el modelo CompraCarteraRechazo (rechazos de la contraparte) y la vista lo muestra, pero no encontré ninguna ruta ni lógica que cree registros ahí. Parece funcionalidad prevista y no implementada.
- La compra no marca las operaciones con ningún estadoCesion. Es coherente (son operaciones propias desde el día uno), pero significa que una operación comprada podría después venderse y luego recomprarse sin ninguna traza que la vincule a su compra original más allá de compraCarteraDetalleId.
Ciclo de vida del socio
Cubre el ciclo de vida del asociado: alta en el padrón, asignación del acta del Consejo de Administración, confirmación en el Libro y ante INAES, alta operativa —que recién ocurre cuando se aprueba su primera operación y es la que le asigna número de socio, fecha de ingreso y capital cooperativo—, los reportes regulatorios (CSV INAES y Libro de Socios PDF) y la salida por baja individual o por depuración masiva con circuito de solicitud, autorización y ejecución. El estado del socio es el candado del negocio: si no está en activo, el sistema no deja aprobar ninguna operación crediticia. Aclaración importante: la "cuota social" que factura el sistema no es un cargo del padrón, es la facturación mensual del producto Servicio Social — ver el flujo 14.
activo, pero recién existe del todo cuando se le aprueba el primer préstamo: ahí se le asigna el número de socio, la fecha de ingreso y el capital cooperativo. El estado del socio es el candado del negocio — sin activo no se aprueba ninguna operación — y de las cuatro salidas sólo incumplimiento tiene vuelta.Quién interviene
Qué lo dispara
- Alta manual de un socio por un admin desde /socios/alta (formulario web).
- Asignación masiva de acta a socios sin acta desde /socios/asignar-actas o alta de acta desde /actas-socios/alta.
- Aprobación de la primera operación del socio en la bandeja de operaciones: dispara número de socio, fecha de ingreso, capital cooperativo y, si corresponde, la suscripción a cuota social.
- Cron mensual del job 'cuota-social' (patrón '0 6 1 * *': día 1 de cada mes a las 6 AM) que genera la cuota social de cada suscripción activa.
- Pedido de baja individual iniciado por un admin en /socios/:id/baja.
- Consulta y solicitud de depuración masiva en /socios/depuracion-masiva/consulta y /solicitar, seguida de autorización y ejecución.
- Necesidad regulatoria de reportar a INAES: exportación CSV trimestral desde /socios/exportar-inaes y generación del Libro de Socios PDF desde /socios/libro-socios.
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Carga el alta del socio (CUIL, DNI, nombre, apellido, fecha de nacimiento validada como mayor de 18, email obligatorio, PEP, domicilios y teléfonos). El CUIL debe tener 11 dígitos numéricos.
/socios/alta·Valida con Zod (app/routes/socios/validations.ts). No acepta campos de capital: el capital nunca se carga a mano.
Resuelve el código ASE de INAES a partir del domicilio principal (provincia + localidad + partido). Si hay una sola coincidencia lo asigna; si hay varias intenta desambiguar por departamento; si no puede, el socio se crea sin código y se completa después.
/socios/alta·Setea Socio.codigoAseInaesId apuntando al catálogo CodigoAseINAES. Nunca acepta un código enviado por el cliente.
Crea el socio dentro de una transacción junto con sus direcciones y teléfonos (creates explícitos, uno por uno, para que queden auditados).
/socios/alta·Inserta Socio con estado 'activo', fechaIngreso null, numeroSocio null y sin capital. Encola el mail 'socio-created' si el socio tiene email. Si el CUIL o el email ya existen devuelve 'Socio ya existente'.
Filtra los socios que todavía no tienen acta (por fecha de alta o rango de número de socio) y les asigna un acta existente o crea una nueva (número, fecha, libro, tipo). Puede tildar 'confirmar en libro' y/o 'confirmar en INAES' en el mismo movimiento.
/socios/asignar-actas·Crea o actualiza ActaSocio y hace un updateMany sobre los socios seleccionados grabando actaSocioId, numeroActa, fechaActa y numeroDeLibroSocios. Si el acta ya estaba confirmada, aborta la transacción.
Administra las actas por separado desde su propio ABM: alta, modificación y confirmación ante INAES con un botón dedicado.
/actas-socios/listado, /actas-socios/alta, /actas-socios/:id/modificar, /actas-socios/:id/confirmar-inaes·Setea ActaSocio.confirmadaInaes = true y fechaConfirmacionInaes = ahora. Una vez confirmada (en libro o en INAES) el acta queda congelada y no se puede modificar.
Aprueba la primera operación del socio. Antes de aprobar, el sistema verifica que el socio esté en estado 'activo'.
/operaciones (bandeja de aprobación)·Si el socio no está activo, devuelve error 400 y no aprueba. Si está activo: setea Socio.fechaIngreso (solo la primera vez), integra el capital cooperativo de $10 (capitalSuscripto/capitalIntegrado) y suma la integración de cuota de servicio social si el cuadro de gastos la tiene.
Asigna el número de socio tomándolo de la secuencia de base socio_numero_socio_seq, con update condicional para evitar que dos aprobaciones concurrentes generen dos números distintos.
Setea Socio.numeroSocio. Recién a partir de acá el socio existe para INAES y para el Libro de Socios.
Si la operación contrató un servicio social, activa la suscripción de cuota social del socio (idempotente por socio + servicio social) y le crea una cuenta corriente dedicada de tipo SERVICIO_SOCIAL.
Crea SuscripcionCuotaSocial en estado 'activa' con el valorCuota del servicio social, crea la CuentaCorriente y replica el valor en Socio.valorCuota para la exportación INAES.
El día 1 de cada mes a las 6 AM recorre todas las suscripciones activas y genera la cuota del período, con vencimiento el 25 del mes y los gastos del servicio social prorrateados (neto + IVA).
Crea CuotaSocial en estado 'pendiente' imputada a la cuenta corriente de la suscripción. Es idempotente: chequea la existencia por fechaVencimiento y tiene unique (suscripción, número de cuota).
Consulta el padrón, con filtros por CUIL, nombre, nivel de riesgo, PEP y estado, y entra al detalle del socio donde ve acta, código ASE, cuota social, operaciones y documentos.
/socios/listado, /socios/:id/ver·Solo lectura. El filtro por estado 'activo' incluye también los socios con estado null (legado).
Modifica los datos del socio. Al guardar se vuelve a resolver el código ASE desde el domicilio.
/socios/:id/modificar·Actualiza Socio, direcciones y teléfonos. Todo queda registrado en el audit log.
Genera los PDFs del socio: solicitud de ingreso, solicitud de baja y la vista previa de formularios.
/socios/:id/formularios-preview, api/socios/documento-ingreso, api/socios/documento-baja·Genera o recupera el PDF desde S3 y lo registra como DocumentoSocio (tipo solicitud_ingreso_generada / solicitud_baja_generada).
Corre la exportación INAES por trimestre eligiendo tipo 'altas' o 'bajas' y rango de fechas. Primero hace un preview con las primeras 10 filas, después descarga.
/socios/exportar-inaes·Si hay socios sin acta o sin código ASE devuelve 422 con el listado y NO genera el archivo. Si está todo OK genera el CSV (o un ZIP si supera 500 registros) y registra la corrida en ExportacionInaes.
Cuando el preview marca socios sin código ASE, pide sugerencias por provincia/localidad y asigna el código a mano.
api/codigos-ase/sugerencias, api/socios/asignar-codigo-ase·Solo acepta IDs que existan realmente en el catálogo CodigoAseINAES; graba Socio.codigoAseInaesId.
Genera el Libro de Socios en PDF por rango de fechas de ingreso y opcionalmente rango de número de socio.
/socios/libro-socios·Sólo incluye socios con número de socio y con acta que tenga número y fecha válidos. Si no hay ninguno devuelve 404. Registra la corrida en LibroSociosGenerado con el usuario que la generó.
Consulta los reportes del módulo: altas y bajas, cantidad de asociados a una fecha de corte, capital acumulado por mes, historial del libro (derivado del audit log) y libros generados.
/socios/reportes y sus subrutas·Solo lectura; exportable a Excel vía api/socios/reportes/export.
Inicia la baja individual del socio en un asistente de 5 pasos (socio, validación, motivo, reembolso, confirmación).
/socios/:id/baja·Valida que el socio esté activo, que tenga fecha de acta, que no tenga operaciones activas, ni pagos pendientes, ni cuotas sociales en estado 'pendiente'. Si algo falla, bloquea la baja y muestra el detalle.
Procesa la baja aprobada por el asistente.
/socios/:id/baja·En una transacción: desactiva la SuscripcionCuotaSocial (estado 'inactiva' + fechaBaja) y actualiza el socio con el nuevo estado, motivoBaja, observacionesBaja y fechaBaja. Encola el mail 'socio-removed'.
Consulta el universo depurable filtrando por rango de fecha de ingreso, préstamos activos, cuota social activa, provincias, organismo y rango de número de socio; puede bajar la preview a Excel.
/socios/depuracion-masiva/consulta·Solo trae socios en estado 'activo' y con fechaActa. Marca como 'restringido' a los que tienen alguna operación con cuotas impagas.
Selecciona los socios y crea la solicitud de depuración masiva.
/socios/depuracion-masiva/solicitar·Crea BajaSocioMasiva en estado 'pendiente' con los filtros usados y un BajaSocioMasivaDetalle por socio. Rechaza la solicitud si algún socio no coincide con los filtros o está restringido. Desde ese momento el socio no puede originar nuevas operaciones.
Revisa la solicitud y la autoriza o la rechaza.
/socios/depuracion-masiva/autorizar/:id·Pasa BajaSocioMasiva a 'autorizado' (grabando autorizadoPorId) o a 'rechazado' (grabando rechazadoPorId). Sólo se puede hacer desde 'pendiente'.
Ejecuta la depuración eligiendo la fecha de baja.
/socios/depuracion-masiva/:id·Revalida fecha de acta y ausencia de operaciones activas. En transacción pasa todos los socios a estado 'depuracion_masiva' con fechaBaja, motivo 'Depuración por auditoría' y observación con el ID del lote, y marca la solicitud como 'ejecutado'.
Reporta las bajas del período a INAES con la misma pantalla de exportación, tipo 'bajas'.
/socios/exportar-inaes·Trae los socios con número de socio cuya fechaBaja cae en el rango y cuyo estado es baja_voluntaria, fallecido, incumplimiento o depuracion_masiva.
Reactiva a un socio que estaba en 'incumplimiento' desde la ficha del socio.
/socios/:id/ver·Lo vuelve a 'activo', limpia motivoBaja, observacionesBaja y fechaBaja, y le asigna un número de socio NUEVO tomado de la secuencia.
Estados
- activo
- Estado del Socio. Es el estado con el que nace todo socio al darse de alta. Es el único estado que habilita aprobar operaciones, dar de baja y entrar en depuración masiva.
- baja_voluntariafinal
- Estado del Socio. Baja pedida por el propio asociado (renuncia, traslado u otros motivos personales). No admite ninguna transición posterior.
- fallecidofinal
- Estado del Socio. Baja por fallecimiento. No admite ninguna transición posterior.
- incumplimiento
- Estado del Socio. Baja por falta de pago de cuotas o incumplimiento del estatuto. Es el único estado de salida reversible: se puede reactivar a 'activo'.
- depuracion_masivafinal
- Estado del Socio. Baja aplicada por el circuito de depuración masiva (auditoría o inactividad). No admite ninguna transición posterior.
- pendiente
- Estado de la solicitud BajaSocioMasiva recién creada, esperando autorización. También es el estado inicial de cada CuotaSocial generada por el job.
- autorizado
- Estado de la solicitud BajaSocioMasiva ya aprobada por el autorizador, lista para ejecutarse. Los socios incluidos ya no pueden originar operaciones.
- ejecutadofinal
- Estado final de la solicitud BajaSocioMasiva: las bajas ya se impactaron en los socios.
- rechazadofinal
- Estado final de la solicitud BajaSocioMasiva rechazada por el autorizador. Los socios quedan intactos.
- activa
- Estado de la SuscripcionCuotaSocial vigente: genera cuota todos los meses y bloquea la baja mientras haya cuotas impagas.
- inactivafinal
- Estado de la SuscripcionCuotaSocial dada de baja junto con el socio. Deja de generar cuotas.
Reglas que el sistema hace cumplir
- Todo socio nace en estado 'activo', sin número de socio, sin fecha de ingreso y sin capital: el alta administrativa no es todavía el alta operativa (app/routes/socios/create.tsx:227 y :223).
- El estado del socio es el candado para aprobar operaciones: la máquina de estados exige la guarda isSocioActivo junto con isApprover para pasar de 'esperando_aprobacion' a 'aprobado' (app/state/operacionEstado/machine.ts:206), la guarda se alimenta comparando el estado del socio contra ESTADOS_SOCIO.ACTIVO (app/lib/operations/transitionEstado.server.ts:80 y :284) y el action revalida del lado del servidor devolviendo 400 con el estado actual del socio (app/routes/operaciones/aprobar-operacion/actions.ts:145 y app/lib/socios/integracion-operaciones.ts:35). Es decir: un socio dado de baja, en incumplimiento o depurado congela toda su cartera nueva.
- El número de socio se asigna recién cuando se aprueba (liquida) la primera operación, no al cargar la solicitud, y se toma de la secuencia de base socio_numero_socio_seq con un updateMany condicional a numeroSocio null para evitar duplicados por concurrencia (app/lib/operations/transitionEstado.server.ts:120 y :139-146; app/lib/socios/nextNumeroSocio.server.ts:11-13).
- La fecha de ingreso del socio también se setea en la aprobación de la primera operación y sólo la primera vez (app/routes/operaciones/aprobar-operacion/actions.ts:360-366).
- El capital cooperativo se integra automáticamente al aprobar: una acción cooperativa de $10 (app/lib/socios/capital.ts:4 y app/lib/socios/integracion-operaciones.ts:71-88). El formulario de alta rechaza explícitamente los campos de capital para que nunca se carguen a mano (app/routes/socios/validations.ts:21-23).
- La integración de cuota de servicio social se suma al capital sólo desde el cuadro de gastos marcado esIntegracionCapital, nunca desde el plan comercial (app/lib/socios/integracion-operaciones.ts:110-155).
- Si se cancela la operación que auto-integró el capital, se revierte, pero únicamente si los valores siguen siendo exactamente los auto-integrados; si el socio recibió capital adicional se deja intacto y sólo se loguea un warning (app/lib/socios/integracion-operaciones.ts:160-210).
- El código ASE de INAES lo determina el servidor a partir del domicilio principal: nunca se acepta un código enviado por el cliente (app/routes/socios/create.tsx:194-202). Si hay varias localidades homónimas intenta desambiguar por partido/departamento y si no puede devuelve 'ambiguo' y el socio queda sin código (app/lib/socios/resolver-codigo-ase.ts:17-50).
- La asignación manual de código ASE sólo acepta IDs que existan en el catálogo oficial CodigoAseINAES; cualquier otro valor devuelve 404 (app/routes/api/socios/asignar-codigo-ase.ts:28-38).
- La exportación INAES está bloqueada si en el período hay socios sin acta asignada o sin código ASE: no genera ninguna parte del archivo y devuelve el detalle para resolverlo (app/lib/socios/exportacion-inaes.ts:312-320; respuestas 422 en app/routes/api/socios/exportar-csv.ts:130 y :155).
- La exportación INAES parte los archivos cada 500 registros y los entrega en ZIP cuando hay más de una parte (app/lib/socios/exportacion-inaes.ts:36 y app/routes/api/socios/exportar-csv.ts:222-250).
- El órgano emisor del acta se completa siempre automáticamente como 'Consejo de Administracion' por norma INAES (app/lib/socios/exportacion-inaes.ts:43).
- La exportación de altas sólo toma socios con número de socio asignado, en estado 'activo' y con fechaIngreso dentro del rango; la de bajas toma los que tienen fechaBaja en el rango y estado baja_voluntaria, fallecido, incumplimiento o depuracion_masiva (app/lib/socios/exportacion-inaes.ts:124-182).
- Un acta confirmada queda congelada: no se puede modificar ni desde el ABM de actas (app/routes/actas-socios/update.tsx:37-39) ni desde la asignación masiva, que aborta la transacción (app/routes/socios/asignar-actas.tsx:279-281).
- No se puede dar de baja un asociado que no tenga fecha de acta asignada. La regla está replicada en los tres caminos de baja: validación individual (app/lib/socios/bajas.ts:100-102), cambio de estado desde la ficha (app/routes/socios/actions.ts:129-135) y ejecución de depuración masiva (app/lib/socios/depuracion-masiva.ts:293-300).
- La baja individual se bloquea si el socio tiene operaciones activas (app/lib/socios/bajas.ts:124-127), pagos pendientes (app/lib/socios/bajas.ts:132-134) o cuotas sociales en estado 'pendiente' (app/lib/socios/bajas.ts:142-146).
- Las transiciones de estado del socio están cerradas por tabla: desde 'activo' se puede ir a los cuatro estados de salida; baja_voluntaria, fallecido y depuracion_masiva son finales; sólo 'incumplimiento' permite volver a 'activo' (app/lib/socios/states.ts:36-42).
- La reactivación desde 'incumplimiento' le asigna al socio un número de socio NUEVO de la secuencia y limpia motivo, observaciones y fecha de baja (app/routes/socios/actions.ts:138-153).
- La depuración masiva sólo alcanza a socios en estado 'activo' y con fechaActa, y marca como restringidos a los que tienen alguna operación con cuotas impagas (app/lib/socios/depuracion-masiva.ts:76-84 y :63-72).
- No se puede incluir en una solicitud de depuración a un socio restringido ni a un socio que no coincida con los filtros consultados (app/lib/socios/depuracion-masiva.ts:190-201), y la validación se repite antes de ejecutar (app/lib/socios/depuracion-masiva.ts:302-307).
- Circuito de cuatro ojos en depuración masiva: sólo se autoriza o rechaza desde 'pendiente' (app/lib/socios/depuracion-masiva.ts:240 y :262) y sólo se ejecuta desde 'autorizado' (app/lib/socios/depuracion-masiva.ts:288). Además solicitar y autorizar son permisos distintos (app/lib/auth/authorization.ts:136-137).
- Un socio incluido en una solicitud de depuración en estado 'pendiente' o 'autorizado' no puede originar nuevas operaciones (app/lib/socios/depuracion-masiva.ts:161-174, usado en app/routes/operaciones/alta-operacion/actions.ts:265 y app/lib/operations/validateClienteAgainstPlanComercial.ts:42).
- La cuota social es una sola por combinación socio + servicio social: la activación es idempotente y omite si ya hay una suscripción activa (app/lib/socios/integracion-operaciones.ts:239-246). Cada suscripción tiene su propia cuenta corriente de tipo SERVICIO_SOCIAL (app/lib/socios/integracion-operaciones.ts:265-273).
- Las cuotas sociales se generan por cron el día 1 de cada mes a las 6 AM (app/jobs/queue.ts:276-282) con vencimiento el día 25 del mes del período (app/lib/socios/generar-cuotas-sociales.ts:28), incluyendo los gastos del servicio social separados en neto e IVA (app/lib/socios/generar-cuotas-sociales.ts:57-66).
- No existe borrado real de socios: la ruta /socios/:id/eliminar redirige al asistente de baja (app/routes/socios/delete.tsx:12) y en producción una extensión de Prisma bloquea delete y deleteMany sobre Socio obligando al soft-delete por estado/fechaBaja (app/lib/db.server.ts:267-276).
- El Libro de Socios PDF sólo incluye socios con número de socio asignado y con acta que tenga número y fecha válidos; si no hay ninguno devuelve 404 (app/routes/api/socios/libro-socios.ts:54-90). Cada generación queda registrada en LibroSociosGenerado con el usuario responsable (app/routes/api/socios/libro-socios.ts:126-137).
- El reporte 'Libro historial' no es una tabla propia: se arma leyendo el audit log de la entidad Socio y calculando los campos que cambiaron en cada update (app/lib/socios/reportes.ts:269-339).
- Socio, ActaSocio, DocumentoSocio, BajaSocioMasiva, BajaSocioMasivaDetalle, SuscripcionCuotaSocial y CuotaSocial están todos bajo audit log automático (app/lib/db.server.ts:33, :45-48, :85-86).
Dónde vive en el código
app/routes.tsapp/routes/socios/create.tsxapp/routes/socios/update.tsxapp/routes/socios/view.tsxapp/routes/socios/list.tsxapp/routes/socios/baja.tsxapp/routes/socios/delete.tsxapp/routes/socios/actions.tsapp/routes/socios/validations.tsapp/routes/socios/asignar-actas.tsxapp/routes/socios/exportar-inaes.tsxapp/routes/socios/libro-socios.tsxapp/routes/socios/formularios-preview.tsxapp/routes/socios/depuracion-masiva/consulta.tsxapp/routes/socios/depuracion-masiva/solicitar.tsxapp/routes/socios/depuracion-masiva/autorizar.tsxapp/routes/socios/depuracion-masiva/detalle.tsxapp/routes/socios/depuracion-masiva/historial.tsxapp/routes/socios/reportes/index.tsxapp/routes/actas-socios/list.tsxapp/routes/actas-socios/create.tsxapp/routes/actas-socios/update.tsxapp/routes/actas-socios/confirmar-inaes.tsxapp/routes/codigos-ase/list.tsxapp/routes/api/socios/exportar-csv.tsapp/routes/api/socios/libro-socios.tsapp/routes/api/socios/asignar-codigo-ase.tsapp/routes/api/socios/documento-ingreso.tsapp/routes/api/socios/documento-baja.tsapp/routes/api/codigos-ase/sugerencias.tsapp/lib/socios/states.tsapp/lib/socios/bajas.tsapp/lib/socios/capital.tsapp/lib/socios/integracion-operaciones.tsapp/lib/socios/exportacion-inaes.tsapp/lib/socios/resolver-codigo-ase.tsapp/lib/socios/nextNumeroSocio.server.tsapp/lib/socios/depuracion-masiva.tsapp/lib/socios/depuracion-masiva-consts.tsapp/lib/socios/generar-cuotas-sociales.tsapp/lib/socios/reportes.tsapp/lib/socios/documentos/libro-socios.tsapp/lib/operations/transitionEstado.server.tsapp/routes/operaciones/aprobar-operacion/actions.tsapp/state/operacionEstado/machine.tsapp/lib/auth/authorization.tsapp/lib/auth/roles.tsapp/lib/db.server.tsapp/jobs/queue.tsapp/jobs/worker.tsprisma/schema.prismadocs/cuota-social.mdA tener en cuenta
- El alta administrativa y el alta operativa están desacopladas y eso no es evidente para el usuario: un socio puede existir en el padrón durante meses en estado 'activo' pero sin número de socio ni fecha de ingreso, y por lo tanto invisible para la exportación INAES y para el Libro de Socios. El disparador real del alta operativa es la aprobación de la primera operación.
- La reactivación de un socio en 'incumplimiento' le asigna un número de socio NUEVO tomado de la secuencia, pisando el original (app/routes/socios/actions.ts:139-153). Para un padrón cooperativo eso significa que el socio reaparece en el Libro con otro número y se pierde la trazabilidad con su historial anterior. Vale la pena confirmar si es lo que se quiere.
- La depuración masiva NO desactiva la SuscripcionCuotaSocial ni envía el mail de baja, cosas que sí hace la baja individual (comparar app/lib/socios/depuracion-masiva.ts:310-325 contra app/lib/socios/bajas.ts:196-250). Resultado: un socio depurado puede quedar con una suscripción en estado 'activa' y el job mensual le va a seguir generando cuotas sociales.
- La depuración masiva tampoco valida cuotas sociales pendientes ni pagos pendientes; sólo mira fecha de acta y operaciones con cuotas impagas. La baja individual es bastante más estricta.
- El asistente de baja individual ofrece como opción de estado a 'depuracion_masiva' (porque obtenerEstadosValidos devuelve las cuatro transiciones desde 'activo'), lo que permite marcar a un socio como depurado salteando por completo el circuito de solicitud y autorización.
- El campo Socio.estado es un String con default 'activo' y no un enum de base: existen socios legado con estado null, que el listado trata como activos (app/routes/socios/list.tsx:74-91) y el reporte de cantidad de asociados también (app/lib/socios/reportes.ts:200).
- El reembolso de capital al dar de baja no está implementado: aparece un TODO explícito y los reportes muestran capitalDevuelto fijo en 0 (app/lib/socios/bajas.ts:4-6 y app/lib/socios/reportes.ts:166-169).
- Los permisos de actas, asignación de actas y depuración masiva existen como constantes separadas pero hoy sólo los tiene el rol admin, porque ROLE_PERMISSIONS[admin] = Object.values(PERMISSIONS) y ningún otro rol los enumera. El control de cuatro ojos de la depuración masiva, en la práctica, hoy lo puede hacer una sola persona.
- La mayor parte del módulo de socios está protegida por rol duro (requireUserWithAnyRole([ROLES.ADMIN])) en vez de por permiso, a diferencia del resto del sistema que usa PERMISSIONS. Eso hace imposible delegar el ABM de socios sin dar admin completo.
- La exportación INAES escribe el CUIT de la entidad hardcodeado en el código (CUIT_GRAN_COOPERATIVA en app/routes/api/socios/exportar-csv.ts:10) mientras que la librería está preparada para leerlo de la variable de entorno COOPERATIVA_CUIT. Si cambia la entidad emisora hay que tocar código.
- El PDF del Libro de Socios se genera con numeroLibro vacío en la configuración (app/routes/api/socios/libro-socios.ts:118), aunque el dato existe en cada ActaSocio.numeroLibro y en Socio.numeroDeLibroSocios.
- La asignación masiva de actas puede confirmar el acta en el Libro y ante INAES con un simple tilde en la misma pantalla, sin ninguna validación adicional ni segundo par de ojos, y esa confirmación es irreversible desde la UI (deja el acta congelada).
- Los datos de domicilio del socio están duplicados en dos lugares: los campos planos Socio.direccionCalle / direccionNumero / codigoPostal y la relación Direccion[]. La exportación INAES lee los campos planos (app/lib/socios/exportacion-inaes.ts:229-236) mientras que la resolución del código ASE y los reportes leen la relación, lo que puede producir CSVs con domicilio vacío aunque el socio tenga direcciones cargadas.
- El doc docs/cuota-social.md está desactualizado respecto del código: describe una suscripción única por socio con @unique en socioId y operacionOrigenId obligatorio, pero el esquema real permite varias suscripciones por socio (una por servicio social), tiene operacionOrigenId opcional, agrega servicioSocialId y una cuenta corriente dedicada, y las cuotas llevan gastos e IVA que el doc no menciona.
Servicio social: suscripción y cuota
El servicio social es la segunda línea de producto de IRIS: un pack de beneficios del catálogo que el socio contrata dentro de una operación y paga con una cuota mensual recurrente. El vendedor lo vende en un paso propio del asistente de alta ('Datos Servicio'); al aprobarse la operación se activa una SuscripcionCuotaSocial con su propia cuenta corriente tipo SERVICIO_SOCIAL y se congela el valor de cuota del catálogo. Un cron mensual (día 1 a las 6 AM) genera una CuotaSocial por suscripción activa, con los gastos del servicio prorrateados en neto e IVA y vencimiento el 25; Cobranzas la cobra importando el archivo de imputaciones con concepto CUOTA_SOCIAL. La baja del socio desactiva la suscripción y corta la facturación. Un préstamo y un servicio social pueden convivir en la misma operación pero se cobran por caminos distintos: el préstamo por CuotaCredito y el servicio por CuotaSocial.
CuotaCredito, el servicio por CuotaSocial. La suscripción nace en la transacción de aprobación —no antes— y sólo muere con la baja del socio.Quién interviene
Qué lo dispara
- El vendedor elige tipo de producto SERVICIO o AMBOS al iniciar el alta de la operación (habilita el paso 'datos_servicio')
- La aprobación de la operación con intent 'aprobarOperacion' sobre una operación que tiene servicioSocialId cargado
- Cron mensual '0 6 1 * *' de la cola 'cuota-social' (día 1 de cada mes a las 6 AM)
- Encolado manual del job de cuota social vía enqueueCuotaSocialJob(periodo) para un período puntual
- Import manual del archivo de imputaciones en /cobranzas/imputaciones/importar con concepto CUOTA_SOCIAL
- Solicitud de baja del socio en /socios/:id/baja
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Da de alta el servicio social en el catálogo: nombre, valor de cuota, modalidad de cobro, vigencia y los gastos que se aplican a cada cuota mensual.
/servicios-sociales/alta·Crea ServicioSocial (activo=true por defecto) y sus GastoServicioSocial con monto fijo por gasto.
Inicia el alta de la operación y elige el tipo de producto: PRESTAMO, SERVICIO o AMBOS.
/operaciones/alta-operacion·Crea la Operacion con los flags tipoProductoPrestamo y tipoProductoServicioSocial. Si el flag de servicio está en true, el asistente inserta el paso 'datos_servicio' entre 'datos_operacion' y 'datos_cobro'.
En el paso 'Datos Servicio' elige un servicio del catálogo. La pantalla muestra el valor de cuota y la tabla informativa de gastos por cuota con sus flags (incluye IVA, incluye CFT, cancelación automática, integración capital).
/operaciones/:operacionId/alta-operacion·Sólo lista servicios con activo=true. No se puede continuar sin elegir uno. Al elegirlo, precarga en el contexto la modalidad de cobro del servicio.
Persiste el servicio contratado en la operación y re-materializa el cuadro de gastos.
/operaciones/:operacionId/alta-operacion·Setea Operacion.servicioSocialId y llama a sincronizarGastosOperacion, que borra y vuelve a crear los OperacionGasto uniendo los gastos del plan comercial con los del servicio social (fila por fila, para que queden auditados). Acá entran a la operación los gastos con esIntegracionCapital.
Completa el paso 'Datos Cobro'. Si la operación es sólo servicio (sin préstamo), la modalidad de cobro se resuelve desde el servicio social elegido, no desde el plan comercial.
/operaciones/:operacionId/alta-operacion·Guarda forma de pago y datos de la modalidad. En operaciones sólo servicio se limpian los campos propios de préstamo (organismo, capital, plazo, cuota, descuentos).
Confirma la carga tras adjuntar documentación.
/operaciones/:operacionId/alta-operacion·Asigna numeroDeOperacion si falta y transiciona la operación a 'esperando_aprobacion'. La validación exige servicioSocialId cargado cuando tipoProductoServicioSocial es true.
Aprueba la operación desde la bandeja.
/operaciones/:operacionId/aprobar-operacion·Verifica que el socio esté en estado 'activo' (si no, corta), aplica los gastos, asegura la acción cooperativa e integra en el capital del socio la suma de los gastos marcados esIntegracionCapital (capitalSuscripto, capitalIntegrado e integracionCuotaSocial).
Activa la suscripción al servicio social contratado.
/operaciones/:operacionId/aprobar-operacion·Crea SuscripcionCuotaSocial en estado 'activa' con fechaAlta, operacionOrigenId y valorCuota congelado del catálogo; crea una CuentaCorriente dedicada (tipo SERVICIO_SOCIAL, moneda ARS, una por suscripción); y pisa Socio.valorCuota con el valor del servicio para compatibilidad con el export INAES. Es idempotente: si ya existe una suscripción activa al mismo servicio, no hace nada.
Cierra la aprobación.
/operaciones/:operacionId/aprobar-operacion·La operación pasa a 'aprobado' y se fija fechaAprobacion. Si además incluye préstamo, se generan aparte las CuotaCredito y el pago de desembolso; el servicio social no genera desembolso ni cuotas en este momento.
El día 1 de cada mes a las 6 AM se dispara el job 'cuota-social'. Si el job no trae período, el worker usa el mes en curso en formato YYYY-MM.
Encola y ejecuta generarCuotasSocialesMensuales(periodo).
Recorre todas las suscripciones en estado 'activa' y emite la cuota del período.
Por cada suscripción calcula el neto y el IVA (21%) de los gastos del servicio, numera la cuota como cantidad existente + 1 dentro de la transacción y crea la CuotaSocial en estado 'pendiente', con vencimiento el 25 del mes del período, imputada a la cuenta corriente dedicada. Es idempotente por fecha de vencimiento y por el índice único (suscripción, número de cuota); si la suscripción no tiene cuenta corriente la crea al vuelo.
Sube el archivo XLSX de imputaciones (columnas idOperacion, importe en centavos y fechaCobro AAAAMMDD) eligiendo canal y concepto CUOTA_SOCIAL, y primero mira la vista previa.
/cobranzas/imputaciones/importar·Valida sin escribir nada: por cada fila ubica la suscripción cuya operacionOrigenId coincide con el idOperacion del archivo y le asigna la cuota social pendiente o vencida más antigua todavía no consumida por otra fila del mismo archivo. Marca errores por ID inválido, operación inexistente, sin cuota disponible o duplicado.
Confirma el import.
/cobranzas/imputaciones/importar·Re-valida y por cada fila válida llama a registrarCobro en su propia transacción, creando un RegistroCobro con origen ARCHIVO_IMPUTACION, resultado EXITOSO, cuotaSocialId, el socio y la modalidad de cobro derivada del servicio social. La CuotaSocial NO se marca como pagada acá.
Procesa la baja del socio eligiendo el estado de baja y el motivo.
/socios/:id/baja·Antes de permitirla valida: socio en estado 'activo', con fecha de acta asignada, sin operaciones activas, sin pagos pendientes y sin cuotas sociales en estado 'pendiente'.
Ejecuta la baja.
/socios/:id/baja·Dentro de una transacción pasa la suscripción activa a estado 'inactiva' con fechaBaja y actualiza el socio al estado de baja elegido con motivo, observaciones y fechaBaja. A partir de ahí el cron mensual ya no la toma y deja de facturar. Si el socio tiene email, encola el mail 'socio-removed'.
Estados
- activa
- Estado de la SuscripcionCuotaSocial recién activada al aprobarse la operación. Es el único estado que el cron mensual factura.
- inactivafinal
- Estado de la SuscripcionCuotaSocial tras la baja del socio. Deja de generar cuotas. En el código no existe ningún camino de reactivación, así que en los hechos es terminal.
- pendiente
- Estado con el que nace toda CuotaSocial generada por el cron. Es elegible para ser imputada por el import de cobranzas y bloquea la baja del socio.
- pagadafinal
- Estado declarado en el schema de CuotaSocial, pero ningún código de la aplicación lo asigna hoy. El import de imputaciones documenta explícitamente que no hace esa transición.
- vencida
- Estado declarado en el schema de CuotaSocial y consultado por el import (busca cuotas 'pendiente' o 'vencida'), pero ningún proceso lo setea: no hay job que venza cuotas sociales.
- esperando_aprobacion
- Estado de la Operacion tras confirmar la carga. Es el estado desde el que se aprueba y, por lo tanto, la antesala de la activación de la suscripción.
- aprobado
- Estado de la Operacion una vez aprobada. Es el momento exacto en que nace la suscripción, su cuenta corriente dedicada y se integra el capital de la cuota de servicio.
- activo
- Estado del Socio. Es requisito para poder aprobar la operación (y por ende para activar la suscripción) y el único estado desde el que se puede pedir la baja.
- baja_voluntariafinal
- Estado del Socio por renuncia, traslado u otros motivos personales. Desactiva la suscripción de servicio social.
- fallecidofinal
- Estado del Socio por fallecimiento. Desactiva la suscripción de servicio social.
- incumplimiento
- Estado del Socio por falta de pago o incumplimiento de estatuto. Desactiva la suscripción, pero es el único estado de baja que admite volver a 'activo' (y esa reactivación NO vuelve a activar la suscripción).
- depuracion_masivafinal
- Estado del Socio dado de baja por el proceso de depuración masiva. Desactiva la suscripción.
Reglas que el sistema hace cumplir
- El paso 'datos_servicio' del asistente sólo existe si la operación tiene tipoProductoServicioSocial en true; el orden de pasos se arma dinámicamente según los dos flags de producto — app/routes/operaciones/[id]-alta-operacion/components/OperationStepper.tsx:30-42
- tipoProductoServicioSocial se deriva de la elección inicial: es true si el tipo de producto es SERVICIO o AMBOS, y tipoProductoPrestamo es true si es PRESTAMO o AMBOS (una operación puede llevar los dos productos a la vez) — app/routes/operaciones/alta-operacion/actions.ts:18-25
- El selector de servicio social sólo ofrece servicios del catálogo con activo=true — app/routes/operaciones/[id]-alta-operacion/loader.ts:288-289
- Elegir un servicio social es obligatorio: el botón Continuar del paso queda deshabilitado sin selección y la validación de confirmación exige el campo cuando el producto incluye servicio — app/routes/operaciones/[id]-alta-operacion/components/DatosServicio.tsx:73 y app/routes/operaciones/[id]-alta-operacion/validation.ts:294-300
- Al guardar el paso de servicio se re-materializa todo el cuadro de gastos de la operación uniendo los gastos del plan comercial con los del servicio social, y sólo mientras la operación no esté aprobada — app/routes/operaciones/[id]-alta-operacion/actions.ts:923-930 y app/lib/gastos/sincronizarGastosOperacion.ts:32-38
- Los gastos del servicio social entran siempre como monto FIJO y con impactaLiquidacion en false — app/lib/gastos/resolverGastosPlan.ts:260-263
- La modalidad de cobro del cobro efectivo se resuelve en cascada plan comercial, luego servicio social, luego organismo: en una operación de sólo servicio manda la modalidad del servicio — app/lib/cobranzas/registrarCobro.server.ts:110-115
- No se puede aprobar la operación si el socio no está en estado 'activo' — app/lib/socios/integracion-operaciones.ts:31-41, invocado en app/routes/operaciones/aprobar-operacion/actions.ts:145
- Sólo los APPROVER_ROLES pueden ejecutar la aprobación — app/routes/operaciones/aprobar-operacion/actions.ts:45 y app/lib/auth/roles.ts:66-73
- La suscripción se activa únicamente si la operación tiene servicioSocialId cargado — app/routes/operaciones/aprobar-operacion/actions.ts:375-377
- La activación es idempotente por par socio + servicio social en estado 'activa': aprobar una segunda operación con el mismo servicio no duplica la suscripción — app/lib/socios/integracion-operaciones.ts:238-247
- El valor de cuota de la suscripción se congela del catálogo al momento de activarla: cambiar después el precio del servicio no afecta a las suscripciones ya activas — app/lib/socios/integracion-operaciones.ts:249-259
- Cada suscripción tiene su propia cuenta corriente dedicada tipo SERVICIO_SOCIAL, distinta de la cuenta general del socio, y el helper de tesorería filtra por tipo para no colapsarlas — app/lib/socios/integracion-operaciones.ts:264-272, prisma/schema.prisma:2015 y app/lib/tesoreria/getOrCreateCuentaCorriente.ts:46
- Al activar se pisa Socio.valorCuota con el valor del servicio, sólo por compatibilidad con la exportación a INAES — app/lib/socios/integracion-operaciones.ts:275-278 y app/lib/socios/exportacion-inaes.ts:261
- La integración de capital de la cuota de servicio sale exclusivamente de la suma de gastos marcados esIntegracionCapital del cuadro de gastos, no de un campo del servicio social — app/lib/gastos/aplicarGastosAprobacion.ts:77-79 y app/routes/operaciones/aprobar-operacion/actions.ts:350-371
- La facturación mensual corre por cron con patrón '0 6 1 * *' y el worker, si el job no trae período, usa el mes en curso — app/jobs/queue.ts:275-282 y app/jobs/worker.ts:266-272
- Sólo se factura a las suscripciones en estado 'activa' — app/lib/socios/generar-cuotas-sociales.ts:30-31
- El vencimiento de la cuota social es siempre el día 25 del mes del período, sin excepción ni configuración — app/lib/socios/generar-cuotas-sociales.ts:28
- Los gastos del servicio se cargan dentro de cada cuota partidos en neto e IVA al 21% cuando el gasto está marcado incluyeIva — app/lib/socios/generar-cuotas-sociales.ts:56-64 y app/lib/gastos/splitMontoIva.ts:1-11
- La generación es idempotente: chequea si ya existe una cuota con la misma fecha de vencimiento y además hay índice único por (suscripción, número de cuota); una violación de unicidad se cuenta como omitida, no como error — app/lib/socios/generar-cuotas-sociales.ts:70-80 y 115-124, prisma/schema.prisma:2568
- El número de cuota se calcula como cantidad existente + 1 dentro de la transacción, para evitar carreras — app/lib/socios/generar-cuotas-sociales.ts:83-87
- Si una suscripción legacy no tiene cuenta corriente dedicada, el job se la crea al vuelo antes de emitir la cuota — app/lib/socios/generar-cuotas-sociales.ts:89-102
- El import de imputaciones con concepto CUOTA_SOCIAL ubica la suscripción por SuscripcionCuotaSocial.operacionOrigenId a partir del idOperacion del archivo — app/lib/cobranzas/importarImputaciones.server.ts:153-163
- Cada fila se imputa a la cuota social 'pendiente' o 'vencida' más antigua por número de cuota, con un cursor por operación: la segunda fila de la misma operación consume la siguiente cuota — app/lib/cobranzas/importarImputaciones.server.ts:165-170 y 244-260
- Deduplicación por cuota social, canal y fecha contra los RegistroCobro EXITOSO ya existentes: reimportar el mismo archivo reporta las filas como duplicadas — app/lib/cobranzas/importarImputaciones.server.ts:277-296
- El import NO marca la cuota social como pagada: sólo escribe el RegistroCobro — app/lib/cobranzas/importarImputaciones.server.ts:6-12
- Cada fila del import corre en su propia transacción, así que una fila fallida no tumba a las demás — app/lib/cobranzas/importarImputaciones.server.ts:334-383
- Importar imputaciones exige el permiso cobranzas_imputaciones:importar y el archivo debe ser .xlsx — app/routes/cobranzas/imputaciones-importar.tsx:42-62
- No se puede dar de baja a un socio con cuotas sociales en estado 'pendiente' — app/lib/socios/bajas.ts:135-146
- La baja del socio desactiva, en la misma transacción, la suscripción activa poniéndola en 'inactiva' con fechaBaja — app/lib/socios/bajas.ts:210-220
- Sólo se puede pedir la baja desde el estado 'activo'; baja_voluntaria, fallecido y depuracion_masiva son terminales, e incumplimiento es el único reversible — app/lib/socios/states.ts:34-40 y app/lib/socios/bajas.ts:198-206
- Dar de baja a un socio es exclusivo del rol admin — app/routes/socios/baja.tsx:44 y 61
- No se puede eliminar un servicio social del catálogo si tiene operaciones o suscripciones asociadas — app/lib/servicios-sociales/servicio-social-delete-guards.ts:11-31
- Los permisos servicios_sociales:read/write/delete sólo los tiene el rol admin (ningún otro rol los recibe en ROLE_PERMISSIONS) y el ítem del sidebar está restringido a admin — app/lib/auth/authorization.ts:211-213 y 246, app/layout/AppSidebar.tsx:145
Dónde vive en el código
/Users/martin.long/Documents/work/rebl/iris/app/routes.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/servicios-sociales/list.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/servicios-sociales/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/servicios-sociales/update.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/servicios-sociales/delete.tsx/Users/martin.long/Documents/work/rebl/iris/app/lib/servicios-sociales/servicio-social-delete-guards.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/alta-operacion/actions.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-alta-operacion/components/OperationStepper.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-alta-operacion/components/DatosServicio.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-alta-operacion/components/DatosCobro.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-alta-operacion/actions.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-alta-operacion/loader.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-alta-operacion/validation.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/aprobar-operacion/actions.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/socios/integracion-operaciones.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/socios/generar-cuotas-sociales.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/socios/bajas.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/socios/states.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/socios/exportacion-inaes.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/gastos/sincronizarGastosOperacion.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/gastos/resolverGastosPlan.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/gastos/aplicarGastosAprobacion.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/gastos/splitMontoIva.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cobranzas/importarImputaciones.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/cobranzas/registrarCobro.server.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/cobranzas/imputaciones-importar.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/socios/baja.tsx/Users/martin.long/Documents/work/rebl/iris/app/jobs/queue.ts/Users/martin.long/Documents/work/rebl/iris/app/jobs/worker.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/auth/authorization.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/auth/roles.ts/Users/martin.long/Documents/work/rebl/iris/prisma/schema.prisma/Users/martin.long/Documents/work/rebl/iris/docs/servicio-social-plan.md/Users/martin.long/Documents/work/rebl/iris/docs/cuota-social.mdA tener en cuenta
- El agujero más grande del circuito: la CuotaSocial nunca cambia de estado. Nace 'pendiente' y ningún código la pasa a 'pagada' ni a 'vencida' (verificado por grep: la única escritura sobre CuotaSocial en toda la app es el create del cron). El propio import de imputaciones lo documenta en su comentario de cabecera diciendo que esa transición es de otro módulo, fuera de alcance. Consecuencias reales: (a) una cuota ya cobrada se sigue ofreciendo como próxima cuota a imputar en el import siguiente; (b) la validación de baja cuenta cuotas 'pendiente', así que un socio que pagó todas sus cuotas igual queda bloqueado para darse de baja, para siempre.
- fechaPago de CuotaSocial existe en el modelo pero nunca se completa.
- No hay ninguna pantalla de cuotas sociales ni de suscripciones. El único lugar donde asoma la cuenta corriente tipo SERVICIO_SOCIAL es el listado genérico de cuentas corrientes de socios; no hay listado, detalle ni estado de cuenta de las CuotaSocial. Un usuario de negocio no tiene forma de ver en la app qué se le facturó a un socio por servicio social.
- La cuenta corriente dedicada se crea con saldo 0 y nada la mueve: no encontré código que genere Movimiento ni actualice el saldo contra cuentas tipo SERVICIO_SOCIAL. Sirve hoy sólo como contenedor lógico de las cuotas.
- Tampoco hay asiento contable propio de la cuota social: la contabilización se dispara en la aprobación de la operación, no en la facturación mensual.
- El día de vencimiento está fijo en el 25 dentro del código del job. A diferencia de las cuotas de crédito, que respetan diaVencimientoCuota de la modalidad de cobro, el servicio social ignora esa configuración.
- El cálculo de la fecha de vencimiento usa new Date(year, month - 1, 25), es decir la zona horaria del servidor, mientras el resto del módulo de cobranzas normaliza a medianoche UTC (toIsoUtc). Es un riesgo de correrse un día según cómo esté configurado el contenedor, y además afecta al chequeo de idempotencia, que compara por fecha exacta.
- No hay recuperación de meses perdidos. El cron genera sólo el mes en curso; si el worker no corre el día 1 (deploy, caída de Redis), ese mes no se factura y hay que encolar el job a mano con el período. Nada detecta ni reporta el hueco.
- El cobro depende de que la suscripción tenga operacionOrigenId: el import matchea el idOperacion del archivo contra ese campo. El campo es opcional en el schema, así que una suscripción creada por migración o backfill sin operación de origen queda incobrable por esta vía, sin ningún error visible (el archivo simplemente reporta OPERACION_INEXISTENTE).
- No existe la baja del servicio social por sí sola. La suscripción sólo se desactiva dando de baja al socio entero: no hay pantalla ni acción para cancelar un servicio y dejar el resto vivo, y cancelar la operación de origen tampoco la revierte (la cancelación revierte el capital cooperativo, pero no toca la suscripción).
- El estado 'inactiva' es terminal en los hechos: no hay ningún código que reactive una suscripción. Un socio que pasa a 'incumplimiento' y después vuelve a 'activo' queda sin suscripción y sin manera de recuperarla desde la app.
- Un socio puede tener varias suscripciones (una por servicio social), pero Socio.valorCuota es un único campo que se pisa con el valor del último servicio activado. La exportación a INAES lee ese campo, así que informa sólo el último servicio contratado, no la suma de los activos.
- La integración de capital de la cuota de servicio depende de que Contabilidad haya marcado un gasto con el flag esIntegracionCapital y lo haya asociado al servicio social. Si el servicio se configura sin ese gasto, la suscripción se activa igual pero el socio no integra capital y nadie avisa.
- Los docs están desactualizados y contradicen al código: docs/cuota-social.md describe una única suscripción por socio (socioId @unique) y el valor de cuota tomado del plan comercial, cosas que ya no son ciertas; docs/servicio-social-plan.md propone un campo integracionCapital en ServicioSocial que no llegó al schema (la integración se resuelve por gastos). Sirven como contexto histórico, no como especificación.
- No hay mora ni punitorios sobre cuotas sociales: el job de cálculo de mora sólo recorre CuotaCredito. Una cuota social impaga no genera ningún cargo adicional.
- Todo el ABM del catálogo es exclusivo del rol admin. Ningún rol de negocio (comercial, cobranzas) puede siquiera ver el listado de servicios sociales, aunque los vendedores los venden a diario desde el asistente de alta.
Acceso, permisos e inhabilitación de CUIL
Este flujo cubre las dos compuertas transversales de IRIS. La primera es el acceso: nadie entra por su cuenta al negocio — un admin da de alta al usuario, ese usuario nace bloqueado (pendienteAprobacion) y sólo puede loguearse cuando un admin aprueba la solicitud de acceso; los cambios de rol siguen el mismo circuito de doble control, y todo lo que hace queda registrado en dos canales separados: la auditoría de datos (extensión de Prisma con before/after) y los eventos de autenticación. Alrededor hay un cinturón de seguridad: bloqueo por intentos fallidos, timeout por inactividad, contraseñas con vencimiento e historial, aceptación obligatoria de términos, impersonación con reautenticación y jobs diarios que inactivan y luego dan de baja a los usuarios que dejaron de entrar. La segunda compuerta es la inhabilitación de CUIL/CUIT: un padrón de personas —existan o no como socios— que frena en seco la originación de cualquier producto; cuando el motivo lo permite, el operador puede pedir la habilitación desde la pantalla bloqueada y el área responsable (Riesgo o Cobranzas) la resuelve en la Bandeja de Habilitación.
pendienteAprobacion y el login lo rechaza hasta que un admin aprueba la solicitud. Y el padrón de CUIL inhabilitados frena la originación de cualquier producto — cuando el motivo lo permite, el operador puede pedir la habilitación desde la misma pantalla bloqueada.Quién interviene
Qué lo dispara
- Un admin da de alta un usuario desde /usuarios/alta (camino oficial de incorporación).
- Autorregistro público en /registrar (crea la cuenta directo en better-auth, sin pasar por aprobación).
- Login en /iniciar-sesion: dispara los controles de cuenta pendiente, cuenta bloqueada y conteo de intentos fallidos.
- Cada request autenticado: withAuth + requireUserSession revalidan inactividad, vencimiento de contraseña y aceptación de términos.
- Un admin modifica los roles de un usuario en /usuarios/:id/editar (genera solicitud de cambio de rol).
- Cron diario 03:00 UTC del job inactivar-usuarios y 03:30 UTC del job eliminar-usuarios-baneados.
- Publicación de una nueva versión de términos en /terminos/alta con requiereAceptacion y fecha de vigencia.
- Alta de operación (/operaciones/alta-operacion): valida el CUIL contra las inhabilitaciones vigentes.
- Alta individual de inhabilitación en /inhabilitaciones/alta o import de un .xlsx en /inhabilitaciones/carga-masiva.
- El operador pide la habilitación desde la pantalla bloqueada de originación (/bandeja-habilitacion/solicitar/:inhabilitacionId).
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Da de alta al usuario: email, nombre, tipo (INTERNO/EXTERNO), DNI/CUIL, roles, sucursal primaria y secundarias u organizaciones, y días de expiración de la contraseña. La contraseña inicial es la variable de entorno AUTO_GENERATED_PASSWORD, igual para todos.
/usuarios/alta·En una transacción crea User con pendienteAprobacion=true, Account con providerId 'credential', HistorialContrasena, los UserRole, las UsuarioOrganizacion y el domicilio laboral por defecto. Cierra creando una SolicitudAcceso tipo 'alta_usuario' en estado 'pendiente'. Todo queda auditado por la extensión de Prisma.
Revisa la bandeja de solicitudes pendientes (el sidebar muestra un badge con el conteo).
/solicitudes-acceso/listado·Sólo lectura. Requiere SOLICITUDES_READ (admin y admin_organizacion); el botón de resolver requiere SOLICITUDES_APPROVE, que hoy sólo tiene admin.
Aprueba la solicitud de alta.
/solicitudes-acceso/:id/aprobar·Transacción: User.pendienteAprobacion pasa a false y la SolicitudAcceso pasa a 'aprobada' con aprobadorId y resolvedAt. Recién ahí el usuario puede loguearse.
Alternativa: rechaza la solicitud escribiendo un motivo obligatorio.
/solicitudes-acceso/:id/rechazar·Transacción: si era 'alta_usuario' ELIMINA físicamente al User (cascada sobre cuenta, historial de contraseñas, roles y organizaciones) y deja la solicitud en 'rechazada' con motivoRechazo.
Inicia sesión con email y contraseña.
/iniciar-sesion·Antes de llamar a better-auth verifica pendienteAprobacion y lockedUntil. Si falla, incrementa failedLoginAttempts y a los 5 setea lockedUntil a 30 minutos. Registra EventoAutenticacion 'login_fallido'; el hook de better-auth registra 'login_exitoso' al crear la Session y resetea el contador.
Desbloquea manualmente una cuenta trabada por intentos fallidos.
/usuarios/:id/desbloquear·Pone failedLoginAttempts en 0 y lockedUntil en null. Queda auditado como update de User.
Cada request autenticado pasa por los controles de sesión.
(todas las rutas protegidas)·Refresca Session.lastActivityAt; si pasaron más de 60 minutos (SESSION_INACTIVITY_TIMEOUT_MINUTES) borra la sesión y redirige a /iniciar-sesion?reason=inactividad. Después chequea passwordExpiresAt y la aceptación de la versión vigente de términos, redirigiendo en cada caso.
Cambia la contraseña, forzado por vencimiento o por decisión propia.
/perfil/cambiar-contrasena·Valida la contraseña actual, exige 10+ caracteres con mayúscula, minúscula, número y símbolo, y rechaza cualquier hash reutilizado de los últimos 6 meses. Transacción: actualiza Account.password, recalcula passwordExpiresAt, guarda el hash en HistorialContrasena y BORRA todas las sesiones del usuario. Registra EventoAutenticacion 'cambio_contrasena' y obliga a loguear de nuevo.
Acepta la versión vigente de los términos y condiciones.
/terminos/aceptar·Crea TerminosAceptacion con userId, versión, fecha, IP y user agent, y registra EventoAutenticacion 'terminos_aceptados'. Recién ahí el usuario llega a la pantalla que quería.
Opera dentro del sistema según sus roles, permisos y organizaciones asignadas.
/·Cada loader/action llama a requireUserWithPermission / requireUserWithAnyRole. El alcance por sucursal se resuelve con las UsuarioOrganizacion y el árbol de organizaciones (app/lib/auth/operaciones-scope.ts). Toda escritura sobre modelos auditados deja AuditLog con before/after.
Modifica los roles de un usuario existente (requiere confirmar su propia contraseña).
/usuarios/:id/editar·Los datos personales y las organizaciones se guardan de inmediato, pero los roles NO: se crea una SolicitudAcceso tipo 'cambio_rol' en 'pendiente' con previousRoleIds y newRoleIds. Al aprobarla, otro admin aplica altas y bajas de UserRole con AuditLog 'asignar_rol' / 'revocar_rol'.
Asigna un usuario comercial o de riesgo a una organización, zonal o zonal2, con acceso a todos los convenios o a una selección.
/usuario-organizaciones/alta·Crea UsuarioOrganizacion (y UsuarioOrganizacionConvenio si el acceso es selectivo). Define qué operaciones ve el usuario en la bandeja y en el alta.
Impersona a un usuario para reproducir un problema, confirmando su propia contraseña.
/usuarios/:id/impersonar·Llama a auth.api.impersonateUser y reemplaza la cookie de sesión. Deja AuditLog operation 'impersonar'. Mientras dura, withAuth sigue atribuyendo las escrituras al admin real. Se corta con POST a /impersonacion/terminar.
Corren los crons de higiene de usuarios.
03:00 UTC: los usuarios sin actividad hace 90 días quedan banned con banReason 'Inactividad automática' (AuditLog 'inactivar_automatico'). 03:30 UTC: los banned con updatedAt de más de 30 días reciben deletedAt (AuditLog 'eliminar_automatico'), lo que además los saca de cualquier sesión activa.
Consulta la auditoría de datos, el historial de un registro puntual y los eventos de autenticación.
/auditoria/listado, /auditoria/historial, /auditoria/autenticacion·Sólo lectura sobre AuditLog y EventoAutenticacion, con filtros por tipo, email, IP y rango de fechas. Las tres pantallas exigen rol admin explícito.
Define los motivos de inhabilitación: código, nombre, área responsable (RIESGO o COBRANZAS) y si el motivo admite solicitud automática de habilitación (el asterisco del PDD).
/motivos-inhabilitacion/alta·Crea MotivoInhabilitacion. El areaResponsable define el ruteo de las futuras solicitudes y permiteSolicitudAutomatica define si el operador tiene salida desde la pantalla bloqueada.
Registra una inhabilitación individual de CUIL/CUIT con motivo, fecha de inicio y observaciones.
/inhabilitaciones/alta·Crea InhabilitacionCuil con estado 'vigente', origen 'individual', areaOrigen copiada del motivo y socioId vinculado si el CUIL ya existe como Socio. Rechaza el alta si ya hay una vigente para ese CUIL y ese motivo.
Alternativa masiva: sube un .xlsx con columna CUIL o CUIT y una columna Observación opcional, eligiendo un único motivo para todo el archivo.
/inhabilitaciones/carga-masiva·En una transacción crea InhabilitacionMasiva ('procesado'), un InhabilitacionMasivaDetalle por fila con resultado 'exitoso' o 'error' (motivos formato_invalido, duplicado, ya_inhabilitado) y una InhabilitacionCuil con origen 'masiva' por cada fila válida. El historial queda en /inhabilitaciones/cargas-masivas.
Consulta el padrón de inhabilitados con filtros y lo exporta a Excel.
/inhabilitaciones/listado, /inhabilitaciones/export·Cada consulta y cada exportación dejan rastro en AuditLog con entityType 'InhabilitacionCuilConsulta' y operation CONSULTA o EXPORTACION, guardando filtros aplicados y cantidad de registros (requisito 6.13 del PDD).
Intenta originar una operación para un socio.
/operaciones/alta-operacion·Antes de armar la operación busca una InhabilitacionCuil 'vigente' para el CUIL del socio. Si existe, corta el alta y devuelve el mensaje de bloqueo con motivo, fecha y área, más el flag de si el motivo admite solicitud y si ya hay una solicitud bloqueante.
Desde la pantalla bloqueada, genera la solicitud de habilitación indicando el producto solicitado, observaciones y adjuntando documentación.
/bandeja-habilitacion/solicitar/:inhabilitacionId·Crea SolicitudHabilitacionCuil en 'pendiente' con areaAsignada derivada del motivo y, si corresponde, el vínculo a la operación bloqueada. Los archivos suben a S3 y quedan en SolicitudHabilitacionDocumento; un error de subida no invalida la solicitud.
Trabaja la Bandeja de Habilitación filtrada por su área y abre la solicitud.
/bandeja-habilitacion/listado, /bandeja-habilitacion/:id·El listado sólo muestra solicitudes cuya areaAsignada está entre las áreas del usuario; el detalle rechaza con 403 si la solicitud es de otra área. El detalle reconstruye el historial leyendo AuditLog de la propia solicitud.
Resuelve la solicitud: aprobar, rechazar o solicitar documentación adicional, siempre con observaciones.
/bandeja-habilitacion/:id·aprobar: transacción que pasa InhabilitacionCuil a 'habilitado' con habilitadoPorId y fechaHabilitacion, y la solicitud a 'aprobada' — la originación queda liberada. rechazar: solicitud a 'rechazada', el CUIL sigue bloqueado y ya no admite nuevas solicitudes. solicitar_docs: solicitud a 'docs_solicitadas', sigue bloqueando.
Vía alternativa: habilita manualmente un CUIL sin pasar por solicitud, dejando observaciones obligatorias.
/inhabilitaciones/:id/habilitar·InhabilitacionCuil pasa de 'vigente' a 'habilitado' con habilitadoPorId, fechaHabilitacion y observacionesHabilitacion. Es la única salida cuando el motivo no admite solicitud automática o cuando la solicitud fue rechazada.
Estados
- pendiente
- SolicitudAcceso recién creada (alta de usuario o cambio de roles). Es la que cuenta el badge del sidebar y bloquea el login del usuario objetivo.
- aprobadafinal
- SolicitudAcceso resuelta a favor: si era alta_usuario habilita el login; si era cambio_rol aplica altas y bajas de roles.
- rechazadafinal
- SolicitudAcceso rechazada con motivo obligatorio. En alta_usuario además elimina físicamente al usuario creado.
- alta_usuario
- Valor de SolicitudAcceso.tipo cuando el admin crea un usuario nuevo.
- cambio_rol
- Valor de SolicitudAcceso.tipo cuando se modifican los roles de un usuario existente.
- pendienteAprobacion
- Flag booleano de User. En true el usuario existe con credenciales pero el login lo rechaza con 'Credenciales inválidas'.
- lockedUntil
- Campo de User con la fecha hasta la que la cuenta queda bloqueada por intentos fallidos. Se limpia al login exitoso o por desbloqueo manual del admin.
- banned
- Flag de User que deja el job inactivar-usuarios con banReason 'Inactividad automática'.
- deletedAtfinal
- Baja lógica de User. La setea el job eliminar-usuarios-baneados; a partir de ahí la sesión deja de resolver al usuario.
- vigente
- Estado de InhabilitacionCuil: el CUIL/CUIT está bloqueado y frena la originación de cualquier producto.
- habilitadofinal
- Estado final de InhabilitacionCuil: se levantó el bloqueo, sea manualmente o por aprobación de una solicitud.
- pendiente
- SolicitudHabilitacionCuil recién generada por el operador, esperando resolución del área asignada. Bloquea generar otra solicitud para la misma inhabilitación.
- docs_solicitadas
- SolicitudHabilitacionCuil a la que el área le pidió documentación adicional. Sigue bloqueando la originación y también impide una solicitud nueva.
- aprobadafinal
- SolicitudHabilitacionCuil aprobada: en la misma transacción la InhabilitacionCuil pasa a habilitado.
- rechazadafinal
- SolicitudHabilitacionCuil rechazada: el CUIL sigue inhabilitado y además queda sin poder generar una nueva solicitud.
- procesadofinal
- Estado con el que nace toda InhabilitacionMasiva (la carga del .xlsx se resuelve sincrónicamente en una transacción).
- exitosofinal
- Resultado de una fila de InhabilitacionMasivaDetalle que efectivamente generó una inhabilitación.
- errorfinal
- Resultado de una fila rechazada de la carga masiva, con motivoRechazo formato_invalido, duplicado o ya_inhabilitado.
Reglas que el sistema hace cumplir
- Un usuario creado por admin no puede loguearse hasta que otro admin apruebe su solicitud: el login corta si pendienteAprobacion es true y devuelve 'Credenciales inválidas' sin revelar el motivo (app/routes/login.tsx:57-72; el flag se setea en app/routes/user-management/create.tsx:98 y se limpia en app/routes/solicitudes-acceso/aprobar.tsx:33-36).
- Toda alta de usuario genera una SolicitudAcceso tipo 'alta_usuario' en estado 'pendiente' dentro de la misma transacción que crea el usuario (app/routes/user-management/create.tsx:182-198).
- Rechazar una solicitud de alta elimina físicamente al usuario creado, con cascada sobre cuenta, historial de contraseñas, roles y organizaciones. El motivo de rechazo es obligatorio (app/routes/solicitudes-acceso/rechazar.tsx:40-42 y 58-61).
- Los cambios de roles no se aplican al guardar: quedan como SolicitudAcceso tipo 'cambio_rol' pendiente y se materializan recién al aprobarse, con AuditLog 'asignar_rol' y 'revocar_rol' por cada rol (app/routes/user-management/update.tsx:241-254; app/routes/solicitudes-acceso/aprobar.tsx:45-79).
- Nadie puede aprobar ni rechazar su propia solicitud de cambio de rol (app/routes/solicitudes-acceso/aprobar.tsx:25-27 y app/routes/solicitudes-acceso/rechazar.tsx:52-54). Este control NO existe para las solicitudes de alta de usuario.
- A los 5 intentos fallidos (FAILED_LOGIN_ATTEMPTS_LIMIT) la cuenta queda bloqueada 30 minutos (ACCOUNT_LOCKOUT_DURATION_MINUTES) y se informa la fecha de desbloqueo (app/routes/login.tsx:9-10, 74-99 y 107-119).
- La sesión se cierra por 60 minutos de inactividad (SESSION_INACTIVITY_TIMEOUT_MINUTES): se borra la Session y se redirige a /iniciar-sesion?reason=inactividad (app/lib/auth/session.server.ts:11 y 75-92).
- Si passwordExpiresAt ya pasó, cada request redirige a /perfil/cambiar-contrasena?reason=expirada, salvo en la propia pantalla de cambio y en la de términos para evitar el bucle (app/lib/auth/session.server.ts:98-108).
- Si existe una ConfiguracionTerminos con requiereAceptacion y vigenteDe menor o igual a hoy y el usuario no aceptó esa versión, todo request lo desvía a /terminos/aceptar (app/lib/auth/session.server.ts:110-125). La aceptación guarda IP y user agent (app/routes/terminos/aceptar.tsx:51-70).
- La contraseña exige 10 caracteres como mínimo, con mayúscula, minúscula, número y carácter especial (app/lib/auth/password-validation.ts:3-9).
- No se puede reutilizar ninguna contraseña usada en los últimos 6 meses; se compara contra HistorialContrasena (app/routes/perfil/cambiar-contrasena.tsx:68-80).
- Al cambiar la contraseña se borran TODAS las sesiones activas del usuario en la misma transacción (app/routes/perfil/cambiar-contrasena.tsx:106).
- Eliminar un usuario, modificarlo o impersonarlo exige reautenticación con la contraseña del propio admin (app/routes/user-management/delete.tsx:51-58; app/routes/user-management/update.tsx:168-175; app/routes/user-management/impersonar.server.ts:19-22).
- Un admin no puede eliminarse a sí mismo (app/routes/user-management/delete.tsx:44-46).
- El rol admin recibe automáticamente todos los permisos declarados: ROLE_PERMISSIONS[admin] = Object.values(PERMISSIONS) (app/lib/auth/authorization.ts:246).
- Mientras dura una impersonación, la auditoría atribuye las escrituras al admin impersonador y no al usuario suplantado (app/lib/auth/session.server.ts:140), y las sesiones impersonadas se excluyen del registro de login/logout (app/lib/auth/better-auth.server.ts:26 y 44).
- AuditLog nunca guarda credenciales: Account e HistorialContrasena están excluidos de AUDITED_MODELS por decisión explícita y los campos password, token, apiKey y secret se reemplazan por [REDACTED] (app/lib/db.server.ts:145-153).
- El job de inactivación no toca usuarios ya baneados, borrados ni pendientes de aprobación, y considera inactivo tanto al que nunca abrió sesión como al que no tiene actividad desde hace INACTIVIDAD_DIAS, default 90 (app/jobs/worker.ts:379-403).
- El borrado automático es baja lógica (deletedAt) y sólo alcanza a usuarios banned con más de DIAS_GRACIA_ELIMINACION, default 30, sin cambios (app/jobs/worker.ts:428-444).
- No se puede originar ningún producto para un CUIL con una InhabilitacionCuil en estado 'vigente'; se toma la más reciente por fechaInicio (app/lib/inhabilitaciones/bloqueoOriginacion.ts:22-32; corte en app/routes/operaciones/alta-operacion/actions.ts:274-284 y 432-442).
- El operador sólo puede pedir la habilitación desde la pantalla bloqueada si el motivo tiene permiteSolicitudAutomatica en true (app/lib/inhabilitaciones/solicitudes.server.ts:57-59; el botón se muestra en app/routes/operaciones/alta-operacion/components/ValidarPlanComercial.tsx:354-381).
- No se puede generar una nueva solicitud si ya existe una en 'pendiente', 'docs_solicitadas' o 'rechazada' para esa misma inhabilitación: el rechazo bloquea para siempre la vía automática (app/lib/inhabilitaciones/bloqueoOriginacion.ts:4 y app/lib/inhabilitaciones/solicitudes.server.ts:61-67).
- El ruteo de la solicitud es automático: areaAsignada se copia del areaResponsable del motivo y no se puede elegir (app/lib/inhabilitaciones/solicitudes.server.ts:76).
- Un usuario sólo ve y resuelve solicitudes de sus áreas: admin ve COBRANZAS y RIESGO, los roles de área ven la suya; el detalle devuelve 403 si la solicitud es de otra área (app/lib/inhabilitaciones/solicitudes.server.ts:24-31; app/routes/bandeja-habilitacion/view.tsx:62-64 y 108-110).
- Aprobar la solicitud habilita el CUIL en la misma transacción: InhabilitacionCuil pasa a 'habilitado' y la solicitud a 'aprobada' (app/lib/inhabilitaciones/solicitudes.server.ts:151-170). Una solicitud ya aprobada o rechazada no se puede volver a resolver (líneas 111-113).
- No se puede cargar dos veces la misma inhabilitación: se rechaza si ya existe una vigente para ese CUIL y ese motivo (app/routes/inhabilitaciones/create.tsx:51-57 y app/lib/inhabilitaciones/carga-masiva.server.ts:100-102).
- La carga masiva descarta filas por tres causales tipificadas: formato_invalido (no son 11 dígitos), duplicado (repetido dentro del archivo) y ya_inhabilitado (app/lib/inhabilitaciones/carga-masiva.server.ts:92-104 y app/lib/inhabilitaciones/carga-masiva-consts.ts:1-13).
- Cada consulta y cada exportación del padrón de inhabilitados deja rastro con usuario, filtros y cantidad de registros, bajo entityType 'InhabilitacionCuilConsulta' (app/lib/inhabilitaciones/consultaLog.ts:17-27; invocado en app/routes/inhabilitaciones/list.tsx:44 y app/routes/inhabilitaciones/export.tsx:27).
Dónde vive en el código
app/routes.tsapp/lib/auth/session.server.tsapp/lib/auth/better-auth.server.tsapp/lib/auth/authorization.tsapp/lib/auth/roles.tsapp/lib/auth/password-validation.tsapp/lib/auth/provision-better-auth-user.server.tsapp/lib/db.server.tsapp/lib/audit-context.server.tsapp/routes/login.tsxapp/routes/register.tsxapp/routes/logout.tsxapp/routes/user-management/create.tsxapp/routes/user-management/update.tsxapp/routes/user-management/delete.tsxapp/routes/user-management/list.tsxapp/routes/user-management/view.tsxapp/routes/user-management/desbloquear.tsxapp/routes/user-management/impersonar.server.tsapp/routes/impersonacion/terminar.server.tsapp/routes/solicitudes-acceso/list.tsxapp/routes/solicitudes-acceso/aprobar.tsxapp/routes/solicitudes-acceso/rechazar.tsxapp/routes/usuario-organizaciones/create.tsxapp/routes/perfil/cambiar-contrasena.tsxapp/routes/terminos/alta.tsxapp/routes/terminos/aceptar.tsxapp/routes/audit-log/list.tsxapp/routes/audit-log/record.tsxapp/routes/audit-log/auth-events.tsxapp/jobs/worker.tsapp/jobs/queue.tsapp/lib/inhabilitaciones/bloqueoOriginacion.tsapp/lib/inhabilitaciones/solicitudes.server.tsapp/lib/inhabilitaciones/carga-masiva.server.tsapp/lib/inhabilitaciones/carga-masiva-consts.tsapp/lib/inhabilitaciones/consultaLog.tsapp/lib/inhabilitaciones/buildInhabilitacionWhereClause.tsapp/routes/inhabilitaciones/create.tsxapp/routes/inhabilitaciones/list.tsxapp/routes/inhabilitaciones/habilitar.tsxapp/routes/inhabilitaciones/carga-masiva.tsxapp/routes/inhabilitaciones/export.tsxapp/routes/bandeja-habilitacion/list.tsxapp/routes/bandeja-habilitacion/solicitar.tsxapp/routes/bandeja-habilitacion/view.tsxapp/routes/motivos-inhabilitacion/create.tsxapp/routes/operaciones/alta-operacion/actions.tsapp/routes/operaciones/alta-operacion/components/ValidarPlanComercial.tsxapp/layout/AppSidebar.tsxprisma/schema.prismadocs/plan-seguridad-autenticacion.mddocs/plan-migracion-better-auth.mddocs/plan-inhabilitacion-cuil.mdA tener en cuenta
- El autorregistro público /registrar sigue vivo y NO pasa por el circuito de aprobación: llama directo a auth.api.signUpEmail, deja pendienteAprobacion en su default false y devuelve la cookie de sesión (app/routes/register.tsx:71-93). El usuario queda sin roles, pero autenticado. Es la puerta trasera del modelo de acceso: si no se usa, conviene apagar la ruta.
- Combinado con lo anterior: /usuarios/listado sólo exige sesión, no permiso (app/routes/user-management/list.tsx:36 usa requireUserSession en vez de requireUserWithPermission(USERS_MANAGE)). Cualquier usuario autenticado —incluido uno autorregistrado sin roles— puede listar el padrón de usuarios con sus emails y roles. Es el hallazgo más serio del dominio.
- Segregación de funciones incompleta: el bloqueo de auto-aprobación sólo aplica a las solicitudes de tipo cambio_rol (app/routes/solicitudes-acceso/aprobar.tsx:25-27). Un admin puede crear un usuario y aprobar su propia solicitud de alta_usuario sin ningún segundo par de ojos.
- Convivencia de dos criterios de baja: el borrado manual desde /usuarios/:id/eliminar es un DELETE físico con cascada (app/routes/user-management/delete.tsx:71-73), mientras el job automático hace baja lógica con deletedAt. El rechazo de una solicitud de alta también borra físicamente al usuario. Se pierde trazabilidad salvo por lo que quede en AuditLog.
- El código de la app nunca chequea el flag banned al iniciar sesión ni al resolver la sesión: login.tsx sólo mira pendienteAprobacion y lockedUntil, y fetchAuthenticatedUser sólo filtra deletedAt. La expulsión efectiva del usuario inactivado depende enteramente del plugin admin de better-auth. Vale confirmarlo con una prueba de humo antes de asumir que 'banned' cierra la puerta.
- El evento 'terminos_aceptados' se escribe en EventoAutenticacion (app/routes/terminos/aceptar.tsx:64) pero no está en el comentario del modelo (prisma/schema.prisma:1210) ni en el diccionario TIPO_LABELS de la pantalla /auditoria/autenticacion (app/routes/audit-log/auth-events.tsx:17-22): en la grilla se muestra el literal crudo, sin etiqueta legible.
- Existe el permiso AUDIT_LOGS_READ pero ninguna ruta lo usa: las tres pantallas de /auditoria exigen el rol admin de forma directa (requireUserWithAnyRole([ROLES.ADMIN])). El permiso está declarado y muerto.
- La impersonación se audita a medias: el inicio deja AuditLog con operation 'impersonar', pero /impersonacion/terminar no registra nada y las sesiones impersonadas se excluyen a propósito de EventoAutenticacion (app/lib/auth/better-auth.server.ts:26). El punto fuerte es que withAuth atribuye todas las escrituras al admin real y no al usuario suplantado (session.server.ts:140).
- docs/plan-inhabilitacion-cuil.md está desactualizado: dice que la solicitud de habilitación genera además un Ticket espejo en el CRM y que la documentación va por TicketDocumento. En el código el campo ticketId de SolicitudHabilitacionCuil nunca se escribe (queda comentado como 'reservado para futura vinculación') y los adjuntos van a un modelo propio, SolicitudHabilitacionDocumento. La integración con el CRM está pendiente.
- InhabilitacionMasiva declara el estado 'procesando' pero nunca se usa: la carga se resuelve sincrónicamente dentro de una transacción y el registro nace ya en 'procesado'. Con archivos grandes esto va a timeoutear: es candidato natural a job en cola.
- El estado 'originacion' del campo origen de InhabilitacionCuil está declarado en el schema pero ningún código lo escribe; hoy sólo se generan inhabilitaciones con origen 'individual' o 'masiva'.
- El rechazo de una solicitud de habilitación es una vía muerta permanente: 'rechazada' queda en SOLICITUD_ESTADOS_BLOQUEANTES, así que el CUIL no puede volver a pedir habilitación nunca más por la vía automática. La única salida es la habilitación manual desde /inhabilitaciones/:id/habilitar por alguien con INHABILITACIONES_WRITE. Vale validar con negocio si esa irreversibilidad es intencional.
- El gate de inhabilitación se aplica sólo en el alta de operación (dos puntos de app/routes/operaciones/alta-operacion/actions.ts). No hay revalidación en la aprobación de la operación: si el CUIL se inhabilita entre el alta y la aprobación, la operación sigue su curso.
- El flujo no manda un solo email ni genera notificaciones in-app: el usuario nuevo no se entera de que lo aprobaron, el operador no se entera de que le aprobaron o rechazaron la habilitación, y el área no recibe aviso de solicitudes nuevas. El único empujón es el badge de solicitudes pendientes en el sidebar, y sólo para admin. app/lib/email existe pero no participa de este dominio.
- La contraseña inicial de todo usuario creado por admin es la misma variable de entorno AUTO_GENERATED_PASSWORD, y encima se muestra en pantalla en el formulario de alta. Sin passwordExpirationDays cargado, esa contraseña compartida no vence nunca.
- El chequeo de reutilización de contraseña compara contra todo el historial de 6 meses con verifyPassword una por una, en serie, dentro del request. Con muchos cambios acumulados la pantalla se va a poner lenta.
Motor asíncrono y documentos
IRIS corre un proceso aparte de la web —el *worker*— que se ocupa de todo el trabajo pesado que no puede hacerse mientras el usuario espera: leer con OCR la documentación adjunta, armar los formularios contractuales en PDF, ensamblar el legajo del cliente, mandar mails y correr los procesos programados. Las tareas se encolan en Redis con BullMQ y el worker las toma con reintentos automáticos. Cuatro procesos son diarios (mora a las 4, estadística de cobro a las 5, inactivar usuarios a las 3 y eliminar baneados a las 3:30) y dos son mensuales: la cuota social el día 1 a las 6 y la canasta básica del INDEC el día 15 a las 13. El eje del flujo es el ciclo de vida de un documento: se sube, va a S3, el OCR lo lee y completa datos del socio y de la operación, y cuando la operación se envía y se aprueba el sistema genera los formularios y los mergea con la documentación en un único legajo PDF.
force, para que el PDF final incluya la papelería recién generada.Quién interviene
Qué lo dispara
- Alta/adjunto de un documento en una operación (intent uploadFile) → encola job 'ocr'
- Envío de la operación para aprobación (transición ENVIAR) → encola job 'legajo' de la operación
- Aprobación de la operación → encola job 'formularios'; el job de formularios, al terminar bien, re-encola el legajo con force
- Botón 'generar/regenerar legajo' en la vista de operación (intent generar-legajo) → job 'legajo' con force
- Botón de legajo en la vista de socio → job 'legajo' tipo socio con force
- Generación de legajos de una venta de cartera (/ventas-cartera/:id/legajos/generar) → un job 'legajo' tipo venta-cartera por operación aceptada
- Aprobación de crédito con socio que tiene email → job 'email' plantilla credit-approved
- Alta y baja de socio → job 'email' plantillas socio-created / socio-removed
- Cron diario 03:00 UTC → 'inactivar-usuarios'; 03:30 UTC → 'eliminar-usuarios-baneados'; 04:00 UTC → 'calculos-mora'; 05:00 UTC → 'estadisticas-cobro'
- Cron mensual: día 1 a las 06:00 → 'cuota-social'; día 15 a las 13:00 → 'canasta-basica'
- Disparo manual de recálculo de estadística de cobro (/estadisticas-cobro/recalcular, permiso estadisticas_cobro:recalcular)
- Release de Heroku → npm run notify-deploy:prod → notificaciones in-app a los admins
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Adjunta un documento del cliente en el paso de documentación del alta de operación, eligiendo el tipo (dni_frente, dni_dorso, recibo_sueldo, certificado_haberes, servicio, constancia_cbu) y opcionalmente desactivando el OCR.
/operaciones/:operacionId/alta-operacion·Sube el archivo a S3 bajo la clave operations/<tipo>/<nombre>_<timestamp>_<random>.<ext> y crea la fila DocumentoOperacion con ocrStatus 'pending'.
Encola el job 'ocr' con el id del documento, la clave de S3 y el tipo. Si el encolado falla se loguea el error pero la subida se da por buena.
/operaciones/:operacionId/alta-operacion·Aparece un job en la cola Redis 'ocr' con hasta 3 intentos y backoff exponencial desde 2 segundos.
Toma el job (concurrencia 3), marca el documento como en proceso y baja el archivo de S3. Si es un PDF lo convierte a imagen a 300 DPI.
DocumentoOperacion.ocrStatus pasa a 'processing'.
Para dni_frente, dni_dorso, recibo_sueldo, certificado_haberes y servicio manda la imagen a OpenAI (gpt-4o) con un prompt específico por tipo de documento que devuelve JSON. Para constancia_cbu usa Tesseract, primero sin preprocesado y, si la validación falla, un segundo intento con la imagen preprocesada.
Devuelve texto crudo más un objeto de datos extraídos (CUIL, apellido y nombre, fecha de nacimiento, sueldo bruto, legajo, período de haberes, monto máximo afectable, domicilio, CBU según el tipo).
Valida los datos extraídos y, si son válidos, los cruza contra lo cargado a mano: completa o corrige Socio, DatosLaborales y datos de la operación, y registra discrepancias.
Escribe en Socio / DatosLaborales / Operacion (campos errores y validacionesExitosasOcr) y fija DocumentoOperacion.ocrStatus en 'completed', 'partial_success' o 'failed'.
Mientras el documento está 'pending' o 'processing', la pantalla consulta el estado cada 3 segundos, hasta 5 veces, y muestra el semáforo por documento.
/operaciones/:operacionId/alta-operacion·Si tras 5 consultas sigue sin resolverse, la UI muestra el estado local 'failed to verify' y pide refrescar la página. No modifica la base.
Envía la operación para aprobación una vez completada la documentación.
/operaciones/:operacionId/alta-operacion·Transiciona la operación a 'esperando_aprobacion' y encola el job 'legajo' de la operación. Si Redis está caído, genera el legajo en línea como plan B y, si eso también falla, avisa al usuario que reintente desde la vista de operación.
Aprueba la operación.
/operaciones/:operacionId/aprobar-operacion·Operación en estado 'aprobado', se encola el job 'formularios' y, si la operación incluye préstamo y el socio tiene email, se encola un job 'email' con la plantilla credit-approved.
Resuelve qué plantillas activas aplican a la operación (por tipo CREDITO o SERVICIO y por coincidencia de organización, modalidad de cobro y plan comercial, tomando null como comodín) y genera un PDF por cada una.
Devuelve la lista de plantillas con su versión activa.
Toma el HTML de la versión activa, reemplaza las variables con los datos reales de la operación y del socio, incrusta las imágenes de la biblioteca como data URI, y renderiza el PDF con Chromium headless respetando tamaño de hoja y márgenes de la plantilla.
Sube el PDF a formularios/operacion/<operacionId>/<plantillaId>.pdf y hace upsert de FormularioGenerado (único por operación + plantilla).
Al terminar, si generó al menos un formulario, re-encola el legajo de la operación con force para que el PDF final incluya la papelería recién emitida.
Nuevo job 'legajo' con jobId único (force) que no puede ser descartado por colisión con un job previo.
Si ya existe legajo y no hay force, corta y no hace nada. Con force borra el archivo viejo de S3 y la fila, y regenera.
Idempotencia garantizada: un mismo pedido repetido no duplica legajos.
Junta los documentos de la operación y los del socio, los ordena por tipo (DNI frente, DNI dorso, recibo, certificado de haberes, servicio, CBU), suma los formularios generados y, si hay consulta BCRA cacheada, agrega la matriz de riesgo.
Descarta archivos que no sean PDF ni imagen (los loguea) y falla si no hay al menos un dni_frente o un recibo_sueldo.
Mergea todo en un único PDF con pdf-lib: copia las páginas de los PDF y convierte cada imagen en una página propia con margen.
Sube el resultado a legajos/operacion/<operacionId>/legajo.pdf e inserta la fila Legajo con tamaño y content type. Si otro job ganó la carrera, borra el archivo huérfano y se considera exitoso igual.
Descarga el legajo desde la vista de la operación o del socio.
/legajos/download?operacionId=… o ?socioId=…·Valida permiso, rol de bandeja de operaciones y alcance por organización, y redirige a una URL prefirmada de S3 válida 300 segundos. No deja copia del archivo en el servidor.
Toma el job de email, deja registro del intento y manda el mail por SendGrid con la plantilla correspondiente.
Crea EmailLog en 'pending' y lo actualiza a 'sent' con el message id de SendGrid o a 'failed' con el error. Hasta 5 intentos con backoff desde 3 segundos.
Crea el ticket eligiendo tipo, tema y concepto; el sistema busca el workflow configurado para esa combinación y arranca el ticket en su estado inicial.
/tickets/alta·Crea Ticket con estado y subestado iniciales del workflow, códigos BCRA de tema y concepto, y vencimiento de SLA; además crea el primer registro de historial.
Adjunta documentación respaldatoria al ticket.
/tickets/:id/documentos·Sube el archivo a tickets/<ticketId>/… en S3 y crea TicketDocumento. La descarga se hace con URL prefirmada de 1 hora.
Ejecuta una transición del workflow; el motor valida permisos, guardas de datos y de cantidad mínima de documentos adjuntos, y exige clasificación cuando corresponde.
/tickets/:id/transicion·Actualiza estado, subestado y clasificación del ticket, escribe TicketHistorialEstado y, si el subestado destino es de derivación, crea automáticamente el ticket derivado enlazado al original.
Tras cada deploy lee el CHANGELOG y notifica a todos los admins la última versión.
/notificaciones/listado·Crea una Notificacion por admin con tipo 'deploy' y dedupeKey por fecha de versión, de modo que un reintento del release no duplica avisos. Un error acá nunca tumba el deploy.
Estados
- pending
- Documento (DocumentoOperacion.ocrStatus) recién subido a S3, esperando que el worker tome el job de OCR.
- processing
- El worker de OCR tomó el documento y está descargándolo y leyéndolo.
- completedfinal
- La lectura fue exitosa y todos los campos controlados del documento validaron contra lo cargado en el sistema.
- partial_successfinal
- La lectura devolvió datos, pero algunos campos validaron y otros quedaron con discrepancia u omisión: el operador tiene que revisar.
- failedfinal
- No se pudo leer el documento o ningún campo controlado validó. También es el estado que queda si el job explota por excepción.
- failed to verifyfinal
- Estado que existe solo en la pantalla (no se guarda en base): la UI consultó 5 veces cada 3 segundos y el documento seguía sin resolverse; pide refrescar.
- already-existsfinal
- Resultado del job de legajo cuando ya había un legajo generado y no se pidió force: no se regenera nada.
- race-lostfinal
- Resultado del job de legajo cuando otro job insertó el legajo primero: se borra el archivo huérfano de S3 y se considera exitoso igual.
- sentfinal
- EmailLog: SendGrid aceptó el mail y devolvió su identificador de mensaje.
- abierto
- Estado inicial habitual de un Ticket recién creado, según lo defina el workflow del concepto.
- en_proceso
- Ticket en gestión; se abre en decenas de subestados según el workflow (en_analisis, pendiente_documentacion, en_validacion_legal, en_tesoreria, etc.).
- resuelto
- Ticket con resolución cargada (favorable, procedente, improcedente, reintegro_total, etc. según la clasificación del workflow).
- cerradofinal
- Ticket cerrado; en la mayoría de los workflows es estado final.
- derivado_reclamo
- Subestado de derivación: al llegar acá el sistema crea automáticamente un ticket nuevo de tipo RECLAMOS enlazado al original.
- derivado_reintegro
- Subestado de derivación: al llegar acá el sistema crea automáticamente un ticket nuevo de tipo REINTEGRO enlazado al original.
Reglas que el sistema hace cumplir
- Hay exactamente diez tipos de trabajo asíncrono: ocr, email, canasta-basica, cuota-social, calculos-mora, legajo, formularios, inactivar-usuarios, eliminar-usuarios-baneados y estadisticas-cobro (app/jobs/types.ts:4).
- Cada cola tiene su propia política de reintentos: OCR 3 intentos con backoff desde 2 s (app/jobs/queue.ts:32), email 5 intentos desde 3 s (app/jobs/queue.ts:55), legajo 5 intentos desde 5 s (app/jobs/queue.ts:170), formularios 3 intentos desde 5 s (app/jobs/queue.ts:193).
- La capacidad del worker está topeada por cola: OCR procesa 3 documentos en paralelo (app/jobs/worker.ts:48), email 5 con límite de 100 envíos por segundo por la cuota de SendGrid (app/jobs/worker.ts:52), legajo y formularios 2 (app/jobs/worker.ts:72 y app/jobs/worker.ts:76) y el resto de a uno.
- Agenda de los procesos automáticos: inactivar usuarios 03:00 UTC (app/jobs/queue.ts:488), eliminar usuarios baneados 03:30 UTC (app/jobs/queue.ts:509), cálculo de mora 04:00 UTC (app/jobs/queue.ts:304), estadística de cobro 05:00 UTC (app/jobs/queue.ts:337), cuota social el día 1 a las 06:00 (app/jobs/queue.ts:280) y canasta básica del INDEC el día 15 a las 13:00 (app/jobs/worker.ts:501).
- Todo documento nace en 'pending': la subida a S3 y la fila en base ocurren juntas y el OCR queda pendiente (app/lib/operations/uploadOperationDocument.ts:31).
- El OCR es opcional por documento y su encolado nunca hace fallar la carga: si Redis no responde se loguea y la subida se da por exitosa (app/routes/operaciones/[id]-alta-operacion/actions.ts:131).
- Cinco de los seis tipos de documento se leen con inteligencia artificial y no con OCR clásico: recibo_sueldo, certificado_haberes, servicio, dni_frente y dni_dorso (app/lib/ocr/index.ts:34). Solo constancia_cbu queda con Tesseract.
- La extracción por IA usa el modelo gpt-4o forzando respuesta JSON, con un prompt distinto por tipo de documento (app/lib/extractTextFromImage.ts:12).
- La lectura con Tesseract se intenta dos veces: primero sobre la imagen original y, si la validación falla, sobre una versión preprocesada (app/lib/ocr/index.ts:321).
- El worker de OCR ya no escribe el resultado final del documento: el bloque que marcaba 'completed'/'failed' y guardaba el texto crudo está comentado (app/jobs/worker.ts:119-150). Hoy el estado final lo fijan los mappers, y ocrRawText / ocrExtractedData / ocrProcessedAt quedan sin poblarse en el camino feliz.
- El estado 'partial_success' aparece cuando conviven campos validados y campos con error para el mismo documento (app/lib/ocr/mappers/reciboSueldo.ts:664; misma regla en dniFrente.ts:710, certificadoHaberes.ts:311 y servicio.ts:321).
- Para armar el legajo de una operación alcanza con que exista un dni_frente o un recibo_sueldo; sin ninguno de los dos, el job falla (app/lib/legajo/legajoDocumentRules.ts:4). Para el legajo de socio la exigencia es dni_frente o dni_dorso (app/lib/legajo/legajoDocumentRules.ts:9).
- El legajo respeta un orden fijo de documentos: DNI frente, DNI dorso, recibo de sueldo, certificado de haberes, servicio y constancia de CBU; lo que no esté en la lista va al final (app/lib/legajo/documentUtils.ts:4).
- Solo se incorporan al legajo archivos PDF o imagen; el resto se saltea con advertencia (app/lib/legajo/documentUtils.ts:23).
- El merge tiene un tope duro de 100 MB de material de origen; por encima el job falla con MAX_SIZE_EXCEEDED (app/lib/legajo/mergePdfs.ts:4).
- La generación del legajo es idempotente: si ya existe y no se pidió force, no se hace nada (app/lib/legajo/generarLegajoOperacion.ts:33). Con force se borra el archivo de S3 y la fila antes de regenerar.
- La matriz de riesgo se anexa al legajo solo si hay consulta BCRA cacheada; si falta o falla, el legajo se genera igual sin ella (app/lib/legajo/generarLegajoOperacion.ts:54).
- La descarga del legajo no expone el bucket: devuelve un redirect a una URL prefirmada de S3 que vive 300 segundos (app/routes/legajos/download.tsx:62), y valida además el alcance por organización o creador de la operación.
- El permiso legajos:gestionar lo tienen solo el rol admin y supervisor_area_operaciones_riesgo (app/lib/auth/authorization.ts:354).
- Al aprobar la operación se encolan los formularios pero ya no el legajo: el legajo se genera a pedido o por cascada del job de formularios (app/routes/operaciones/aprobar-operacion/actions.ts:584).
- Una plantilla aplica a la operación si está activa, es de tipo CREDITO o SERVICIO, y sus filtros de organización, modalidad de cobro y plan comercial coinciden o están vacíos (el vacío funciona como comodín). Además tiene que tener una versión marcada como activa (app/lib/formularios/resolverFormularios.server.ts:20).
- Si en la generación real falta el valor de una variable requerida, el PDF no se emite: se lanza VariableSinValorError (app/lib/formularios/variables/resolver.ts:86). En la vista previa, en cambio, se marca el faltante en amarillo y el PDF se genera igual.
- Un formulario descartado por variables faltantes no rompe el lote: se loguea como advertencia y se sigue con las demás plantillas; en cambio cualquier otro error hace fallar el job para que reintente (app/jobs/worker.ts:349 y app/jobs/worker.ts:372).
- Si se emitió al menos un formulario, el worker re-encola el legajo con force para que la papelería quede adentro del PDF final (app/jobs/worker.ts:365), usando un jobId único para que el pedido no se pierda contra un job de legajo ya encolado (app/jobs/queue.ts:369).
- Los formularios se renderizan con Chromium headless vía puppeteer-core, reutilizando una única instancia de navegador por proceso (app/lib/formularios/chromium.server.ts:3).
- Solo hay cuatro plantillas de email en el sistema: credit-approved, loan-paid-off, socio-created y socio-removed (app/lib/email/types.ts:5).
- El envío real de mails está apagado salvo que se prenda explícitamente: con SENDGRID_ENABLED distinto de 'true' el sistema loguea el mail, no lo manda y devuelve éxito con un identificador simulado (app/lib/email/index.ts:17 y app/lib/email/index.ts:76).
- Cada intento de envío deja rastro: se crea el EmailLog en 'pending' antes de enviar y se actualiza a 'sent' o 'failed' (app/jobs/worker.ts:183 y app/jobs/worker.ts:210).
- Las notificaciones in-app son data propia del usuario: la pantalla no exige permiso especial, solo sesión (app/routes/notificaciones/list.tsx:15). Un link solo se sigue si es interno (app/lib/notificaciones.server.ts:28).
- La notificación masiva a admins es idempotente por dedupeKey, garantizada además por índice único usuario + dedupeKey (app/lib/notificaciones.server.ts:132); el aviso de novedades post-deploy usa la fecha de la versión como clave (console/notify-deploy.ts:56).
- No se puede abrir un ticket para una combinación tipo + tema + concepto que no tenga workflow configurado (app/routes/tickets/create.tsx:95).
- El SLA del ticket se calcula en días hábiles al momento del alta, con 10 días como valor por defecto y excepciones puntuales de 5 días (app/lib/tickets/sla-rules.ts:26).
- Una transición puede exigir documentación adjunta: la guarda hasDocuments compara la cantidad real de adjuntos contra el mínimo (app/lib/tickets/workflow-machine.ts:211), y el botón de la pantalla queda deshabilitado si no se cumple.
- Los subestados de derivación crean automáticamente un ticket hijo del tipo escalado dentro de la misma transacción, heredando socio, tema y concepto (app/routes/tickets/transition.tsx:301 y app/lib/tickets/derivation.ts:13).
Dónde vive en el código
/Users/martin.long/Documents/work/rebl/iris/app/jobs/types.ts/Users/martin.long/Documents/work/rebl/iris/app/jobs/queue.ts/Users/martin.long/Documents/work/rebl/iris/app/jobs/worker.ts/Users/martin.long/Documents/work/rebl/iris/worker/index.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/redis.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/s3/index.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ocr/index.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ocr/preprocessing.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ocr/mappers/index.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ocr/mappers/reciboSueldo.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ocr/mappers/dniFrente.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/ocr/mappers/dniDorso.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/extractTextFromImage.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/openai/client.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/operations/uploadOperationDocument.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-alta-operacion/actions.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-alta-operacion/components/DocumentacionAdjunta.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/aprobar-operacion/actions.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/legajo/generarLegajoOperacion.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/legajo/generarLegajoSocio.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/legajo/selectLegajoDocuments.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/legajo/legajoDocumentRules.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/legajo/documentUtils.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/legajo/mergePdfs.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/legajos/download.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/ventas-cartera/legajos/generar.tsx/Users/martin.long/Documents/work/rebl/iris/app/lib/formularios/resolverFormularios.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/formularios/emitirFormularioGenerado.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/formularios/generarFormularioPdf.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/formularios/chromium.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/formularios/variables/resolver.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/formularios/variables/contexto.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/formularios/imagenes/resolverImagenes.server.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/formularios/imagenes/uploadImagenFormulario.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/formularios/preview.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/operaciones/[id]-ver-operacion/formularios-preview.tsx/Users/martin.long/Documents/work/rebl/iris/app/lib/email/index.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/email/types.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/notificaciones.server.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/notificaciones/list.tsx/Users/martin.long/Documents/work/rebl/iris/console/notify-deploy.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tickets/workflow-machine.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tickets/constants.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tickets/derivation.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tickets/sla-rules.ts/Users/martin.long/Documents/work/rebl/iris/app/lib/tickets/documents.server.ts/Users/martin.long/Documents/work/rebl/iris/app/routes/tickets/create.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/tickets/transition.tsx/Users/martin.long/Documents/work/rebl/iris/app/routes/tickets/documentos.tsx/Users/martin.long/Documents/work/rebl/iris/prisma/schema.prisma/Users/martin.long/Documents/work/rebl/iris/docs/legajo-pdf-generation.md/Users/martin.long/Documents/work/rebl/iris/docs/plan-formularios-pdf-puppeteer.md/Users/martin.long/Documents/work/rebl/iris/docs/plan-admin-formularios.md/Users/martin.long/Documents/work/rebl/iris/docs/plan-notificaciones.md/Users/martin.long/Documents/work/rebl/iris/heroku.ymlA tener en cuenta
- El camino feliz del OCR quedó a medias: el bloque que guardaba el texto crudo, los datos extraídos y la fecha de proceso, y que marcaba el documento como completed o failed, está comentado en app/jobs/worker.ts:119-150. Hoy el estado final depende exclusivamente de que el mapper haya podido correr updateModels, y las columnas ocrRawText, ocrExtractedData y ocrProcessedAt quedan vacías salvo cuando el job explota. Consecuencia práctica: no hay trazabilidad de qué leyó el OCR cuando salió bien, y si un mapper no encuentra DatosLaborales previos el documento se queda en 'processing' para siempre.
- La variable isValid se calcula en el worker y no se usa para nada (app/jobs/worker.ts:117), residuo del bloque comentado.
- El envío de emails está apagado en todos los ambientes salvo que se setee SENDGRID_ENABLED=true, y cuando está apagado el servicio devuelve success con un identificador simulado: el EmailLog queda en 'sent' aunque no se haya enviado nada. Un product owner mirando el log de emails puede creer que el cliente recibió el aviso de crédito aprobado.
- El módulo de notificaciones está construido pero prácticamente sin usar: la única fuente real de notificaciones es el aviso de novedades post-deploy, y solo para admins. Ninguna parte del flujo operativo (legajo listo, formulario fallido, ticket vencido de SLA) genera notificación in-app.
- No hay ninguna pantalla de administración de colas: getQueueStats existe en app/jobs/queue.ts pero no está expuesta en ninguna ruta. Si un job de legajo o formularios falla tras agotar los reintentos, nadie se entera salvo mirando los logs del worker.
- El SLA de tickets se calcula al crear el ticket y se guarda en slaExpiresAt, pero no hay nada que lo vigile: existe el subestado sla_vencido en las constantes y ningún job ni cron lo aplica.
- La generación de formularios corre por operación completa: si una sola plantilla falla por un error técnico, el job entero se marca como fallido y se reintenta, volviendo a regenerar también las plantillas que ya habían salido bien (se salvan por el upsert, pero se paga el costo de render de nuevo).
- El legajo de socio genera los formularios de tipo ALTA_SOCIO en línea, dentro del mismo job, sin hard-stop de variables: un formulario con variables faltantes se emite igual con el placeholder visible. Es un criterio distinto al de la operación, donde ese mismo caso descarta el formulario.
- La resolución de plantillas aplicables usa los filtros como comodines independientes, sin ranking de especificidad: si hay una plantilla genérica y otra específica para el mismo plan comercial, se emiten las dos. No hay regla de 'gana la más específica'.
- Cada imagen de formulario se descarga de S3 y se incrusta como data URI en cada render, sin caché. Para plantillas con logos pesados y lotes grandes de operaciones esto multiplica el tráfico contra S3.
- El permiso legajos:gestionar es muy restrictivo (solo admin y supervisor de área Operaciones y Riesgo), pero la generación de formularios y el preview usan formularios:read, que solo tiene admin. Ningún rol operativo puede previsualizar la papelería de una operación.
- El tope de 100 MB del merge de legajos es un límite silencioso: cuando se supera, el job falla con un error técnico y el usuario solo ve que el legajo no aparece.
- La conversión de PDF a imagen para OCR escribe archivos temporales en el disco del worker y los borra al final; si el proceso muere en el medio quedan huérfanos.
- El worker regenera legajos con force borrando primero el archivo de S3: durante la ventana entre el borrado y la nueva subida, una descarga concurrente devuelve error.
- Los documentos de ticket se borran de S3 antes que de la base (app/lib/tickets/documents.server.ts): si el delete de base falla, queda una fila apuntando a un archivo inexistente.
Reclamos y consultas del socio
Es el circuito por el que IRIS recibe, clasifica, gestiona y resuelve todo reclamo, consulta, pedido de reintegro o gestión judicial que plantea un socio (o que llega por notificación de un organismo como Defensa del Consumidor o el BCRA). Cada caso se abre como un ticket clasificado en una terna tipo > tema > concepto, y esa terna determina automáticamente qué workflow de estados sigue: hay 25 workflows configurados (24 archivos, uno de ellos con dos variantes) con 229 transiciones en total, 4 estados y 84 subestados posibles. El motor es genérico: no hay código por reclamo, sino configuración declarativa que la aplicación arma en runtime, validando en cada paso permisos, documentación adjunta, datos obligatorios y clasificación del diagnóstico. Sirve además al reporte regulatorio: cada ticket guarda el código BCRA de tema y concepto, y una fecha de vencimiento de SLA en días hábiles.
Quién interviene
Qué lo dispara
- Alta manual del operador desde /tickets/alta (canal de origen 'manual', que el formulario fuerza con un campo oculto)
- Notificación regulatoria externa (Defensa del Consumidor, concurso de acreedores, reclamo por información crediticia incorrecta al BCRA) que el operador carga como ticket de tipo GESTION_JUDICIAL
- Derivación automática desde otro ticket: cuando una CONSULTA se resuelve con subestado 'derivado_reclamo' o 'derivado_reintegro', el sistema crea el ticket hijo dentro de la misma transacción
- Canal 'API' previsto en el catálogo de canales de origen, pero sin endpoint implementado hoy
Sistemas y procesos que toca
Detalle operativo
Paso a paso
Abre el alta de ticket y elige en cascada Tipo (RECLAMOS, REINTEGRO, CONSULTA, GESTION_JUDICIAL), Tema (CLIENTE, OPERACION, OPERACION_DIGITAL, INF_BASE_DATOS_BCRA, ATENCION_AL_CLIENTE, GESTION_ESTUDIO) y Concepto. Opcionalmente vincula el socio y describe el caso.
/tickets/alta·Todavía no escribe nada. Los combos se alimentan de la lista blanca de 24 combinaciones válidas.
Valida el formulario: campos obligatorios, que la terna tipo/tema/concepto esté en la lista blanca y que exista un workflow registrado para esa terna. Si no hay workflow, rechaza el alta.
/tickets/alta·Devuelve errores de validación al formulario sin persistir.
Crea el ticket en una transacción: lo posiciona en el estado inicial que declara el workflow (hoy siempre 'abierto' sin subestado), estampa el código BCRA de tema y de concepto, y calcula la fecha de vencimiento del SLA en días hábiles.
/tickets/alta·INSERT en tickets (estado, subestado, tema_bcra_cod, concepto_bcra_cod, sla_expires_at, socio_id, canal_origen) + INSERT en ticket_historial_estados con la observación 'Ticket creado'. El modelo Ticket está auditado. Redirige al detalle.
Trabaja el caso desde el detalle: adjunta documentación de respaldo (se sube a S3), deja comentarios internos y consulta el historial completo de estados, el ticket de origen y los tickets derivados.
/tickets/:id/ver·INSERT en ticket_documentos (archivo en S3) o en ticket_comentarios. Los comentarios quedan auditados; los documentos no.
Al entrar a la pantalla de gestión de estado, arma el workflow de esa terna en runtime y calcula las transiciones disponibles filtrando por el estado exacto actual (estado + subestado) y por los permisos del usuario. Si al usuario le falta el permiso, la opción directamente no se muestra.
/tickets/:id/transicion·Solo lectura. También cuenta los documentos adjuntos para evaluar las guardas documentales.
Elige una transición, completa los datos que exige (motivo de cierre cuando aplica), selecciona la clasificación del diagnóstico si la transición la requiere, tilda las condiciones de cierre y agrega observaciones.
/tickets/:id/transicion·El botón de ejecutar queda deshabilitado si falta documentación obligatoria.
Revalida todo del lado del servidor: que la transición exista, que el estado origen coincida con el estado real del ticket, que el usuario tenga los permisos exigidos, que se cumplan las guardas de documentos y de datos, y que haya clasificación cuando es obligatoria.
/tickets/:id/transicion·Si alguna validación falla, devuelve el mensaje de error configurado en la guarda y no modifica nada.
Ejecuta la transición en una transacción: actualiza estado y subestado del ticket, guarda la clasificación (una sola en modo simple, o reemplaza el conjunto completo en modo múltiple) y escribe el asiento de historial con estado y clasificación anterior y nueva, usuario y observaciones.
/tickets/:id/transicion·UPDATE tickets + INSERT ticket_historial_estados (+ DELETE/INSERT en ticket_clasificaciones en modo múltiple). Todo auditado salvo el historial.
Si el subestado destino es 'derivado_reclamo' o 'derivado_reintegro', crea automáticamente un ticket hijo vinculado dentro de la misma transacción, heredando socio, tema y concepto, cambiando solo el tipo de reclamo, arrancando en el estado inicial del workflow destino y con la descripción prefijada 'Derivado desde ticket #N'.
/tickets/:id/transicion·INSERT en tickets con ticket_origen_id apuntando al original. El hijo NO recibe fecha de SLA.
Cierra el caso con una transición de resolución que fija el resultado (favorable / no favorable, procedente / improcedente, reintegro total o parcial, rechazado, archivado, derivado a judicial, fraude confirmado, operación legítima, etc.).
/tickets/:id/transicion·El ticket queda en 'resuelto' con el subestado de resultado. En 24 de los 25 workflows no hay transición de salida desde 'resuelto': la resolución es irreversible desde la aplicación.
Monitorea la cartera desde el listado, con filtros por tipo, tema, estado, subestado y semáforo de SLA (en término, en riesgo, vencido, no aplica) y ordenamiento por vencimiento de SLA o tiempo en estado.
/tickets/listado·Solo lectura. El semáforo se calcula en memoria: vencido si la fecha pasó, en riesgo si faltan menos de 24 horas.
Da de baja un ticket cargado por error.
/tickets/:id/eliminar·Baja lógica: marca deleted_at. El ticket deja de aparecer en listados y vistas.
Estados
- abierto
- Estado inicial de todos los workflows. El caso está registrado y clasificado pero nadie lo tomó todavía.
- en_proceso
- Estado macro de gestión. Concentra la enorme mayoría de los subestados: análisis, pedidos de documentación, intervención de áreas y instancias legales.
- resueltofinal
- El caso tiene un resultado. El subestado indica cuál. Es el estado terminal real de casi todos los workflows.
- cerradofinal
- Cierre administrativo sin trámite o cierre posterior a la resolución. Solo 2 de los 24 archivos de workflow tienen alguna transición que llegue acá.
- en_analisis
- Subestado de en_proceso. Análisis del caso por atención al cliente. Es el nodo central desde el que salen casi todos los caminos.
- en_analisis_preliminar
- Subestado de en_proceso usado en posible fraude, antes de la investigación técnica.
- pendiente_documentacion
- Subestado de en_proceso. Se espera que el socio o el área aporte respaldo. Suspende el avance.
- pendiente_doc
- Variante del anterior usada en el workflow de baja de socio no efectuada.
- pendiente_cliente
- Subestado de en_proceso. Se le pidió al socio un comprobante (caso informa pago).
- pendiente_estudio
- Subestado de en_proceso. Se pidió informe al estudio externo de cobranza. Para avanzar hay que adjuntar el informe.
- medidas_preventivas
- Subestado de en_proceso. Se aplicaron medidas cautelares (suspender la gestión de cobro, frenar débitos) mientras se investiga.
- en_investigacion
- Subestado de en_proceso. Investigación del caso, típicamente sobre la conducta de un tercero.
- en_investigacion_tecnica
- Subestado de en_proceso. Investigación técnica de una operación digital sospechada de fraude.
- en_determinacion
- Subestado de en_proceso. Se cerró la investigación y se está definiendo el dictamen.
- en_estrategia_legal
- Subestado de en_proceso. Legales define la estrategia de defensa ante Defensa del Consumidor.
- contestacion_presentada
- Subestado de en_proceso. Se presentó el descargo ante el organismo.
- pendiente_audiencia
- Subestado de en_proceso. A la espera de la audiencia de conciliación.
- acuerdo_pendiente_cumplimiento
- Subestado de en_proceso. Se acordó en audiencia y falta ejecutar lo pactado.
- acuerdo_incumplido
- Subestado de en_proceso disponible en el catálogo para acuerdos que la contraparte no cumplió.
- pendiente_resolucion
- Subestado de en_proceso. No hubo acuerdo y se espera resolución del organismo.
- en_validacion_legal
- Subestado de en_proceso. Legales valida un reintegro antes de habilitar el ajuste contable.
- en_ajuste_contable
- Subestado de en_proceso. Contabilidad registra el asiento del reintegro.
- en_tesoreria
- Subestado de en_proceso. Tesorería ejecuta el pago del reintegro.
- en_analisis_tesoreria
- Subestado de en_proceso. Tesorería busca el pago que el socio dice haber hecho.
- en_analisis_cobranzas
- Subestado de en_proceso. Cobranzas revisa la imputación del pago informado.
- favorablefinal
- Subestado de resuelto. El reclamo se resolvió a favor del socio.
- no_favorablefinal
- Subestado de resuelto. El reclamo se rechazó con fundamento.
- procedentefinal
- Subestado de resuelto. Se comprobó la mala praxis denunciada (trato indigno de terceros).
- improcedentefinal
- Subestado de resuelto. La denuncia no se comprobó y se rehabilita la gestión.
- caso_dudosofinal
- Subestado de resuelto. No concluyente; se declara seguimiento del proveedor.
- reintegro_totalfinal
- Subestado de resuelto. Se devolvió el total del débito incorrecto. Exige comprobante de Tesorería adjunto.
- reintegro_parcialfinal
- Subestado de resuelto. Se devolvió una parte. Exige comprobante de Tesorería adjunto.
- rechazadofinal
- Subestado de resuelto. Se rechazó el pedido de reintegro con respaldo documental.
- derivado_legalfinal
- Subestado de resuelto. El reintegro se escaló a instancia legal.
- acuerdo_cumplidofinal
- Subestado de resuelto. Se cumplió el acuerdo de audiencia. Exige documentación de respaldo.
- resolucion_ejecutadafinal
- Subestado de resuelto. Se acató y ejecutó la resolución administrativa del organismo.
- archivadofinal
- Subestado de resuelto. El expediente se archivó (improcedente o desistido).
- derivado_judicialfinal
- Subestado de resuelto. El caso pasó a la vía judicial.
- fraude_confirmadofinal
- Subestado de resuelto. Se confirmó el fraude en la operación digital.
- operacion_legitimafinal
- Subestado de resuelto. Se descartó el fraude: la operación fue legítima.
- caso_parcialfinal
- Subestado de resuelto. Fraude parcialmente comprobado.
- consulta_respondidafinal
- Subestado de resuelto. La consulta se contestó formalmente al socio. Exige documento de respaldo.
- derivado_reclamofinal
- Subestado de resuelto que dispara la creación automática de un ticket hijo de tipo RECLAMOS.
- derivado_reintegrofinal
- Subestado de resuelto que dispara la creación automática de un ticket hijo de tipo REINTEGRO.
- regularizadofinal
- Subestado de resuelto. Se encontró e imputó el pago informado por el socio.
- pago_no_encontradofinal
- Subestado de resuelto. No se pudo verificar el pago informado.
Reglas que el sistema hace cumplir
- Solo se puede abrir un ticket con una de las 24 combinaciones tipo/tema/concepto habilitadas (app/lib/tickets/valid-combinations.ts:10-42), validadas tanto en los combos del formulario como en el servidor (app/routes/tickets/create.tsx:47-50).
- Además de la combinación válida, tiene que existir un workflow registrado para esa terna; si no, el alta se rechaza con 'No existe un workflow configurado para esta combinacion' (app/routes/tickets/create.tsx:93-102). El registro tiene 25 workflows (app/lib/tickets/index.ts:52-87).
- El estado inicial no lo elige el usuario: lo impone el workflow. Hoy los 25 workflows arrancan en 'abierto' sin subestado (app/routes/tickets/create.tsx:113-114).
- El SLA se calcula una única vez, al crear el ticket, sumando días hábiles a la fecha de alta (app/routes/tickets/create.tsx:120, app/lib/tickets/sla-rules.ts:35-37). La tabla de reglas define 5 días hábiles para falta de respuesta a estado de cuenta y para información crediticia incorrecta, y 10 días hábiles por defecto (app/lib/tickets/sla-rules.ts:10-27).
- Los códigos regulatorios BCRA se estampan al crear y nunca se recalculan: 6 códigos de tema (2 cliente, 3 operación, 4 operación digital, 5 base de datos BCRA, 6 atención al cliente, 8 gestión estudio) y 14 códigos de concepto, por ejemplo 205 débito incorrecto, 502 información crediticia incorrecta, 802 trato indigno por terceros (app/lib/tickets/bcra-codes.ts:13-48).
- Un concepto sin código BCRA mapeado guarda null: es el caso de defensa al consumidor, concursos de acreedores y todos los conceptos de CONSULTA (app/lib/tickets/bcra-codes.ts:25-48).
- Las transiciones ofrecidas se filtran por coincidencia exacta del estado actual (estado + subestado) y por los permisos del usuario; si le falta el permiso, la opción ni siquiera se muestra (app/lib/tickets/workflow-machine.ts:277-289).
- El servidor revalida todo al ejecutar: que la transición exista, que el estado origen coincida con el estado real del ticket, los permisos y las guardas (app/lib/tickets/workflow-machine.ts:300-343). No alcanza con manipular el formulario.
- Guarda documental: cuenta los documentos adjuntos del ticket y exige el mínimo configurado, por defecto 1 (app/lib/tickets/workflow-machine.ts:211-218; el conteo se inyecta en app/routes/tickets/transition.tsx:230). 20 de los 24 archivos de workflow la usan en al menos una transición de resolución.
- Guarda de dato obligatorio: solo se usa hoy para exigir el motivo de cierre al cerrar sin trámite el reclamo de baja de socio no efectuada (app/lib/tickets/workflows/baja-socio-no-efectuada.ts:126-133 y 142-149).
- Cuando la transición marca requiresClassification, el sistema no deja avanzar sin clasificación seleccionada (app/lib/tickets/workflow-machine.ts:334-343). Esa clasificación es el diagnóstico que determina el camino de resolución.
- Hay dos modos de clasificación: simple (una sola, se guarda en el ticket) y múltiple (varias, reemplaza el conjunto en ticket_clasificaciones). El modo múltiple solo se usa en trato indigno por terceros, para marcar todos los tipos de mala praxis que apliquen (app/lib/tickets/workflows/trato-indigno-terceros-cobro.ts:53, app/routes/tickets/transition.tsx:247-283).
- Toda transición ejecutada deja asiento en el historial con estado y subestado anterior y nuevo, clasificación anterior y nueva, usuario y observaciones (app/routes/tickets/transition.tsx:286-298).
- Derivación automática: si el subestado destino es 'derivado_reclamo' o 'derivado_reintegro', se crea un ticket hijo en la misma transacción (app/routes/tickets/transition.tsx:301-312, app/lib/tickets/derivation.ts:14-17).
- El ticket derivado hereda socio, tema y concepto, cambia solo el tipo de reclamo, arranca en el estado inicial del workflow destino y su descripción se prefija con 'Derivado desde ticket #N'; queda vinculado por ticket_origen_id (app/lib/tickets/derivation.ts:68-89).
- Hoy la derivación automática solo puede dispararse desde el workflow CONSULTA > CLIENTE > SOLICITUD_BAJA_CUOTA_SERVICIO: es el único que tiene transiciones hacia esos dos subestados (app/lib/tickets/workflows/consulta-baja-cuota-servicio.ts:109-161).
- La resolución es irreversible desde la aplicación: en 24 de los 25 workflows no existe ninguna transición que salga de 'resuelto' (la única excepción es consulta-avisos-masivos.ts:59).
- La edición de un ticket solo permite cambiar la descripción; tipo, tema, concepto, estado y clasificación no se editan a mano (app/routes/tickets/update.tsx:52-72).
- La baja es lógica (marca deleted_at) y exige el permiso tickets:delete (app/routes/tickets/delete.tsx:40 y 56-59).
- Semáforo de SLA en el listado: 'Vencido' si la fecha ya pasó, 'En Riesgo' si faltan menos de 24 horas, 'En Término' en el resto, 'No Aplica' si el ticket no tiene fecha (app/routes/tickets/list.tsx:148-164).
- Permisos: listado y detalle exigen tickets:read; alta, edición, adjuntos, comentarios y ejecución de transiciones exigen tickets:write (app/routes/tickets/transition.tsx:90 y 158); 83 de las 229 transiciones definidas exigen además tickets:manage_workflow (app/lib/auth/authorization.ts:140-143).
- Ticket, TicketComentario y TicketClasificacion están bajo auditoría automática de cambios; TicketHistorialEstado y TicketDocumento no lo están (app/lib/db.server.ts:76-78).
- En la práctica, hoy el único rol con permisos de tickets es admin: ROLE_PERMISSIONS le asigna todos los permisos y ningún otro rol lista tickets:* (app/lib/auth/authorization.ts:246), y el sidebar restringe el módulo a admin (app/layout/AppSidebar.tsx:193-202).
Dónde vive en el código
app/routes.tsapp/layout/AppSidebar.tsxapp/lib/tickets/constants.tsapp/lib/tickets/types.tsapp/lib/tickets/index.tsapp/lib/tickets/workflow-machine.tsapp/lib/tickets/valid-combinations.tsapp/lib/tickets/derivation.tsapp/lib/tickets/sla-rules.tsapp/lib/tickets/bcra-codes.tsapp/lib/tickets/labels.tsapp/lib/tickets/documents.server.tsapp/lib/tickets/workflows/defensa-al-consumidor.tsapp/lib/tickets/workflows/debito-incorrecto.tsapp/lib/tickets/workflows/consulta-baja-cuota-servicio.tsapp/lib/tickets/workflows/baja-socio-no-efectuada.tsapp/lib/tickets/workflows/trato-indigno-terceros-cobro.tsapp/lib/tickets/workflows/posible-fraude.tsapp/lib/tickets/workflows/informa-pago.tsapp/routes/tickets/create.tsxapp/routes/tickets/transition.tsxapp/routes/tickets/view.tsxapp/routes/tickets/list.tsxapp/routes/tickets/update.tsxapp/routes/tickets/delete.tsxapp/routes/tickets/documentos.tsxapp/lib/auth/authorization.tsapp/lib/auth/roles.tsapp/lib/db.server.tsprisma/schema.prismaA tener en cuenta
- El SLA diferenciado por concepto no funciona: las claves de la tabla de reglas (FALTA_DE_RESPUESTA_AL_REQ_DE_ESTADOS_DE_CUENTA_O_LIBRE_DEUDA, INF_CREDITICIA_INCORRECTA_A_CENTRAL_DE_DEUDORES_DEL_BCRA en app/lib/tickets/sla-rules.ts:10-27) no coinciden con los valores reales de concepto del catálogo (FALTA_RESPUESTA_ESTADO_CUENTA_LIBRE_DEUDA, INF_CREDITICIA_INCORRECTA_CENTRAL_DE_DEUDORES_DEL_BCRA en app/lib/tickets/constants.ts). Consecuencia: todos los tickets, sin excepción, reciben el SLA por defecto de 10 días hábiles, incluidos los que la normativa exige resolver en 5.
- No hay ningún trabajo asíncrono ni notificación asociada al SLA: el semáforo solo existe si alguien mira el listado. app/jobs/ no tiene ningún job de tickets y app/lib/email/ no envía nada por este flujo. Un ticket puede vencer sin que nadie se entere.
- 22 de los 24 archivos de workflow declaran 'cerrado' como estado final pero no tienen ninguna transición que llegue hasta ahí: los tickets terminan su vida en 'resuelto'. Solo baja-socio-no-efectuada y consulta-avisos-masivos llegan a 'cerrado'. Si el negocio distingue 'resuelto' de 'cerrado' (por ejemplo, cierre tras notificar al socio), esa distinción hoy no se puede registrar.
- Las 'condiciones de cierre' (checklist regulatorio que aparece en la pantalla de transición, por ejemplo 'socio notificado', 'reintegro ejecutado', 'evidencia archivada') se muestran como checkboxes pero el servidor nunca las lee, no las valida ni las persiste (se renderizan en app/routes/tickets/transition.tsx:494 y el action, líneas 162-185, solo procesa transitionId, observations, clasificacion y campos con prefijo field_). Es un recordatorio visual sin ningún efecto ni traza.
- El workflow CONSULTA por avisos masivos está registrado en el motor pero su combinación tipo/tema/concepto no figura en la lista blanca del alta: no se puede crear ese ticket desde la aplicación. Además declara como estado final únicamente 'cerrado', dejando 'resuelto - consulta_respondida' fuera de los finales.
- La derivación automática CONSULTA a RECLAMO/REINTEGRO está cableada a un solo concepto (baja de cuota de servicio), pese a que el motor la soporta genéricamente y hay 9 workflows de RECLAMOS y 3 de REINTEGRO que podrían recibir derivaciones. Es capacidad instalada sin uso.
- El ticket derivado se crea sin fecha de SLA (app/lib/tickets/derivation.ts:75-88 no setea slaExpiresAt) y sin pasar por la validación de combinación válida. Si no existe workflow para la terna destino, el hijo queda en 'abierto' sin subestado y sin ninguna transición disponible: un ticket huérfano imposible de gestionar.
- Dos tipos de guarda declarados en el modelo nunca se implementaron: 'hasPermission' es un no-op y 'custom' se ignora silenciosamente (app/lib/tickets/workflow-machine.ts:220-227). Hoy ningún workflow las usa, pero si alguien las configura creyendo que validan, no van a frenar nada.
- La máquina de estados XState se construye pero no se usa para ejecutar: executeTransition valida y resuelve el nuevo estado recorriendo a mano la lista de transiciones de la configuración (app/lib/tickets/workflow-machine.ts:294-355) y el actor de XState nunca se inicia. El motor XState es hoy, en los hechos, decorativo. Vale la pena decidir si se completa o se saca la dependencia.
- El historial de estados (TicketHistorialEstado) y los documentos adjuntos (TicketDocumento) no están en la lista de modelos auditados (app/lib/db.server.ts:23-80), a diferencia del ticket, sus comentarios y sus clasificaciones. Para un módulo con exposición regulatoria, el rastro de quién borró un adjunto no queda registrado.
- El filtro por estado de SLA en el listado rompe la paginación de base de datos: cuando está activo se traen todos los tickets sin limit y se filtra y pagina en memoria (app/routes/tickets/list.tsx:107-113 y 170-178). Con volumen creciente esa pantalla se va a degradar.
- El canal de origen 'API' existe en el catálogo pero no hay endpoint que lo use: el formulario fuerza 'manual' con un campo oculto (app/routes/tickets/create.tsx:310). Si se espera ingesta automática de reclamos (por ejemplo desde el portal del socio o desde el BCRA), todavía no está.
- El modelo Ticket no tiene responsable asignado ni área: no hay campo de usuario asignado ni de equipo (prisma/schema.prisma:2376-2413). La naturaleza multi-actor del flujo se expresa solo a través del subestado, así que no se puede armar una bandeja personal de trabajo ni medir carga por persona.
- Nada notifica al socio: ningún workflow dispara correo ni registro de comunicación. 'Socio notificado de la resolución' aparece como condición de cierre en varios workflows, pero es una afirmación declarativa sin respaldo en el sistema.
- La separación de permisos operador (tickets:write) versus resolutor (tickets:manage_workflow) está bien modelada en las 229 transiciones, pero hoy es inerte: el único rol con permisos de tickets es admin (app/lib/auth/authorization.ts:246), que tiene los dos. El control de segregación de funciones que el diseño promete no está operando.
- El selector de socio en el alta trae solo los primeros 100 socios ordenados por apellido (app/routes/tickets/create.tsx:57-66): con la base real, la mayoría de los socios no van a ser seleccionables y los tickets van a quedar sin vincular.