FactuGES_ERP/docs/audit/audit-system-design.md
david e0ba5ef229 feat: add proforma duplicator service and related tests
- Implemented ProformaDuplicator service for duplicating proforma documents.
- Added tests for duplicating proformas, including scenarios for successful duplication, audit failure propagation, and payment term adaptations.
- Created ProformaStatusChanger service to handle status changes of proformas with corresponding tests.
- Developed ProformaArchiver service for unarchiving proformas and added tests to verify functionality.
- Introduced a test fixture for creating proforma instances for testing purposes.
2026-08-26 13:03:39 +02:00

24 KiB

DOCUMENTO DE DISENO - SISTEMA TRANSVERSAL DE AUDITORIA

Estado: consolidado actual. Fecha: 25 de agosto de 2026. Alcance: Writer portable, composición server-side, lectura HTTP y frontend de /audit actualmente implementados.

Resumen ejecutivo

Audit registra quién realizó una operación, qué ocurrió, cuándo, sobre qué target, bajo qué scope y, para una mutación, los cambios históricos exactos. Es un Audit Trail con Change History append-only.

No es Event Sourcing, logging técnico general, un sistema genérico de versionado, BusinessVersion ni DocumentVersion. El estado actual implementa Audit Trail y Change History. BusinessVersion y DocumentVersion son evoluciones distintas y no están implementadas.

Separación conceptual

Concepto Responsabilidad Estado
Audit Trail Quién, qué, cuándo, target, contexto y operación realizada Implementado
Change History Diff histórico de una mutation Implementado
BusinessVersion Versionado funcional de entidades/documentos de negocio Futuro
DocumentVersion Versionado de binarios o documentos Futuro

Principios y límites del paquete portable

@repo/rdx-audit proporciona el Writer portable. No conoce Sequelize, MariaDB, Express, Company, tenant, Criteria, Identity, roles, permisos, transacciones concretas ni el AsyncLocalStorage del host. El dominio funcional tampoco conoce Audit: los application services reciben IAudit por composición.

Principios normativos:

  • Store append-only y actions históricas estables.
  • La mutación de negocio y su AuditEntry comparten transacción.
  • La autorización de lectura está separada de los filtros solicitados.
  • El Reader está separado del Writer y no pertenece al paquete portable.
  • Cada módulo proyecta snapshots históricos controlados; Audit no inspecciona aggregates ni ORM.
  • Audit no depende de módulos funcionales concretos.

Contratos Writer

IAudit.record() es la operación canónica. Los atajos sólo fijan el kind y delegan en ella:

interface IAudit {
  record(input: RecordAuditInput): Promise<Result<void, AuditError>>;
  recordEvent(input: RecordAuditEventShortcutInput): Promise<Result<void, AuditError>>;
  recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result<void, AuditError>>;
}

Un event representa una acción ocurrida y siempre persiste changes: []; su target puede no tener ID. Una mutation exige target identificado, recibe before y after, calcula el diff y sólo se persiste si hay al menos un cambio. Una mutation no-op devuelve éxito y no crea AuditEntry.

La estructura base contiene id, occurredAt ISO, source, operation, actor, scope, action, kind, target y changes. El ID se genera con randomUUID() salvo generador inyectado; el timestamp procede de now().toISOString().

AuditState y snapshots

AuditState admite objetos planos readonly cuyos valores son string, número finito, boolean, null, objeto plano readonly o array readonly. Se rechazan undefined, Date, Buffer, bigint, funciones, Map, Set, instancias de clase, Value Objects, Maybe, Collection, símbolos y ciclos. Las claves no pueden contener ..

Los snapshots son funcionales, no serializaciones ORM. No se debe aplicar aggregate.toPrimitives() automáticamente. Normalmente excluyen timestamps técnicos y companyId cuando el scope ya expresa compañía. PII puede incluirse si la proyección histórica lo decide; referencias de catálogo se guardan como IDs históricos y no se resuelven con labels actuales. Los arrays son sensibles al orden; si el orden no tiene semántica, el módulo debe normalizarlo antes de auditar.

