From 71b79cc4b1def7ccb837228fb37840c60a94668a Mon Sep 17 00:00:00 2001 From: david Date: Tue, 25 Aug 2026 00:47:29 +0200 Subject: [PATCH] feat(audit): integrate audit functionality for payment terms management --- .../payment-term-audit.integration.test.ts | 201 +++++++++++++ .../payment-terms/audit/payment-term-audit.ts | 42 +++ .../di/payment-term-creator.di.ts | 7 +- .../di/payment-term-deleter.di.ts | 7 +- .../di/payment-term-status-changer.di.ts | 6 +- .../di/payment-term-updater.di.ts | 7 +- .../__tests__/payment-term-services.test.ts | 281 ++++++++++++++++-- .../services/payment-term-creator.service.ts | 21 +- .../services/payment-term-deleter.service.ts | 19 ++ .../payment-term-status-changer.service.ts | 21 ++ .../services/payment-term-updater.service.ts | 24 +- .../payment-terms/di/payment-terms.di.ts | 9 +- 12 files changed, 610 insertions(+), 35 deletions(-) create mode 100644 apps/server/src/testing/http/payment-term-audit.integration.test.ts create mode 100644 modules/catalogs/src/api/application/payment-terms/audit/payment-term-audit.ts diff --git a/apps/server/src/testing/http/payment-term-audit.integration.test.ts b/apps/server/src/testing/http/payment-term-audit.integration.test.ts new file mode 100644 index 00000000..3e7cc69e --- /dev/null +++ b/apps/server/src/testing/http/payment-term-audit.integration.test.ts @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { SequelizeTransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize"; + +import { PaymentTermUpdater } from "../../../../../modules/catalogs/src/api/application/payment-terms/services/payment-term-updater.service"; +import { + PaymentTermDueDays, + PaymentTermPercentage, +} from "../../../../../modules/catalogs/src/api/domain/payment-terms"; +import { SequelizePaymentTermDomainMapper } from "../../../../../modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-domain.mapper"; +import { SequelizePaymentTermSummaryMapper } from "../../../../../modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-summary.mapper"; +import { PaymentTermDueRuleModel } from "../../../../../modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/models/sequelize-payment-term-due-rule.model"; +import { SequelizePaymentTermRepository } from "../../../../../modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository"; + +import { + buildCompanyHeaders, + createTestAccount, + createTestCompany, + createTestMembership, + loginByApi, +} from "./http-test-fixtures"; +import { createHttpTestRuntime } from "./http-test-runtime"; + +test("payment terms registra dueRules atómicas y revierte junto con el agregado en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + + const account = await createTestAccount(runtime.database, { + email: `payment-term-audit-${randomUUID()}@example.test`, + password: "Password123!", + }); + const company = await createTestCompany(runtime.database, { + legalName: "Payment term audit company", + }); + await createTestMembership(runtime.database, { + accountId: account.id, + companyId: company.id, + }); + const loginResponse = await loginByApi({ + request: runtime.request, + email: account.email, + password: account.password, + }); + assert.equal(loginResponse.status, 200); + const accessToken = (loginResponse.body as { access_token: string }).access_token; + const paymentTermId = randomUUID(); + + const createResponse = await runtime.request("/api/v1/catalogs/payment-terms", { + method: "POST", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ + id: paymentTermId, + name: "Net 30", + is_active: true, + due_rules: [{ due_days: "30", percentage: { value: "10000", scale: "2" } }], + }), + }); + assert.equal(createResponse.status, 201); + + const updateResponse = await runtime.request(`/api/v1/catalogs/payment-terms/${paymentTermId}`, { + method: "PUT", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + "x-correlation-id": "payment-term-due-rules-correlation", + }, + body: JSON.stringify({ + due_rules: [ + { due_days: "30", percentage: { value: "5000", scale: "2" } }, + { due_days: "60", percentage: { value: "5000", scale: "2" } }, + ], + }), + }); + assert.equal(updateResponse.status, 200); + + const auditEvent = await AuditEventModel.findOne({ + where: { action: "payment_term.updated", target_id: paymentTermId }, + }); + assert.ok(auditEvent); + assert.equal(auditEvent.source_type, "application"); + assert.equal(auditEvent.source_id, "uecko-erp"); + assert.equal(auditEvent.origin, "http"); + assert.equal(auditEvent.operation_id, updateResponse.headers.get("x-request-id")); + assert.equal(auditEvent.correlation_id, "payment-term-due-rules-correlation"); + assert.equal(auditEvent.actor_type, "user"); + assert.equal(auditEvent.actor_id, account.id); + assert.equal(auditEvent.scope_type, "company"); + assert.equal(auditEvent.scope_id, company.id); + assert.equal(auditEvent.target_type, "payment_term"); + assert.equal(auditEvent.target_label, "Net 30"); + + const auditChanges = await AuditChangeModel.findAll({ + where: { audit_event_id: auditEvent.id }, + order: [["position", "ASC"]], + }); + assert.deepEqual( + auditChanges.map((change) => ({ + path: change.path, + kind: change.kind, + before: change.before_value, + after: change.after_value, + })), + [ + { + path: "dueRules", + kind: "change", + before: [{ dueDays: 30, percentage: "100.00" }], + after: [ + { dueDays: 30, percentage: "50.00" }, + { dueDays: 60, percentage: "50.00" }, + ], + }, + ] + ); + assert.equal( + auditChanges.some((change) => change.path.startsWith("dueRules.")), + false + ); + + const dueDays30 = PaymentTermDueDays.create(30); + const dueDays60 = PaymentTermDueDays.create(60); + const dueDays90 = PaymentTermDueDays.create(90); + const percentage25 = PaymentTermPercentage.create({ value: 2500, scale: 2 }); + const percentage50 = PaymentTermPercentage.create({ value: 5000, scale: 2 }); + assert.equal(dueDays30.isSuccess, true); + assert.equal(dueDays60.isSuccess, true); + assert.equal(dueDays90.isSuccess, true); + assert.equal(percentage25.isSuccess, true); + assert.equal(percentage50.isSuccess, true); + + const repository = new SequelizePaymentTermRepository( + new SequelizePaymentTermDomainMapper(), + new SequelizePaymentTermSummaryMapper(), + runtime.database + ); + const updater = new PaymentTermUpdater(repository, runtime.audit); + const transactionManager = new SequelizeTransactionManager( + runtime.database, + runtime.transactionContext + ); + const paymentTermIdResult = UniqueID.create(paymentTermId); + assert.equal(paymentTermIdResult.isSuccess, true); + const changesBeforeRollback = await AuditChangeModel.count(); + + await assert.rejects( + () => + runtime.operationContext.run( + { + operationId: "payment-term-rollback-operation", + correlationId: "payment-term-rollback-correlation", + origin: "http", + actor: { type: "user", id: account.id, label: account.email }, + scope: { type: "company", id: company.id, label: null }, + }, + () => + transactionManager.complete(async (transaction) => { + const updateResult = await updater.update({ + companyId: UniqueID.create(company.id).data, + id: paymentTermIdResult.data, + patchProps: { + dueRules: [ + { dueDays: dueDays30.data, percentage: percentage25.data }, + { dueDays: dueDays60.data, percentage: percentage25.data }, + { dueDays: dueDays90.data, percentage: percentage50.data }, + ], + }, + transaction, + }); + assert.equal(updateResult.isSuccess, true); + return Result.fail(new Error("forced payment term rollback")); + }) + ), + /forced payment term rollback/ + ); + + const persistedRules = await PaymentTermDueRuleModel.findAll({ + where: { payment_term_id: paymentTermId }, + order: [["due_days", "ASC"]], + }); + assert.deepEqual( + persistedRules.map((rule) => ({ dueDays: rule.due_days, percentage: rule.percentage_value })), + [ + { dueDays: 30, percentage: 5000 }, + { dueDays: 60, percentage: 5000 }, + ] + ); + assert.equal( + await AuditEventModel.count({ where: { operation_id: "payment-term-rollback-operation" } }), + 0 + ); + assert.equal(await AuditChangeModel.count(), changesBeforeRollback); +}); diff --git a/modules/catalogs/src/api/application/payment-terms/audit/payment-term-audit.ts b/modules/catalogs/src/api/application/payment-terms/audit/payment-term-audit.ts new file mode 100644 index 00000000..13022f4f --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/audit/payment-term-audit.ts @@ -0,0 +1,42 @@ +import type { AuditMutationTarget, AuditState } from "@repo/rdx-audit"; + +import type { PaymentTerm } from "../../../domain"; + +export const PAYMENT_TERM_AUDIT_ACTIONS = { + CREATED: "payment_term.created", + UPDATED: "payment_term.updated", + ENABLED: "payment_term.enabled", + DISABLED: "payment_term.disabled", + DELETED: "payment_term.deleted", +} as const; + +const toPercentageString = (params: { value: number; scale: number }): string => { + const divisor = 10 ** params.scale; + const integer = Math.floor(params.value / divisor); + const decimal = String(params.value % divisor).padStart(params.scale, "0"); + + return `${integer}.${decimal}`; +}; + +/** Proyección histórica estable; las reglas ya llegan ordenadas por dueDays desde el dominio. */ +export const toPaymentTermAuditState = (paymentTerm: PaymentTerm) => + ({ + name: paymentTerm.name.toPrimitive(), + description: paymentTerm.description.match( + (description) => description.toPrimitive(), + () => null + ), + isActive: paymentTerm.isActive, + isSystem: paymentTerm.isSystem, + dueRules: paymentTerm.dueRules.getAll().map((rule) => ({ + dueDays: rule.dueDays.toPrimitive(), + percentage: toPercentageString(rule.percentage), + })), + }) satisfies AuditState; + +/** El nombre se conserva como label del recurso en el momento de la mutación. */ +export const toPaymentTermAuditTarget = (paymentTerm: PaymentTerm): AuditMutationTarget => ({ + type: "payment_term", + id: paymentTerm.id.toPrimitive(), + label: paymentTerm.name.toPrimitive(), +}); diff --git a/modules/catalogs/src/api/application/payment-terms/di/payment-term-creator.di.ts b/modules/catalogs/src/api/application/payment-terms/di/payment-term-creator.di.ts index 07929179..00a15595 100644 --- a/modules/catalogs/src/api/application/payment-terms/di/payment-term-creator.di.ts +++ b/modules/catalogs/src/api/application/payment-terms/di/payment-term-creator.di.ts @@ -1,10 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IPaymentTermRepository } from "../repositories"; import { type IPaymentTermCreator, PaymentTermCreator } from "../services"; export const buildPaymentTermCreator = (params: { repository: IPaymentTermRepository; + audit: IAudit; }): IPaymentTermCreator => { - const { repository } = params; + const { repository, audit } = params; - return new PaymentTermCreator(repository); + return new PaymentTermCreator(repository, audit); }; diff --git a/modules/catalogs/src/api/application/payment-terms/di/payment-term-deleter.di.ts b/modules/catalogs/src/api/application/payment-terms/di/payment-term-deleter.di.ts index 70c38460..4cb7e74d 100644 --- a/modules/catalogs/src/api/application/payment-terms/di/payment-term-deleter.di.ts +++ b/modules/catalogs/src/api/application/payment-terms/di/payment-term-deleter.di.ts @@ -1,3 +1,5 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IPaymentTermRepository } from "../repositories"; import type { IPaymentTermUsageChecker } from "../services"; import { type IPaymentTermDeleter, PaymentTermDeleter } from "../services"; @@ -5,8 +7,9 @@ import { type IPaymentTermDeleter, PaymentTermDeleter } from "../services"; export const buildPaymentTermDeleter = (params: { repository: IPaymentTermRepository; usageChecker: IPaymentTermUsageChecker; + audit: IAudit; }): IPaymentTermDeleter => { - const { repository, usageChecker } = params; + const { repository, usageChecker, audit } = params; - return new PaymentTermDeleter({ repository, usageChecker }); + return new PaymentTermDeleter({ repository, usageChecker, audit }); }; diff --git a/modules/catalogs/src/api/application/payment-terms/di/payment-term-status-changer.di.ts b/modules/catalogs/src/api/application/payment-terms/di/payment-term-status-changer.di.ts index 0d466aab..1ae0ccd3 100644 --- a/modules/catalogs/src/api/application/payment-terms/di/payment-term-status-changer.di.ts +++ b/modules/catalogs/src/api/application/payment-terms/di/payment-term-status-changer.di.ts @@ -1,3 +1,4 @@ +import type { IAudit } from "@repo/rdx-audit"; import type { UniqueID } from "@repo/rdx-ddd"; import type { Result } from "@repo/rdx-utils"; @@ -6,6 +7,7 @@ import { type IPaymentTermStatusChanger, PaymentTermStatusChanger } from "../ser export const buildPaymentTermStatusChanger = (params: { repository: IPaymentTermRepository; + audit: IAudit; companyDefaultUsageChecker?: { isUsedAsCompanyDefault(params: { companyId: UniqueID; @@ -14,7 +16,7 @@ export const buildPaymentTermStatusChanger = (params: { }): Promise>; }; }): IPaymentTermStatusChanger => { - const { repository, companyDefaultUsageChecker } = params; + const { repository, audit, companyDefaultUsageChecker } = params; - return new PaymentTermStatusChanger({ repository, companyDefaultUsageChecker }); + return new PaymentTermStatusChanger({ repository, companyDefaultUsageChecker, audit }); }; diff --git a/modules/catalogs/src/api/application/payment-terms/di/payment-term-updater.di.ts b/modules/catalogs/src/api/application/payment-terms/di/payment-term-updater.di.ts index cfd84f58..d8fa5c47 100644 --- a/modules/catalogs/src/api/application/payment-terms/di/payment-term-updater.di.ts +++ b/modules/catalogs/src/api/application/payment-terms/di/payment-term-updater.di.ts @@ -1,10 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IPaymentTermRepository } from "../repositories"; import { type IPaymentTermUpdater, PaymentTermUpdater } from "../services"; export const buildPaymentTermUpdater = (params: { repository: IPaymentTermRepository; + audit: IAudit; }): IPaymentTermUpdater => { - const { repository } = params; + const { repository, audit } = params; - return new PaymentTermUpdater(repository); + return new PaymentTermUpdater(repository, audit); }; diff --git a/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts b/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts index 4f2280dc..1379c440 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; import { EntityNotFoundError } from "@erp/core/api"; +import type { AuditError, IAudit, RecordAuditMutationShortcutInput } from "@repo/rdx-audit"; import { UniqueID } from "@repo/rdx-ddd"; import { Collection, Maybe, Result } from "@repo/rdx-utils"; @@ -11,8 +12,29 @@ import { PaymentTermName } from "../../../../domain/payment-terms/payment-term-n import { PaymentTermPercentage } from "../../../../domain/payment-terms/payment-term-percentage"; import type { PaymentTermSummary } from "../../models/payment-term-summary.model"; import type { IPaymentTermRepository } from "../../repositories/payment-term-repository.interface"; +import { PaymentTermCreator } from "../payment-term-creator.service"; import { PaymentTermDeleter } from "../payment-term-deleter.service"; import { PaymentTermStatusChanger } from "../payment-term-status-changer.service"; +import { PaymentTermUpdater } from "../payment-term-updater.service"; + +class AuditSpy implements IAudit { + public readonly mutations: RecordAuditMutationShortcutInput[] = []; + + public constructor(private readonly failure?: Error) {} + + async record(): Promise> { + return Result.ok(); + } + + async recordEvent(): Promise> { + return Result.ok(); + } + + async recordMutation(input: RecordAuditMutationShortcutInput): Promise> { + this.mutations.push(input); + return this.failure ? Result.fail(this.failure) : Result.ok(); + } +} class InMemoryPaymentTermRepository implements IPaymentTermRepository { private readonly items = new Map(); @@ -124,9 +146,174 @@ const createPaymentTerm = (companyId = UniqueID.generateNewID()) => { return paymentTermResult.data; }; +const createSystemPaymentTerm = () => { + const companyId = UniqueID.generateNewID(); + const nameResult = PaymentTermName.create("System term"); + assert.equal(nameResult.isSuccess, true); + const paymentTermResult = PaymentTerm.create( + { + companyId, + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: true, + dueRules: [createRuleProps({ dueDays: 0, percentageValue: 10000 })], + }, + UniqueID.generateNewID() + ); + assert.equal(paymentTermResult.isSuccess, true); + + return paymentTermResult.data; +}; + describe("Payment term services", () => { + test("create registra el estado completo", async () => { + const repository = new InMemoryPaymentTermRepository(); + const audit = new AuditSpy(); + const companyId = UniqueID.generateNewID(); + const paymentTerm = createPaymentTerm(companyId); + + const result = await new PaymentTermCreator(repository, audit).create({ + companyId, + id: paymentTerm.id, + props: { + companyId, + name: paymentTerm.name, + description: paymentTerm.description, + isActive: paymentTerm.isActive, + isSystem: paymentTerm.isSystem, + dueRules: paymentTerm.dueRules.getAll().map((rule) => ({ + dueDays: rule.dueDays, + percentage: rule.percentage, + })), + }, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.deepEqual(audit.mutations, [ + { + action: "payment_term.created", + target: { type: "payment_term", id: paymentTerm.id.toPrimitive(), label: "30/60" }, + before: {}, + after: { + name: "30/60", + description: null, + isActive: true, + isSystem: false, + dueRules: [ + { dueDays: 30, percentage: "50.00" }, + { dueDays: 60, percentage: "50.00" }, + ], + }, + }, + ]); + }); + + test("update registra reglas de vencimiento como un único estado ordenado", async () => { + const repository = new InMemoryPaymentTermRepository(); + const audit = new AuditSpy(); + const paymentTerm = createPaymentTerm(); + await repository.create(paymentTerm); + + const result = await new PaymentTermUpdater(repository, audit).update({ + companyId: paymentTerm.companyId, + id: paymentTerm.id, + patchProps: { + dueRules: [ + createRuleProps({ dueDays: 60, percentageValue: 5000 }), + createRuleProps({ dueDays: 30, percentageValue: 5000 }), + ], + }, + }); + + assert.equal(result.isSuccess, true); + assert.equal(audit.mutations.length, 0); + + const changedRulesResult = await new PaymentTermUpdater(repository, audit).update({ + companyId: paymentTerm.companyId, + id: paymentTerm.id, + patchProps: { + dueRules: [ + createRuleProps({ dueDays: 30, percentageValue: 5000 }), + createRuleProps({ dueDays: 60, percentageValue: 2500 }), + createRuleProps({ dueDays: 90, percentageValue: 2500 }), + ], + }, + }); + + assert.equal(changedRulesResult.isSuccess, true); + assert.deepEqual(audit.mutations, [ + { + action: "payment_term.updated", + target: { type: "payment_term", id: paymentTerm.id.toPrimitive(), label: "30/60" }, + before: { + name: "30/60", + description: null, + isActive: true, + isSystem: false, + dueRules: [ + { dueDays: 30, percentage: "50.00" }, + { dueDays: 60, percentage: "50.00" }, + ], + }, + after: { + name: "30/60", + description: null, + isActive: true, + isSystem: false, + dueRules: [ + { dueDays: 30, percentage: "50.00" }, + { dueDays: 60, percentage: "25.00" }, + { dueDays: 90, percentage: "25.00" }, + ], + }, + }, + ]); + }); + + test("propaga el fallo de auditoría al actualizar", async () => { + const repository = new InMemoryPaymentTermRepository(); + const paymentTerm = createPaymentTerm(); + await repository.create(paymentTerm); + const auditFailure = new Error("audit unavailable"); + + const result = await new PaymentTermUpdater(repository, new AuditSpy(auditFailure)).update({ + companyId: paymentTerm.companyId, + id: paymentTerm.id, + patchProps: { isActive: false }, + }); + + assert.equal(result.isFailure, true); + assert.equal(result.error, auditFailure); + }); + + test("update rechaza plazos de sistema y not found sin auditar", async () => { + const repository = new InMemoryPaymentTermRepository(); + const audit = new AuditSpy(); + const paymentTerm = createSystemPaymentTerm(); + await repository.create(paymentTerm); + const updater = new PaymentTermUpdater(repository, audit); + + const systemResult = await updater.update({ + companyId: paymentTerm.companyId, + id: paymentTerm.id, + patchProps: { isActive: false }, + }); + const notFoundResult = await updater.update({ + companyId: paymentTerm.companyId, + id: UniqueID.generateNewID(), + patchProps: { isActive: false }, + }); + + assert.equal(systemResult.isFailure, true); + assert.equal(notFoundResult.isFailure, true); + assert.equal(audit.mutations.length, 0); + }); + test("delete allows removing unreferenced custom payment terms", async () => { const repository = new InMemoryPaymentTermRepository(); + const audit = new AuditSpy(); const paymentTerm = createPaymentTerm(); await repository.create(paymentTerm); @@ -135,6 +322,7 @@ describe("Payment term services", () => { usageChecker: { findUsage: async () => Result.ok([]), }, + audit, }); const result = await deleter.delete({ @@ -142,10 +330,28 @@ describe("Payment term services", () => { }); assert.equal(result.isSuccess, true); + assert.deepEqual(audit.mutations, [ + { + action: "payment_term.deleted", + target: { type: "payment_term", id: paymentTerm.id.toPrimitive(), label: "30/60" }, + before: { + name: "30/60", + description: null, + isActive: true, + isSystem: false, + dueRules: [ + { dueDays: 30, percentage: "50.00" }, + { dueDays: 60, percentage: "50.00" }, + ], + }, + after: {}, + }, + ]); }); test("delete blocks referenced payment terms", async () => { const repository = new InMemoryPaymentTermRepository(); + const audit = new AuditSpy(); const paymentTerm = createPaymentTerm(); await repository.create(paymentTerm); @@ -154,6 +360,7 @@ describe("Payment term services", () => { usageChecker: { findUsage: async () => Result.ok([{ source: "customers" }]), }, + audit, }); const result = await deleter.delete({ @@ -161,43 +368,34 @@ describe("Payment term services", () => { }); assert.equal(result.isFailure, true); + assert.equal(audit.mutations.length, 0); + assert.equal( + (await repository.getByIdInCompany(paymentTerm.companyId, paymentTerm.id)).isSuccess, + true + ); }); test("delete blocks system payment terms", async () => { - const companyId = UniqueID.generateNewID(); - const nameResult = PaymentTermName.create("Contado sistema"); - assert.equal(nameResult.isSuccess, true); - - const paymentTermResult = PaymentTerm.create( - { - companyId, - name: nameResult.data, - description: Maybe.none(), - isActive: true, - isSystem: true, - dueRules: [createRuleProps({ dueDays: 0, percentageValue: 10000 })], - }, - UniqueID.generateNewID() - ); - - assert.equal(paymentTermResult.isSuccess, true); - + const audit = new AuditSpy(); const deleter = new PaymentTermDeleter({ repository: new InMemoryPaymentTermRepository(), usageChecker: { findUsage: async () => Result.ok([]), }, + audit, }); const result = await deleter.delete({ - paymentTerm: paymentTermResult.data, + paymentTerm: createSystemPaymentTerm(), }); assert.equal(result.isFailure, true); + assert.equal(audit.mutations.length, 0); }); test("disable permite desactivar un payment term que no es default de company", async () => { const repository = new InMemoryPaymentTermRepository(); + const audit = new AuditSpy(); const paymentTerm = createPaymentTerm(); await repository.create(paymentTerm); @@ -206,6 +404,7 @@ describe("Payment term services", () => { companyDefaultUsageChecker: { isUsedAsCompanyDefault: async () => Result.ok(false), }, + audit, }); const result = await changer.changeStatus({ @@ -215,10 +414,14 @@ describe("Payment term services", () => { assert.equal(result.isSuccess, true); assert.equal(result.data.isActive, false); + assert.equal(audit.mutations[0]?.action, "payment_term.disabled"); + assert.equal(audit.mutations[0]?.before.isActive, true); + assert.equal(audit.mutations[0]?.after.isActive, false); }); test("disable rechaza desactivar un payment term usado como default de company", async () => { const repository = new InMemoryPaymentTermRepository(); + const audit = new AuditSpy(); const paymentTerm = createPaymentTerm(); await repository.create(paymentTerm); @@ -227,6 +430,7 @@ describe("Payment term services", () => { companyDefaultUsageChecker: { isUsedAsCompanyDefault: async () => Result.ok(true), }, + audit, }); const result = await changer.changeStatus({ @@ -236,5 +440,44 @@ describe("Payment term services", () => { assert.equal(result.isFailure, true); assert.match(result.error.message, /plazo por defecto/i); + assert.equal(audit.mutations.length, 0); + }); + + test("enable registra la transición activa", async () => { + const repository = new InMemoryPaymentTermRepository(); + const audit = new AuditSpy(); + const paymentTerm = createPaymentTerm(); + const disableResult = paymentTerm.disable(); + assert.equal(disableResult.isSuccess, true); + await repository.create(paymentTerm); + + const result = await new PaymentTermStatusChanger({ repository, audit }).changeStatus({ + paymentTerm, + action: "enable", + }); + + assert.equal(result.isSuccess, true); + assert.equal(audit.mutations[0]?.action, "payment_term.enabled"); + assert.equal(audit.mutations[0]?.before.isActive, false); + assert.equal(audit.mutations[0]?.after.isActive, true); + }); + + test("disable rechaza plazos de sistema sin auditar", async () => { + const audit = new AuditSpy(); + const paymentTerm = createSystemPaymentTerm(); + + const result = await new PaymentTermStatusChanger({ + repository: new InMemoryPaymentTermRepository(), + audit, + companyDefaultUsageChecker: { + isUsedAsCompanyDefault: async () => Result.ok(false), + }, + }).changeStatus({ + paymentTerm, + action: "disable", + }); + + assert.equal(result.isFailure, true); + assert.equal(audit.mutations.length, 0); }); }); diff --git a/modules/catalogs/src/api/application/payment-terms/services/payment-term-creator.service.ts b/modules/catalogs/src/api/application/payment-terms/services/payment-term-creator.service.ts index 459f19ba..81779a88 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/payment-term-creator.service.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/payment-term-creator.service.ts @@ -1,7 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { type IPaymentTermCreateProps, PaymentTerm } from "../../../domain"; +import { + PAYMENT_TERM_AUDIT_ACTIONS, + toPaymentTermAuditState, + toPaymentTermAuditTarget, +} from "../audit/payment-term-audit"; import type { IPaymentTermRepository } from "../repositories/payment-term-repository.interface"; export interface IPaymentTermCreatorParams { @@ -16,7 +22,10 @@ export interface IPaymentTermCreator { } export class PaymentTermCreator implements IPaymentTermCreator { - constructor(private readonly repository: IPaymentTermRepository) {} + constructor( + private readonly repository: IPaymentTermRepository, + private readonly audit: IAudit + ) {} public async create(params: IPaymentTermCreatorParams): Promise> { const { companyId, id, props, transaction } = params; @@ -32,6 +41,16 @@ export class PaymentTermCreator implements IPaymentTermCreator { return Result.fail(saveResult.error); } + const auditResult = await this.audit.recordMutation({ + action: PAYMENT_TERM_AUDIT_ACTIONS.CREATED, + target: toPaymentTermAuditTarget(paymentTerm), + before: {}, + after: toPaymentTermAuditState(paymentTerm), + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(paymentTerm); } } diff --git a/modules/catalogs/src/api/application/payment-terms/services/payment-term-deleter.service.ts b/modules/catalogs/src/api/application/payment-terms/services/payment-term-deleter.service.ts index 7cc085c2..d870c4f7 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/payment-term-deleter.service.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/payment-term-deleter.service.ts @@ -1,7 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; import { Result } from "@repo/rdx-utils"; import type { PaymentTerm } from "../../../domain"; import { PaymentTermCannotBeDeletedError } from "../../../domain"; +import { + PAYMENT_TERM_AUDIT_ACTIONS, + toPaymentTermAuditState, + toPaymentTermAuditTarget, +} from "../audit/payment-term-audit"; import type { IPaymentTermRepository } from "../repositories/payment-term-repository.interface"; import type { IPaymentTermUsageChecker } from "./payment-term-usage-checker"; @@ -18,6 +24,7 @@ export class PaymentTermDeleter implements IPaymentTermDeleter { private readonly deps: { repository: IPaymentTermRepository; usageChecker: IPaymentTermUsageChecker; + audit: IAudit; } ) {} @@ -26,6 +33,8 @@ export class PaymentTermDeleter implements IPaymentTermDeleter { transaction?: unknown; }): Promise> { const { paymentTerm, transaction } = params; + const before = toPaymentTermAuditState(paymentTerm); + const target = toPaymentTermAuditTarget(paymentTerm); const invariantResult = paymentTerm.ensureCanBeDeleted(); if (invariantResult.isFailure) { @@ -61,6 +70,16 @@ export class PaymentTermDeleter implements IPaymentTermDeleter { return Result.fail(deleteResult.error); } + const auditResult = await this.deps.audit.recordMutation({ + action: PAYMENT_TERM_AUDIT_ACTIONS.DELETED, + target, + before, + after: {}, + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(paymentTerm); } } diff --git a/modules/catalogs/src/api/application/payment-terms/services/payment-term-status-changer.service.ts b/modules/catalogs/src/api/application/payment-terms/services/payment-term-status-changer.service.ts index 27ccf971..8644a161 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/payment-term-status-changer.service.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/payment-term-status-changer.service.ts @@ -1,7 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; import { Result } from "@repo/rdx-utils"; import type { PaymentTerm } from "../../../domain"; import { PaymentTermCannotBeDisabledError } from "../../../domain"; +import { + PAYMENT_TERM_AUDIT_ACTIONS, + toPaymentTermAuditState, + toPaymentTermAuditTarget, +} from "../audit/payment-term-audit"; import type { IPaymentTermRepository } from "../repositories"; export type PaymentTermStatusChangeAction = "enable" | "disable"; @@ -25,6 +31,7 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger { transaction?: unknown; }): Promise>; }; + audit: IAudit; } ) {} @@ -34,6 +41,7 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger { transaction?: unknown; }): Promise> { const { paymentTerm, action, transaction } = params; + const before = toPaymentTermAuditState(paymentTerm); if (action === "disable" && this.deps.companyDefaultUsageChecker) { const companyDefaultUsageResult = @@ -70,6 +78,19 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger { return Result.fail(persistenceResult.error); } + const auditResult = await this.deps.audit.recordMutation({ + action: + action === "enable" + ? PAYMENT_TERM_AUDIT_ACTIONS.ENABLED + : PAYMENT_TERM_AUDIT_ACTIONS.DISABLED, + target: toPaymentTermAuditTarget(paymentTerm), + before, + after: toPaymentTermAuditState(paymentTerm), + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(paymentTerm); } } diff --git a/modules/catalogs/src/api/application/payment-terms/services/payment-term-updater.service.ts b/modules/catalogs/src/api/application/payment-terms/services/payment-term-updater.service.ts index b465f861..5cce273c 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/payment-term-updater.service.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/payment-term-updater.service.ts @@ -1,7 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { PaymentTerm, PaymentTermPatchProps } from "../../../domain"; +import { + PAYMENT_TERM_AUDIT_ACTIONS, + toPaymentTermAuditState, + toPaymentTermAuditTarget, +} from "../audit/payment-term-audit"; import type { IPaymentTermRepository } from "../repositories/payment-term-repository.interface"; export interface IPaymentTermUpdater { @@ -14,7 +20,10 @@ export interface IPaymentTermUpdater { } export class PaymentTermUpdater implements IPaymentTermUpdater { - constructor(private readonly repository: IPaymentTermRepository) {} + constructor( + private readonly repository: IPaymentTermRepository, + private readonly audit: IAudit + ) {} public async update(params: { companyId: UniqueID; @@ -30,8 +39,8 @@ export class PaymentTermUpdater implements IPaymentTermUpdater { } const paymentTerm = existingResult.data; + const before = toPaymentTermAuditState(paymentTerm); - // Aplicar cambios en el agregado const updateResult = paymentTerm.update(patchProps); if (updateResult.isFailure) { return Result.fail(updateResult.error); @@ -41,12 +50,21 @@ export class PaymentTermUpdater implements IPaymentTermUpdater { return Result.ok(paymentTerm); } - // Persistir cambios const saveResult = await this.repository.update(paymentTerm, transaction); if (saveResult.isFailure) { return Result.fail(saveResult.error); } + const auditResult = await this.audit.recordMutation({ + action: PAYMENT_TERM_AUDIT_ACTIONS.UPDATED, + target: toPaymentTermAuditTarget(paymentTerm), + before, + after: toPaymentTermAuditState(paymentTerm), + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(paymentTerm); } } diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts b/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts index a31a4fe5..5f5204c2 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts @@ -43,7 +43,7 @@ export type PaymentTermsInternalDeps = { }; export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTermsInternalDeps => { - const { database, transactionContext } = params; + const { audit, database, transactionContext } = params; const transactionManager = buildTransactionManager(database as Sequelize, transactionContext); const persistenceMappers = buildPaymentTermPersistenceMappers(); @@ -57,11 +57,12 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm const inputMappers = buildPaymentTermInputMappers(); const finder = buildPaymentTermFinder({ repository }); - const creator = buildPaymentTermCreator({ repository }); - const updater = buildPaymentTermUpdater({ repository }); - const deleter = buildPaymentTermDeleter({ repository, usageChecker }); + const creator = buildPaymentTermCreator({ repository, audit }); + const updater = buildPaymentTermUpdater({ repository, audit }); + const deleter = buildPaymentTermDeleter({ repository, usageChecker, audit }); const statusChanger = buildPaymentTermStatusChanger({ repository, + audit, companyDefaultUsageChecker, });