From f1c0df685b18389acfa45c6649c9c2fcd9680211 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 25 Aug 2026 00:28:23 +0200 Subject: [PATCH] feat(audit): integrate audit functionality for payment methods - Added @repo/rdx-audit as a dependency in server and catalogs package.json. - Enhanced payment method services (creator, updater, deleter, status changer, and default setter) to include audit logging. - Implemented audit actions for create, update, enable, disable, delete, and default set/ unset operations. - Created payment-method-audit.ts for managing audit actions and state projections. - Developed integration tests for payment method audit logging in the HTTP context. --- apps/server/package.json | 1 + .../src/testing/http/http-test-runtime.ts | 3 + .../payment-method-audit.integration.test.ts | 237 ++++++++++++++++++ modules/catalogs/package.json | 3 +- .../audit/payment-method-audit.ts | 33 +++ .../di/payment-method-creator.di.ts | 7 +- .../di/payment-method-deleter.di.ts | 8 +- .../di/payment-method-status-changer.di.ts | 7 +- .../di/payment-method-updater.di.ts | 7 +- .../__tests__/payment-method-services.test.ts | 154 +++++++++++- .../payment-methods/services/index.ts | 2 +- .../services/payment-method-creator.ts | 19 +- .../services/payment-method-deleter.ts | 18 ++ .../services/payment-method-status-changer.ts | 23 +- .../services/payment-method-updater.ts | 20 +- .../set-default-payment-method.service.ts | 29 ++- ...t-default-payment-method-by-id.use-case.ts | 2 +- .../payment-methods/di/payment-methods.di.ts | 14 +- pnpm-lock.yaml | 6 + 19 files changed, 562 insertions(+), 31 deletions(-) create mode 100644 apps/server/src/testing/http/payment-method-audit.integration.test.ts create mode 100644 modules/catalogs/src/api/application/payment-methods/audit/payment-method-audit.ts diff --git a/apps/server/package.json b/apps/server/package.json index 2a9a3dfe..9f839d9a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.11", + "@repo/rdx-ddd": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.19", diff --git a/apps/server/src/testing/http/http-test-runtime.ts b/apps/server/src/testing/http/http-test-runtime.ts index 1bd0c47b..5bfb6822 100644 --- a/apps/server/src/testing/http/http-test-runtime.ts +++ b/apps/server/src/testing/http/http-test-runtime.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { AsyncLocalStorageOperationContextStore, type IModuleServer, + type IOperationContextStore, type ISequelizeTransactionContext, OperationAuditContextProvider, SequelizeTransactionContext, @@ -53,6 +54,7 @@ export interface IHttpTestRuntime { readonly config: ConfigType; readonly database: Sequelize; readonly transactionContext: ISequelizeTransactionContext; + readonly operationContext: IOperationContextStore; readonly audit: IAudit; request( path: string, @@ -136,6 +138,7 @@ export async function createHttpTestRuntime( baseUrl, config, database, + operationContext, transactionContext, audit, request: async (path: string, init?: RequestInit) => { diff --git a/apps/server/src/testing/http/payment-method-audit.integration.test.ts b/apps/server/src/testing/http/payment-method-audit.integration.test.ts new file mode 100644 index 00000000..e7c1d987 --- /dev/null +++ b/apps/server/src/testing/http/payment-method-audit.integration.test.ts @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { SequelizeTransactionManager } from "@erp/core/api"; +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize"; + +import { PaymentMethodUpdater } from "../../../../../modules/catalogs/src/api/application/payment-methods/services/payment-method-updater"; +import { SequelizePaymentMethodDomainMapper } from "../../../../../modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-domain.mapper"; +import { SequelizePaymentMethodSummaryMapper } from "../../../../../modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-summary.mapper"; +import { PaymentMethodModel } from "../../../../../modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/models/sequelize-payment-method.model"; +import { SequelizePaymentMethodRepository } from "../../../../../modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository"; + +import { + buildCompanyHeaders, + createTestAccount, + createTestCompany, + createTestMembership, + loginByApi, +} from "./http-test-fixtures"; +import { createHttpTestRuntime } from "./http-test-runtime"; + +test("payment methods registra create y update con el contexto HTTP en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + + const account = await createTestAccount(runtime.database, { + email: `payment-method-audit-${randomUUID()}@example.test`, + password: "Password123!", + }); + const company = await createTestCompany(runtime.database, { + legalName: "Payment method 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 paymentMethodId = randomUUID(); + + const createResponse = await runtime.request("/api/v1/catalogs/payment-methods", { + method: "POST", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + "x-correlation-id": "payment-method-create-correlation", + }, + body: JSON.stringify({ + id: paymentMethodId, + name: "Cash", + description: "Counter payment", + is_active: true, + }), + }); + assert.equal(createResponse.status, 201); + + const createdAudit = await AuditEventModel.findOne({ + where: { action: "payment_method.created", target_id: paymentMethodId }, + }); + assert.ok(createdAudit); + assert.equal(createdAudit.source_type, "application"); + assert.equal(createdAudit.source_id, "uecko-erp"); + assert.equal(createdAudit.origin, "http"); + assert.equal(createdAudit.operation_id, createResponse.headers.get("x-request-id")); + assert.equal(createdAudit.correlation_id, "payment-method-create-correlation"); + assert.equal(createdAudit.actor_type, "user"); + assert.equal(createdAudit.actor_id, account.id); + assert.equal(createdAudit.scope_type, "company"); + assert.equal(createdAudit.scope_id, company.id); + assert.equal(createdAudit.target_type, "payment_method"); + assert.equal(createdAudit.target_label, "Cash"); + + const updateResponse = await runtime.request( + `/api/v1/catalogs/payment-methods/${paymentMethodId}`, + { + method: "PUT", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + "x-correlation-id": "payment-method-update-correlation", + }, + body: JSON.stringify({ name: "Cash updated" }), + } + ); + assert.equal(updateResponse.status, 200); + + const updatedAudit = await AuditEventModel.findOne({ + where: { action: "payment_method.updated", target_id: paymentMethodId }, + }); + assert.ok(updatedAudit); + assert.equal(updatedAudit.target_label, "Cash updated"); + assert.equal(updatedAudit.actor_id, account.id); + assert.equal(updatedAudit.scope_id, company.id); + const changes = await AuditChangeModel.findAll({ + where: { audit_event_id: updatedAudit.id }, + order: [["position", "ASC"]], + }); + assert.deepEqual( + changes.map((change) => ({ + path: change.path, + kind: change.kind, + before: change.before_value, + after: change.after_value, + })), + [{ path: "name", kind: "change", before: "Cash", after: "Cash updated" }] + ); + + const secondPaymentMethodId = randomUUID(); + const secondCreateResponse = await runtime.request("/api/v1/catalogs/payment-methods", { + method: "POST", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ + id: secondPaymentMethodId, + name: "Card", + is_active: true, + }), + }); + assert.equal(secondCreateResponse.status, 201); + + const firstDefaultResponse = await runtime.request( + `/api/v1/catalogs/payment-methods/${paymentMethodId}/default`, + { + method: "PATCH", + headers: buildCompanyHeaders({ accessToken, companyId: company.id }), + } + ); + assert.equal(firstDefaultResponse.status, 200); + + const defaultResponse = await runtime.request( + `/api/v1/catalogs/payment-methods/${secondPaymentMethodId}/default`, + { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "x-correlation-id": "payment-method-default-correlation", + }, + } + ); + assert.equal(defaultResponse.status, 200); + + const defaultEvents = await AuditEventModel.findAll({ + where: { + action: ["payment_method.default_unset", "payment_method.default_set"], + operation_id: defaultResponse.headers.get("x-request-id") ?? "", + }, + order: [["action", "ASC"]], + }); + assert.equal(defaultEvents.length, 2); + const unsetEvent = defaultEvents.find((entry) => entry.action === "payment_method.default_unset"); + const setEvent = defaultEvents.find((entry) => entry.action === "payment_method.default_set"); + assert.equal(unsetEvent?.target_id, paymentMethodId); + assert.equal(setEvent?.target_id, secondPaymentMethodId); + assert.equal(unsetEvent?.correlation_id, "payment-method-default-correlation"); + assert.equal(setEvent?.correlation_id, "payment-method-default-correlation"); + assert.equal(unsetEvent?.actor_id, account.id); + assert.equal(setEvent?.scope_id, company.id); + + const [unsetChange] = await AuditChangeModel.findAll({ + where: { audit_event_id: unsetEvent?.id ?? "" }, + }); + const [setChange] = await AuditChangeModel.findAll({ + where: { audit_event_id: setEvent?.id ?? "" }, + }); + assert.deepEqual( + { path: unsetChange?.path, before: unsetChange?.before_value, after: unsetChange?.after_value }, + { path: "isDefault", before: true, after: false } + ); + assert.deepEqual( + { path: setChange?.path, before: setChange?.before_value, after: setChange?.after_value }, + { path: "isDefault", before: false, after: true } + ); + + const originalName = "Cash updated"; + const rollbackName = "Cash rollback attempt"; + const repository = new SequelizePaymentMethodRepository( + new SequelizePaymentMethodDomainMapper(), + new SequelizePaymentMethodSummaryMapper(), + runtime.database + ); + const updater = new PaymentMethodUpdater(repository, runtime.audit); + const transactionManager = new SequelizeTransactionManager( + runtime.database, + runtime.transactionContext + ); + const paymentMethodIdResult = UniqueID.create(paymentMethodId); + const rollbackNameResult = Name.create(rollbackName); + assert.equal(paymentMethodIdResult.isSuccess, true); + assert.equal(rollbackNameResult.isSuccess, true); + const changesBeforeRollback = await AuditChangeModel.count(); + + await assert.rejects( + () => + runtime.operationContext.run( + { + operationId: "payment-method-rollback-operation", + correlationId: "payment-method-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: paymentMethodIdResult.data, + patchProps: { name: rollbackNameResult.data }, + transaction, + }); + assert.equal(updateResult.isSuccess, true); + return Result.fail(new Error("forced payment method rollback")); + }) + ), + /forced payment method rollback/ + ); + + const rolledBackPaymentMethod = await PaymentMethodModel.findByPk(paymentMethodId); + assert.equal(rolledBackPaymentMethod?.name, originalName); + assert.equal( + await AuditEventModel.count({ + where: { operation_id: "payment-method-rollback-operation" }, + }), + 0 + ); + assert.equal(await AuditChangeModel.count(), changesBeforeRollback); +}); diff --git a/modules/catalogs/package.json b/modules/catalogs/package.json index d3f9e7fb..64936a50 100644 --- a/modules/catalogs/package.json +++ b/modules/catalogs/package.json @@ -37,6 +37,7 @@ "@erp/identity": "workspace:*", "@repo/i18next": "workspace:*", "@repo/rdx-criteria": "workspace:*", + "@repo/rdx-audit": "workspace:*", "@repo/rdx-ddd": "workspace:*", "@repo/rdx-logger": "workspace:*", "@repo/rdx-ui": "workspace:*", @@ -52,4 +53,4 @@ "use-debounce": "^10.1.1", "zod": "^4.3.6" } -} \ No newline at end of file +} diff --git a/modules/catalogs/src/api/application/payment-methods/audit/payment-method-audit.ts b/modules/catalogs/src/api/application/payment-methods/audit/payment-method-audit.ts new file mode 100644 index 00000000..6fd09718 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/audit/payment-method-audit.ts @@ -0,0 +1,33 @@ +import type { AuditMutationTarget, AuditState } from "@repo/rdx-audit"; + +import type { PaymentMethod } from "../../../domain"; + +export const PAYMENT_METHOD_AUDIT_ACTIONS = { + CREATED: "payment_method.created", + UPDATED: "payment_method.updated", + ENABLED: "payment_method.enabled", + DISABLED: "payment_method.disabled", + DELETED: "payment_method.deleted", + DEFAULT_SET: "payment_method.default_set", + DEFAULT_UNSET: "payment_method.default_unset", +} as const; + +/** Proyección estable del estado funcional, sin detalles ORM ni de infraestructura. */ +export const toPaymentMethodAuditState = (paymentMethod: PaymentMethod) => + ({ + name: paymentMethod.name.toPrimitive(), + description: paymentMethod.description.match( + (description) => description.toPrimitive(), + () => null + ), + isActive: paymentMethod.isActive, + isSystem: paymentMethod.isSystem, + isDefault: paymentMethod.isDefault, + }) satisfies AuditState; + +/** El label conserva el nombre visible del recurso en el momento de la mutación. */ +export const toPaymentMethodAuditTarget = (paymentMethod: PaymentMethod): AuditMutationTarget => ({ + type: "payment_method", + id: paymentMethod.id.toPrimitive(), + label: paymentMethod.name.toPrimitive(), +}); diff --git a/modules/catalogs/src/api/application/payment-methods/di/payment-method-creator.di.ts b/modules/catalogs/src/api/application/payment-methods/di/payment-method-creator.di.ts index 1e783fed..bf8c2075 100644 --- a/modules/catalogs/src/api/application/payment-methods/di/payment-method-creator.di.ts +++ b/modules/catalogs/src/api/application/payment-methods/di/payment-method-creator.di.ts @@ -1,10 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IPaymentMethodRepository } from "../repositories"; import { type IPaymentMethodCreator, PaymentMethodCreator } from "../services"; export const buildPaymentMethodCreator = (params: { repository: IPaymentMethodRepository; + audit: IAudit; }): IPaymentMethodCreator => { - const { repository } = params; + const { repository, audit } = params; - return new PaymentMethodCreator(repository); + return new PaymentMethodCreator(repository, audit); }; diff --git a/modules/catalogs/src/api/application/payment-methods/di/payment-method-deleter.di.ts b/modules/catalogs/src/api/application/payment-methods/di/payment-method-deleter.di.ts index 5f5ecf6b..a3e74422 100644 --- a/modules/catalogs/src/api/application/payment-methods/di/payment-method-deleter.di.ts +++ b/modules/catalogs/src/api/application/payment-methods/di/payment-method-deleter.di.ts @@ -1,15 +1,19 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IPaymentMethodRepository } from "../repositories"; -import { type IPaymentMethodDeleter, PaymentMethodDeleter } from "../services"; import type { IPaymentMethodUsageChecker } from "../services"; +import { type IPaymentMethodDeleter, PaymentMethodDeleter } from "../services"; export const buildPaymentMethodDeleter = (params: { repository: IPaymentMethodRepository; usageChecker: IPaymentMethodUsageChecker; + audit: IAudit; }): IPaymentMethodDeleter => { - const { repository, usageChecker } = params; + const { repository, usageChecker, audit } = params; return new PaymentMethodDeleter({ repository, usageChecker, + audit, }); }; diff --git a/modules/catalogs/src/api/application/payment-methods/di/payment-method-status-changer.di.ts b/modules/catalogs/src/api/application/payment-methods/di/payment-method-status-changer.di.ts index a229b0f7..c21d48b3 100644 --- a/modules/catalogs/src/api/application/payment-methods/di/payment-method-status-changer.di.ts +++ b/modules/catalogs/src/api/application/payment-methods/di/payment-method-status-changer.di.ts @@ -1,10 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IPaymentMethodRepository } from "../repositories"; import { type IPaymentMethodStatusChanger, PaymentMethodStatusChanger } from "../services"; export const buildPaymentMethodStatusChanger = (params: { repository: IPaymentMethodRepository; + audit: IAudit; }): IPaymentMethodStatusChanger => { - const { repository } = params; + const { repository, audit } = params; - return new PaymentMethodStatusChanger(repository); + return new PaymentMethodStatusChanger(repository, audit); }; diff --git a/modules/catalogs/src/api/application/payment-methods/di/payment-method-updater.di.ts b/modules/catalogs/src/api/application/payment-methods/di/payment-method-updater.di.ts index 87197ca7..4d9fb85a 100644 --- a/modules/catalogs/src/api/application/payment-methods/di/payment-method-updater.di.ts +++ b/modules/catalogs/src/api/application/payment-methods/di/payment-method-updater.di.ts @@ -1,10 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IPaymentMethodRepository } from "../repositories"; import { type IPaymentMethodUpdater, PaymentMethodUpdater } from "../services"; export const buildPaymentMethodUpdater = (params: { repository: IPaymentMethodRepository; + audit: IAudit; }): IPaymentMethodUpdater => { - const { repository } = params; + const { repository, audit } = params; - return new PaymentMethodUpdater(repository); + return new PaymentMethodUpdater(repository, audit); }; diff --git a/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts b/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts index c3344624..cbf13dd2 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts @@ -1,15 +1,37 @@ -import { describe, test } from "node:test"; 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 { Name, UniqueID } from "@repo/rdx-ddd"; import { Collection, Maybe, Result } from "@repo/rdx-utils"; import { PaymentMethod } from "../../../../domain/payment-methods/payment-method.aggregate"; -import { PaymentMethodDeleter } from "../payment-method-deleter"; -import { SetDefaultPaymentMethodService } from "../set-default-payment-method.service"; -import type { IPaymentMethodRepository } from "../../repositories"; import type { PaymentMethodSummary } from "../../models"; +import type { IPaymentMethodRepository } from "../../repositories"; +import { PaymentMethodDeleter } from "../payment-method-deleter"; +import { PaymentMethodStatusChanger } from "../payment-method-status-changer"; +import { PaymentMethodUpdater } from "../payment-method-updater"; +import { SetDefaultPaymentMethodService } from "../set-default-payment-method.service"; + +class AuditSpy implements IAudit { + public readonly mutations: RecordAuditMutationShortcutInput[] = []; + + async record(): Promise> { + return Result.ok(); + } + + async recordEvent(): Promise> { + return Result.ok(); + } + + async recordMutation(input: RecordAuditMutationShortcutInput): Promise> { + this.mutations.push(input); + return Result.ok(); + } +} + +const audit = new AuditSpy(); class InMemoryPaymentMethodRepository implements IPaymentMethodRepository { private readonly items = new Map(); @@ -126,9 +148,113 @@ const buildPaymentMethod = (params: { }; describe("Payment method services", () => { + test("enable registra la transición activa", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const audit = new AuditSpy(); + const companyId = UniqueID.generateNewID(); + const paymentMethod = buildPaymentMethod({ companyId, name: "Card", isActive: false }); + await repository.create(paymentMethod); + + const result = await new PaymentMethodStatusChanger(repository, audit).changeStatus({ + paymentMethod, + action: "enable", + }); + + assert.equal(result.isSuccess, true); + assert.equal(audit.mutations.length, 1); + assert.deepEqual(audit.mutations[0], { + action: "payment_method.enabled", + target: { type: "payment_method", id: paymentMethod.id.toPrimitive(), label: "Card" }, + before: { + name: "Card", + description: null, + isActive: false, + isSystem: false, + isDefault: false, + }, + after: { name: "Card", description: null, isActive: true, isSystem: false, isDefault: false }, + }); + }); + + test("disable rechaza métodos de sistema sin auditar", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const audit = new AuditSpy(); + const paymentMethod = buildPaymentMethod({ + companyId: UniqueID.generateNewID(), + name: "System", + isSystem: true, + }); + const result = await new PaymentMethodStatusChanger(repository, audit).changeStatus({ + paymentMethod, + action: "disable", + }); + assert.equal(result.isFailure, true); + assert.equal(audit.mutations.length, 0); + }); + + test("disable registra la transición inactiva", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const audit = new AuditSpy(); + const paymentMethod = buildPaymentMethod({ companyId: UniqueID.generateNewID(), name: "Cash" }); + const result = await new PaymentMethodStatusChanger(repository, audit).changeStatus({ + paymentMethod, + action: "disable", + }); + assert.equal(result.isSuccess, true); + assert.equal(audit.mutations.length, 1); + assert.equal(audit.mutations[0]?.action, "payment_method.disabled"); + assert.equal(audit.mutations[0]?.before.isActive, true); + assert.equal(audit.mutations[0]?.after.isActive, false); + }); + + test("update rechaza métodos de sistema sin auditar", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const audit = new AuditSpy(); + const companyId = UniqueID.generateNewID(); + const paymentMethod = buildPaymentMethod({ companyId, name: "System", isSystem: true }); + await repository.create(paymentMethod); + const name = Name.create("Changed"); + assert.equal(name.isSuccess, true); + const result = await new PaymentMethodUpdater(repository, audit).update({ + companyId, + id: paymentMethod.id, + patchProps: { name: name.data }, + }); + assert.equal(result.isFailure, true); + assert.equal(audit.mutations.length, 0); + }); + + test("delete registra estado completo y no audita rechazos", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const audit = new AuditSpy(); + const paymentMethod = buildPaymentMethod({ companyId: UniqueID.generateNewID(), name: "Cash" }); + await repository.create(paymentMethod); + const deleter = new PaymentMethodDeleter({ + repository, + audit, + usageChecker: { findUsage: async () => Result.ok([]) }, + }); + const result = await deleter.delete({ paymentMethod }); + assert.equal(result.isSuccess, true); + assert.equal(audit.mutations.length, 1); + assert.deepEqual(audit.mutations[0]?.target, { + type: "payment_method", + id: paymentMethod.id.toPrimitive(), + label: "Cash", + }); + assert.deepEqual(audit.mutations[0]?.before, { + name: "Cash", + description: null, + isActive: true, + isSystem: false, + isDefault: false, + }); + assert.deepEqual(audit.mutations[0]?.after, {}); + assert.equal(audit.mutations[0]?.action, "payment_method.deleted"); + }); test("set default swaps previous default atomically in repository state", async () => { const repository = new InMemoryPaymentMethodRepository(); - const service = new SetDefaultPaymentMethodService(repository); + const service = new SetDefaultPaymentMethodService(repository, audit); const companyId = UniqueID.generateNewID(); const cash = buildPaymentMethod({ @@ -162,7 +288,7 @@ describe("Payment method services", () => { test("set default is idempotent when method is already default", async () => { const repository = new InMemoryPaymentMethodRepository(); - const service = new SetDefaultPaymentMethodService(repository); + const service = new SetDefaultPaymentMethodService(repository, audit); const companyId = UniqueID.generateNewID(); const cash = buildPaymentMethod({ @@ -184,7 +310,7 @@ describe("Payment method services", () => { test("set default rejects inactive methods", async () => { const repository = new InMemoryPaymentMethodRepository(); - const service = new SetDefaultPaymentMethodService(repository); + const service = new SetDefaultPaymentMethodService(repository, audit); const companyId = UniqueID.generateNewID(); const inactive = buildPaymentMethod({ @@ -205,7 +331,7 @@ describe("Payment method services", () => { test("set default preserves company isolation", async () => { const repository = new InMemoryPaymentMethodRepository(); - const service = new SetDefaultPaymentMethodService(repository); + const service = new SetDefaultPaymentMethodService(repository, audit); const companyA = UniqueID.generateNewID(); const companyB = UniqueID.generateNewID(); @@ -226,11 +352,13 @@ describe("Payment method services", () => { test("delete blocks referenced payment methods", async () => { const repository = new InMemoryPaymentMethodRepository(); + const audit = new AuditSpy(); const deleter = new PaymentMethodDeleter({ repository, usageChecker: { findUsage: async () => Result.ok([{ source: "customers" }]), }, + audit, }); const companyId = UniqueID.generateNewID(); const paymentMethod = buildPaymentMethod({ @@ -245,15 +373,21 @@ describe("Payment method services", () => { }); assert.equal(result.isFailure, true); + assert.equal(audit.mutations.length, 0); + + const persistedPaymentMethod = await repository.getByIdInCompany(companyId, paymentMethod.id); + assert.equal(persistedPaymentMethod.isSuccess, true); }); test("delete blocks system payment methods", async () => { const repository = new InMemoryPaymentMethodRepository(); + const audit = new AuditSpy(); const deleter = new PaymentMethodDeleter({ repository, usageChecker: { findUsage: async () => Result.ok([]), }, + audit, }); const companyId = UniqueID.generateNewID(); const paymentMethod = buildPaymentMethod({ @@ -269,5 +403,9 @@ describe("Payment method services", () => { }); assert.equal(result.isFailure, true); + assert.equal(audit.mutations.length, 0); + + const persistedPaymentMethod = await repository.getByIdInCompany(companyId, paymentMethod.id); + assert.equal(persistedPaymentMethod.isSuccess, true); }); }); diff --git a/modules/catalogs/src/api/application/payment-methods/services/index.ts b/modules/catalogs/src/api/application/payment-methods/services/index.ts index 2658804f..74f580d6 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/index.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/index.ts @@ -4,7 +4,7 @@ export * from "./payment-method-disabler"; export * from "./payment-method-enabler"; export * from "./payment-method-finder"; export * from "./payment-method-public-services"; -export * from "./payment-method-usage-checker"; export * from "./payment-method-status-changer"; export * from "./payment-method-updater"; +export * from "./payment-method-usage-checker"; export * from "./set-default-payment-method.service"; diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-creator.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-creator.ts index 0cf6e24b..bc69b0ab 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-creator.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-creator.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 IPaymentMethodCreateProps, PaymentMethod } from "../../../domain"; +import { + PAYMENT_METHOD_AUDIT_ACTIONS, + toPaymentMethodAuditState, + toPaymentMethodAuditTarget, +} from "../audit/payment-method-audit"; import type { IPaymentMethodRepository } from "../repositories"; export interface IPaymentMethodCreatorParams { @@ -16,7 +22,10 @@ export interface IPaymentMethodCreator { } export class PaymentMethodCreator implements IPaymentMethodCreator { - constructor(private readonly repository: IPaymentMethodRepository) {} + constructor( + private readonly repository: IPaymentMethodRepository, + private readonly audit: IAudit + ) {} async create(params: IPaymentMethodCreatorParams): Promise> { const { companyId, id, props, transaction } = params; @@ -36,6 +45,14 @@ export class PaymentMethodCreator implements IPaymentMethodCreator { return Result.fail(saveResult.error); } + const auditResult = await this.audit.recordMutation({ + action: PAYMENT_METHOD_AUDIT_ACTIONS.CREATED, + target: toPaymentMethodAuditTarget(paymentMethod), + before: {}, + after: toPaymentMethodAuditState(paymentMethod), + }); + if (auditResult.isFailure) return Result.fail(auditResult.error); + return Result.ok(paymentMethod); } } diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-deleter.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-deleter.ts index 1c177fae..44a4c3a6 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-deleter.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-deleter.ts @@ -1,8 +1,15 @@ +import type { IAudit } from "@repo/rdx-audit"; import { Result } from "@repo/rdx-utils"; import type { PaymentMethod } from "../../../domain"; import { PaymentMethodInUseError } from "../../../domain"; +import { + PAYMENT_METHOD_AUDIT_ACTIONS, + toPaymentMethodAuditState, + toPaymentMethodAuditTarget, +} from "../audit/payment-method-audit"; import type { IPaymentMethodRepository } from "../repositories"; + import type { IPaymentMethodUsageChecker } from "./payment-method-usage-checker"; export interface IPaymentMethodDeleter { @@ -17,6 +24,7 @@ export class PaymentMethodDeleter implements IPaymentMethodDeleter { private readonly deps: { repository: IPaymentMethodRepository; usageChecker: IPaymentMethodUsageChecker; + audit: IAudit; } ) {} @@ -25,6 +33,8 @@ export class PaymentMethodDeleter implements IPaymentMethodDeleter { transaction?: unknown; }): Promise> { const { paymentMethod, transaction } = params; + const before = toPaymentMethodAuditState(paymentMethod); + const target = toPaymentMethodAuditTarget(paymentMethod); const invariantResult = paymentMethod.ensureCanBeDeleted(); if (invariantResult.isFailure) { @@ -60,6 +70,14 @@ export class PaymentMethodDeleter implements IPaymentMethodDeleter { return Result.fail(deleteResult.error); } + const auditResult = await this.deps.audit.recordMutation({ + action: PAYMENT_METHOD_AUDIT_ACTIONS.DELETED, + target, + before, + after: {}, + }); + if (auditResult.isFailure) return Result.fail(auditResult.error); + return Result.ok(paymentMethod); } } diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-status-changer.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-status-changer.ts index d91c206d..04be242c 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-status-changer.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-status-changer.ts @@ -1,6 +1,12 @@ +import type { IAudit } from "@repo/rdx-audit"; import { Result } from "@repo/rdx-utils"; import type { PaymentMethod } from "../../../domain"; +import { + PAYMENT_METHOD_AUDIT_ACTIONS, + toPaymentMethodAuditState, + toPaymentMethodAuditTarget, +} from "../audit/payment-method-audit"; import type { IPaymentMethodRepository } from "../repositories"; export type PaymentMethodStatusChangeAction = "enable" | "disable"; @@ -14,7 +20,10 @@ export interface IPaymentMethodStatusChanger { } export class PaymentMethodStatusChanger implements IPaymentMethodStatusChanger { - public constructor(private readonly repository: IPaymentMethodRepository) {} + public constructor( + private readonly repository: IPaymentMethodRepository, + private readonly audit: IAudit + ) {} public async changeStatus(params: { paymentMethod: PaymentMethod; @@ -22,6 +31,7 @@ export class PaymentMethodStatusChanger implements IPaymentMethodStatusChanger { transaction?: unknown; }): Promise> { const { paymentMethod, action, transaction } = params; + const before = toPaymentMethodAuditState(paymentMethod); const statusResult = action === "enable" ? paymentMethod.enable() : paymentMethod.disable(); @@ -39,6 +49,17 @@ export class PaymentMethodStatusChanger implements IPaymentMethodStatusChanger { return Result.fail(persistenceResult.error); } + const auditResult = await this.audit.recordMutation({ + action: + action === "enable" + ? PAYMENT_METHOD_AUDIT_ACTIONS.ENABLED + : PAYMENT_METHOD_AUDIT_ACTIONS.DISABLED, + target: toPaymentMethodAuditTarget(paymentMethod), + before, + after: toPaymentMethodAuditState(paymentMethod), + }); + if (auditResult.isFailure) return Result.fail(auditResult.error); + return Result.ok(paymentMethod); } } diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-updater.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-updater.ts index 2d4d5513..37df1a32 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-updater.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-updater.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 { PaymentMethod, PaymentMethodPatchProps } from "../../../domain"; +import { + PAYMENT_METHOD_AUDIT_ACTIONS, + toPaymentMethodAuditState, + toPaymentMethodAuditTarget, +} from "../audit/payment-method-audit"; import type { IPaymentMethodRepository } from "../repositories"; export interface IPaymentMethodUpdater { @@ -14,7 +20,10 @@ export interface IPaymentMethodUpdater { } export class PaymentMethodUpdater implements IPaymentMethodUpdater { - constructor(private readonly repository: IPaymentMethodRepository) {} + constructor( + private readonly repository: IPaymentMethodRepository, + private readonly audit: IAudit + ) {} async update(params: { companyId: UniqueID; @@ -32,6 +41,7 @@ export class PaymentMethodUpdater implements IPaymentMethodUpdater { } const paymentMethod = existingResult.data; + const before = toPaymentMethodAuditState(paymentMethod); // Aplicar cambios en el agregado const updateResult = paymentMethod.update(patchProps); @@ -54,6 +64,14 @@ export class PaymentMethodUpdater implements IPaymentMethodUpdater { return Result.fail(saveResult.error); } + const auditResult = await this.audit.recordMutation({ + action: PAYMENT_METHOD_AUDIT_ACTIONS.UPDATED, + target: toPaymentMethodAuditTarget(paymentMethod), + before, + after: toPaymentMethodAuditState(paymentMethod), + }); + if (auditResult.isFailure) return Result.fail(auditResult.error); + return Result.ok(paymentMethod); } } diff --git a/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts b/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts index 3d8d1bf6..55650f83 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.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 { PaymentMethodCannotBeDefaultError, type PaymentMethod } from "../../../domain"; +import { type PaymentMethod, PaymentMethodCannotBeDefaultError } from "../../../domain"; +import { + PAYMENT_METHOD_AUDIT_ACTIONS, + toPaymentMethodAuditState, + toPaymentMethodAuditTarget, +} from "../audit/payment-method-audit"; import type { IPaymentMethodRepository } from "../repositories"; export interface ISetDefaultPaymentMethodService { @@ -13,7 +19,10 @@ export interface ISetDefaultPaymentMethodService { } export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodService { - public constructor(private readonly repository: IPaymentMethodRepository) {} + public constructor( + private readonly repository: IPaymentMethodRepository, + private readonly audit: IAudit + ) {} public async setDefault(params: { companyId: UniqueID; @@ -56,6 +65,7 @@ export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodS const currentDefault = currentDefaultResult.data.getOrUndefined() ?? null; if (currentDefault && currentDefault.id.toString() !== paymentMethod.id.toString()) { + const previousDefaultState = toPaymentMethodAuditState(currentDefault); const unmarkResult = currentDefault.unmarkAsDefault(); if (unmarkResult.isFailure) { return Result.fail(unmarkResult.error); @@ -66,9 +76,17 @@ export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodS if (saveCurrentResult.isFailure) { return Result.fail(saveCurrentResult.error); } + const auditResult = await this.audit.recordMutation({ + action: PAYMENT_METHOD_AUDIT_ACTIONS.DEFAULT_UNSET, + target: toPaymentMethodAuditTarget(currentDefault), + before: previousDefaultState, + after: toPaymentMethodAuditState(currentDefault), + }); + if (auditResult.isFailure) return Result.fail(auditResult.error); } } + const previousPaymentMethodState = toPaymentMethodAuditState(paymentMethod); const markResult = paymentMethod.markAsDefault(); if (markResult.isFailure) { return Result.fail(markResult.error); @@ -79,6 +97,13 @@ export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodS if (savePaymentMethodResult.isFailure) { return Result.fail(savePaymentMethodResult.error); } + const auditResult = await this.audit.recordMutation({ + action: PAYMENT_METHOD_AUDIT_ACTIONS.DEFAULT_SET, + target: toPaymentMethodAuditTarget(paymentMethod), + before: previousPaymentMethodState, + after: toPaymentMethodAuditState(paymentMethod), + }); + if (auditResult.isFailure) return Result.fail(auditResult.error); } return Result.ok(paymentMethod); diff --git a/modules/catalogs/src/api/application/payment-methods/use-cases/set-default-payment-method-by-id.use-case.ts b/modules/catalogs/src/api/application/payment-methods/use-cases/set-default-payment-method-by-id.use-case.ts index 85e7ff3e..bdee34eb 100644 --- a/modules/catalogs/src/api/application/payment-methods/use-cases/set-default-payment-method-by-id.use-case.ts +++ b/modules/catalogs/src/api/application/payment-methods/use-cases/set-default-payment-method-by-id.use-case.ts @@ -2,8 +2,8 @@ import type { ITransactionManager } from "@erp/core/api"; import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import type { IPaymentMethodFullSnapshotBuilder } from "../snapshot-builders"; import type { ISetDefaultPaymentMethodService } from "../services"; +import type { IPaymentMethodFullSnapshotBuilder } from "../snapshot-builders"; export type SetDefaultPaymentMethodByIdUseCaseInput = { companyId: UniqueID; diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/di/payment-methods.di.ts b/modules/catalogs/src/api/infrastructure/payment-methods/di/payment-methods.di.ts index 406121ba..36de4106 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/di/payment-methods.di.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/di/payment-methods.di.ts @@ -25,10 +25,10 @@ import { ListPaymentMethodsUseCase, UpdatePaymentMethodByIdUseCase, } from "../../../application/payment-methods/use-cases"; +import { buildPaymentMethodReferenceUsageChecker } from "../payment-method-reference-usage-checker"; import { buildPaymentMethodPersistenceMappers } from "./payment-method-persistence-mappers.di"; import { buildPaymentMethodRepository } from "./payment-method-repositories.di"; -import { buildPaymentMethodReferenceUsageChecker } from "../payment-method-reference-usage-checker"; export type PaymentMethodsInternalDeps = { repository: IPaymentMethodRepository; @@ -47,7 +47,7 @@ export type PaymentMethodsInternalDeps = { export const buildPaymentMethodsDependencies = ( params: ModuleParams ): PaymentMethodsInternalDeps => { - const { database, transactionContext } = params; + const { audit, database, transactionContext } = params; // Infrastructure const transactionManager = buildTransactionManager(database as Sequelize, transactionContext); @@ -59,11 +59,11 @@ export const buildPaymentMethodsDependencies = ( // Application helpers const inputMappers = buildPaymentMethodInputMappers(); const finder = buildPaymentMethodFinder({ repository }); - const creator = buildPaymentMethodCreator({ repository }); - const updater = buildPaymentMethodUpdater({ repository }); - const deleter = buildPaymentMethodDeleter({ repository, usageChecker }); - const statusChanger = buildPaymentMethodStatusChanger({ repository }); - const setDefaultService = new SetDefaultPaymentMethodService(repository); + const creator = buildPaymentMethodCreator({ repository, audit }); + const updater = buildPaymentMethodUpdater({ repository, audit }); + const deleter = buildPaymentMethodDeleter({ repository, usageChecker, audit }); + const statusChanger = buildPaymentMethodStatusChanger({ repository, audit }); + const setDefaultService = new SetDefaultPaymentMethodService(repository, audit); const snapshotBuilders = buildPaymentMethodSnapshotBuilders(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 880adbfc..028105ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: '@biomejs/biome': specifier: ^2.4.11 version: 2.4.11 + '@repo/rdx-ddd': + specifier: workspace:* + version: link:../../packages/rdx-ddd '@repo/typescript-config': specifier: workspace:* version: link:../../packages/typescript-config @@ -428,6 +431,9 @@ importers: '@repo/i18next': specifier: workspace:* version: link:../../packages/i18n + '@repo/rdx-audit': + specifier: workspace:* + version: link:../../packages/rdx-audit '@repo/rdx-criteria': specifier: workspace:* version: link:../../packages/rdx-criteria