Diff

diffAuditState() compara objetos recursivamente, ordena paths de forma determinista y es independiente del orden de inserción de keys. Los arrays son atómicos; atomicPaths permite tratar un objeto como atómico. La ausencia de clave no equivale a null:

interface AuditChange {
  readonly path: string;
  readonly kind: "set" | "unset" | "change";
  readonly before: AuditValue | null;
  readonly after: AuditValue | null;
}
  • set: clave ausente antes y presente después.
  • unset: presente antes y ausente después.
  • change: presente en ambos, con valor distinto o cambio atómico/tipo incompatible.

Contexto de operación y transacción

OperationContext contiene operationId, correlationId, origin, actor y scope. AsyncLocalStorageOperationContextStore lo aísla por operación asíncrona y restaura correctamente scopes anidados. Operation/correlation/origin son inmutables; sólo se enriquecen actor y scope. Un actor concreto no puede sustituirse por otro, y un scope concreto no puede sustituirse por otro; el actor anónimo y el scope nulo pueden enriquecerse una vez.

Pipeline HTTP actual:

request -> OperationContext middleware -> authenticateUser -> requireCompanyContext -> controller

El middleware genera operationId, adopta X-Correlation-Id si es válido o genera uno, responde X-Request-Id y X-Correlation-Id, y parte de origin: "http", actor anónimo y scope nulo. Autenticación enriquece actor como user; el contexto de compañía autorizado enriquece scope como company.

OperationAuditContextProvider adapta OperationContext al puerto IAuditContextProvider, sin invertir la dependencia del paquete portable. scope: null sólo significa que la operación histórica no tuvo scope; nunca significa visibilidad global.

SequelizeTransactionContext usa otro AsyncLocalStorage exclusivamente para la transacción Sequelize activa. No reemplaza el paso explícito de Transaction entre casos de uso y repositorios. SequelizeTransactionManager.complete() prohíbe transacciones anidadas, abre T1, publica T1, ejecuta el callback y hace rollback si lanza o devuelve Result.fail; en éxito hace commit.

Para una mutation la propiedad requerida es:

BEGIN T1
  mutación y persistencia funcional
  Audit.recordMutation
  audit_events + audit_changes
COMMIT

Si Audit falla, el resultado debe propagarse y T1 revierte negocio y auditoría. Los tests MariaDB de sequelize-audit-store.integration.test.ts comprueban commit, rollback, cambios JSON y fallo de mutation sin T.

Store y persistencia MariaDB

SequelizeAuditStore implementa IAuditStore.append(entry). No hay upsert: un ID duplicado falla. Un event puede insertarse sin transacción; una mutation sin transacción devuelve AuditStoreError controlado. Con T activa crea el event y hace bulkCreate de los changes en la misma T.

audit_events tiene PK id y columnas kind, occurred_at, source, operation/correlation/origin, actor, scope, action y target. Índices: (occurred_at,id), scope+fecha+id, target+fecha+id, actor+fecha+id, operation_id y correlation_id.

audit_changes tiene PK compuesta (audit_event_id, position), FK a audit_events(id) con ON DELETE CASCADE, además de path, kind, before_value y after_value JSON. MariaDB puede devolver JSON como texto; los getters del modelo lo parsean para mantener el contrato al leer. position preserva el orden persistido.

La composición productiva crea una única instancia: AsyncLocalStorageOperationContextStore -> OperationAuditContextProvider, SequelizeTransactionContext -> SequelizeAuditStore, y Audit({ source: { type: "application", id: "uecko-erp" }, ... }). Esa instancia se entrega como ModuleParams.audit; los módulos reciben el puerto, no el store concreto ni una instancia por request.

Patrón de instrumentación funcional

Para actualizar: cargar agregado, proyectar before, mutar, terminar sin persistencia/audit si es no-op, persistir con T, proyectar after, recordMutation, y propagar cualquier fallo para rollback. Para create: crear, persistir, proyectar estado y auditar dentro de T. Para delete: cargar, proyectar before, borrar y auditar dentro de T.

Normalmente no se auditan validation failure, not found, rechazo de dominio, cross-company, duplicados ni no-ops: Audit describe operaciones realizadas, no intentos fallidos.

Módulos instrumentados y actions

Target Action Kind Estado
payment_method payment_method.created mutation Implementada
payment_method payment_method.updated mutation Implementada
payment_method payment_method.enabled mutation Implementada
payment_method payment_method.disabled mutation Implementada
payment_method payment_method.deleted mutation Implementada
payment_method payment_method.default_set mutation Implementada
payment_method payment_method.default_unset mutation Implementada
payment_term payment_term.created mutation Implementada
payment_term payment_term.updated mutation Implementada
payment_term payment_term.enabled mutation Implementada
payment_term payment_term.disabled mutation Implementada
payment_term payment_term.deleted mutation Implementada
customer customer.created mutation Implementada
customer customer.updated mutation Implementada
company company.created mutation Implementada
company company.updated mutation Implementada
company company.enabled mutation Implementada
company company.disabled mutation Implementada
supplier supplier.created mutation Implementada
supplier supplier.updated mutation Implementada
supplier supplier.activated mutation Implementada
supplier supplier.deactivated mutation Implementada
proforma proforma.created mutation Implementada
proforma proforma.updated mutation Implementada
proforma proforma.status_changed mutation Implementada
proforma proforma.archived mutation Implementada
proforma proforma.unarchived mutation Implementada
proforma proforma.deleted mutation Implementada
proforma proforma.duplicated mutation Implementada

Payment Methods proyecta name, description, flags isActive/isSystem/isDefault; cambiar el default puede producir dos AuditEntries (default_unset y default_set) bajo la misma operación y T. Payment Terms añade dueRules como array atómico ordenado por el dominio, con porcentajes serializados como strings.

Customers y Suppliers proyectan estado, referencia, identidad comercial/fiscal, dirección, contactos, idioma y moneda. Customers añade método/plazo de pago, régimen fiscal y flags de recargo/retención; incluye PII y excluye deliberadamente legalRecord. No hay actions lifecycle de Customer. Suppliers separa status del PATCH mediante lifecycle explícito; los inactivos siguen siendo consultables. Companies proyecta identidad, contacto, slug, plazo por defecto y estado; create es unscoped y update/status son scoped en las rutas actuales.

Proformas

P1 está COMPLETA: proforma.created se registra después de persistence y reload, y proforma.updated captura before/after. Create y update comparten la misma transacción con Audit; un fallo de Audit revierte la Proforma, items/taxes y, en create, DocumentSeries.next_number. Un update sin cambio funcional no persiste ni genera AuditEntry. Los casos validados de update incluyen description, customer y recipient derivado, items y limpieza de paymentTermId.

P2 está COMPLETA: proforma.status_changed, proforma.archived, proforma.unarchived y proforma.deleted. Todas reciben IAudit explícitamente, usan target proforma con ID real y referencia como label, y comparten frontera transaccional funcional + Audit. La regla es: functional mutation + Audit append belong to the same transaction boundary; si la mutación falla o es no-op no hay Audit, y si Audit falla se revierte la mutación sin AuditEntries parciales committed.

proforma.status_changed solo registra transiciones manuales reales. Mismo status es no-op sin persistence ni Audit; los rechazos tampoco auditan. Los estados actuales son draft, sent, approved, rejected e issued; las transiciones manuales son draft -> sent, sent -> approved|rejected, approved -> rejected|draft y rejected -> draft. issued no admite transición manual y no existe el estado accepted.

proforma.archived proyecta archivedAt de null a timestamp funcional; proforma.unarchived lo proyecta de timestamp a null. proforma.deleted mantiene delete físico, usa before = toProformaAuditState(proforma) y after = {}, conservando ID y referencia del target aunque el aggregate desaparezca.

ProformaAuditState conserva cabecera funcional, recipient con PII, taxConfig JSON-like e items ordenados como array atómico. Excluye companyId, IDs técnicos de líneas, totals derivados y timestamps ORM. Los opcionales se normalizan a null; Quantity y Percentage usan value:scale, y Money usa currency:value:scale.

P3 está COMPLETA: DuplicateProformaByIdUseCase -> ProformaDuplicator -> proforma.duplicated. ProformaCreator y ProformaDuplicator son servicios hermanos: create sigue el flujo CreateProformaUseCase -> ProformaCreator -> proforma.created, mientras que Duplicator no llama a Creator ni usa flags como skipAudit, auditAction o shouldAudit. Para la copia, el target es la nueva Proforma recargada, con su ID y referencia final como label; se registra exactamente una AuditEntry con before = {} y after = toProformaAuditState(duplicatedProforma). No se registra proforma.created para la duplicate, la source no recibe Audit adicional, no existe sourceProformaId en Audit y ProformaAuditState no cambia.

Duplicator devuelve IProformaDuplicatorResult con la Proforma final y paymentTermAdaptation: null | { source: "inactive_original"; resolution: "customer" | "company" | "none" }. La adaptación se calcula junto con la resolución del payment term y se propaga por el Use Case, pero no pertenece al snapshot Audit.

P4 sigue PENDIENTE para issue: proforma.issued e issued_invoice.created no están iniciadas ni implementadas.

Evidencia técnica: P1 cuenta con tests unit/application, tests dedicados de ProformaAuditState, MariaDB real, rollback de create/update y de numeración DocumentSeries, y success real mediante SequelizeAuditStore. P2 cuenta con application status 3/0, archive 2/0, unarchive 1/0 y delete 2/0; su suite MariaDB lifecycle terminó 7/0, confirmó rollback de status/archive/unarchive/delete y cero nuevas Audit rows fallidas por action/target. P3 cuenta con ProformaDuplicator application 4/0 (success, fallo funcional previo a persistence, fallo Audit y fallback de payment term inactivo a Customer) y DuplicateProformaByIdUseCase 3/0 (success, source failure y propagación de fallo del Duplicator). Su MariaDB real valida success HTTP 201, source committed, duplicate draft con ID y referencia nuevos, una proforma.duplicated y cero proforma.created para la duplicate. El rollback P3 con FailingAudit falla después de persistence y reload (recordMutation = 1): la source permanece, la duplicate y sus items/taxes no persisten, AuditEvents y AuditChanges no aumentan, no quedan actions parciales y DocumentSeries.next_number vuelve al valor previo. Biome y git diff --check fueron correctos en P2/P3.

Una action es un string jerárquico validado en minúsculas, por ejemplo customer.updated. Tras registrarse se convierte en contrato histórico: no debe renombrarse arbitrariamente ni centralizarse en un enum global inexistente.

Reader, autorización y HTTP

IAuditReader y SequelizeAuditReader viven en apps/server porque dependen de Sequelize. AuditReadAccess puede ser unrestricted o scoped { scopes, includeUnscoped }. La ruta HTTP V1 sólo crea acceso scoped con la compañía activa y includeUnscoped: false; no expone unrestricted ni RBAC funcional. Por tanto, cualquier usuario autenticado con membership activa de la compañía puede leer todo su Audit, incluidos snapshots con TIN, email, teléfonos y direcciones. Es una limitación y riesgo de PII explícito.

Regla normativa de lectura: AUTHORIZED_WHERE AND REQUESTED_FILTERS. El test prueba que autorizado en A y filtro scopeId=B produce []. Detail fuera de scope devuelve 404, no 403, para no revelar existencia.

Endpoints actuales:

  • GET /audit
  • GET /audit/:audit_entry_id

Ambos pasan por requireIdentityTenant(). Zod usa objetos estrictos; una query/params inválida devuelve 422 mediante ValidationApiError, no 400. Query soporta pageNumber, pageSize, q, from, to, kind, action, targetType, targetId, actorType, actorId, scopeType, scopeId, operationId, correlationId, origin, orderBy y order.

El Reader aplica una whitelist propia incluso cuando se invoca directamente. Rechaza unknownField, beforeValue, afterValue y some_sql_column antes de SQL. Quick search busca action, labels de target/actor/scope y coincidencias exactas de target/operation/correlation ID; escapa %, _ y \\, y no busca dentro del JSON histórico.

Los periodos son normativos [from,to): occurred_at >= from y < to. El backend usa paginación zero-based, pageSize máximo 100, y responde page, per_page, total_pages, total_items. Sorting acepta occurredAt, action, targetType y actorLabel; el default y tie-breaker son occurred_at DESC, id DESC.

Cada AuditEntry es una fila de list. La list no carga changes: resuelve changes_count con COUNT + SELECT y subquery correlacionada, por lo que es O(1) queries respecto a N entries. Detail primero autoriza y carga el event, después carga changes en consulta adicional por position ASC; un event devuelve changes: [].

Frontend /audit

El módulo es modules/audit; la ruta protegida y con compañía requerida es /audit. La URL es la fuente de verdad para q, period, from, to, kind, targetType, action, pageNumber, pageSize, orderBy, order y entry. pageNumber URL es one-based; controller y backend son zero-based. El default es last7Days.

Filtros visibles V1: búsqueda, periodo, kind, recurso y action. No hay filtro Actor ni Company. Actor no se expone porque no existe endpoint productivo de company users ni selector reutilizable; /users es legacy/desconectado, actor.label es histórico y hay actores no humanos. El backend sí soporta actorId/actorType.

Presets: today usa inicio local del día a inicio del siguiente; last24Hours, last7Days y last30Days terminan en now; custom conserva sus ISO. Registries de targets/actions/paths son metadata de presentación frontend, no contratos backend, y usan fallback raw para actions, paths o targets desconocidos.

entry=<id> abre un Sheet lateral y permite deep-link, reload y Back/Forward. GET /audit/:id se mapea de DTO snake_case a modelo camelCase. El Sheet muestra action/target/actor, fecha local, changes (set: nuevo valor; unset: valor eliminado; change: antes/después), evento sin cambios, y metadata técnica: Audit Entry ID, action raw, kind, target/actor/scope ID, operation/correlation ID, origin, source type/id y timestamp ISO. El renderer genérico cubre null, boolean, scalar, array y objeto; no hay renderers de dominio especializados ni resolución actual de catálogos.

Estados UX: skeleton inicial, datos conservados durante refetch, empty y filtered empty, PageError inicial, error inline de refresh, skeleton/error/retry en detail y 404 neutro.

Refresh y Live

Actualizar llama al refetch() de la query actual. Live es una preferencia local no persistida. React Query hace polling de 5 segundos sólo cuando Live es efectivo: primera página y periodo que incluye now. Para custom se usa from <= now < to; fuera de primera página o periodo incompatible Live permanece solicitado pero pausado y explica el motivo. El detalle no hace polling.

Los rangos relativos se recalculan dentro del request de polling sin modificar la URL. La query key mantiene el estado semántico ya renderizado, evitando crear una entrada de caché por cada now. Las keys de list y detail incluyen companyId; al cambiar compañía se limpia entry y Live solicitado continúa sobre la key de la nueva compañía.

Validación, regresiones y evidencia

La cobertura existente incluye Writer/diff/state, OperationContext y transaction context, store y rollback MariaDB, integrations de Payment Methods/Terms, Customer, Company y Supplier, Reader scoped/unrestricted/whitelist/rango/búsqueda/paginación/detail, HTTP tenant A/B/404/422, y frontend mapper/URL/periodos/filtros/detail/renderer/live. La ejecución actual de pnpm --filter @erp/audit test valida 32 tests frontend.

Lecciones verificadas en código y pruebas:

  • Los operadores de Criteria GREATER_THAN_OR_EQUAL y LOWER_THAN se traducen expresamente a Op.gte y Op.lt; fue un bug de implementación del Reader, no una regla opcional.
  • from y to deben combinarse sobre la misma columna occurred_at; sobrescribir el where habría roto [from,to). La integración Reader cubre el rango.
  • El parser frontend convierte URL one-based a estado/backend zero-based y serializa a la inversa; fue una regresión de integración UI, cubierta por test.
  • El contrato HTTP de login es access_token; la divergencia accessToken corresponde al mapeo interno/frontend o a harnesses y no a un campo HTTP alternativo productivo.

Limitaciones V1 y evolución prudente

Limitaciones actuales: sin RBAC granular, filtro Actor, filtro Company, facets, export, saved searches, búsqueda en JSON before/after, SSE/WebSocket, cursor pagination, contador de nuevos eventos, navegación al target ni resolución histórica de labels de catálogo. Los events unscoped no son visibles por HTTP normal.

Posibles evoluciones, no implementadas ni obligatorias: RBAC y endpoint unrestricted de administrador, endpoint company-users y actor filter, facets/export/cursor pagination, SSE/WebSocket si polling no basta, renderers especializados, navegación a recursos, BusinessVersion y DocumentVersion.

Guía para instrumentar un módulo nuevo

  1. Identificar operaciones efectivamente realizadas.
  2. Definir actions estables y target.
  3. Diseñar AuditState histórico y decidir PII.
  4. Decidir arrays, normalización y atomicPaths.
  5. Detectar no-ops.
  6. Instrumentar el application service, no controller, repository ni domain.
  7. Compartir la misma T y propagar el fallo de Audit.
  8. Añadir tests unitarios, integración funcional y rollback MariaDB.
  9. Actualizar la tabla de actions y registries/traducciones frontend si procede.

Anti-patrones: auditar desde controllers/repositorios/domain, registrar no-ops o mutaciones fuera de T, usar toPrimitives() automático, reescribir historia con labels actuales, usar scope solicitado como autorización, tratar scope=null como global, renombrar actions históricas, usar Audit como Event Sourcing o como directorio de usuarios.

Decisiones normativas e implementativas

Son normativas: actions estables, snapshots explícitos, mutation atómica con negocio, no auditar no-ops, separación entre scope y autorización, y una AuditEntry por hecho auditado. Una operación de negocio puede producir más de un hecho, como el cambio de método de pago predeterminado.

Son implementativas actuales y reemplazables: MariaDB, Sequelize, modelos audit_events/audit_changes, subquery correlacionada de changes_count, paginación offset, React Query y polling Live de cinco segundos. Cambiar esa infraestructura no debe reinterpretar las reglas históricas.

Referencias de implementación

  • Writer y diff: packages/rdx-audit/src/audit.ts, audit.types.ts, internal/audit-diff.ts, internal/audit-state-validator.ts.
  • Contexto y T: modules/core/src/api/infrastructure/context/, .../persistence/sequelize/sequelize-transaction-*.ts.
  • Store/modelos/Reader/rutas: apps/server/src/infrastructure/audit/.
  • Evidencia server: apps/server/src/testing/http/*audit*.test.ts y sequelize-audit-store.integration.test.ts.
  • Instrumentaciones: los directorios audit/ de Catalogs, Customers, Companies y Suppliers.
  • Frontend: modules/audit/src/web/ y sus tests de utils/shared.