diff --git a/AGENTS.md b/AGENTS.md index 6ef9196b..0774f7ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,8 @@ No introducir capas, adaptadores, factories, genéricos, eventos, contenedores D ## Comentarios y documentación -Los comentarios deben explicar: +### Los comentarios +Deben explicar: - por qué existe una decisión; - qué riesgo evita; @@ -116,7 +117,12 @@ Documentar mediante TSDoc las APIs públicas cuando aporten información sobre: Después de un refactor, actualizar o eliminar los comentarios que hayan quedado obsoletos. -La documentación del proyecto debe redactarse en castellano. +### La documentación del proyecto + +- debe redactarse en castellano. +- Reside en /docs. +- Utilizar ficheros Markdown +- Los nombres de los ficheros deben estar en castellano. Reglas de estilo para documentación: diff --git a/apps/server/src/testing/http/proforma-creator.integration.test.ts b/apps/server/src/testing/http/proforma-creator.integration.test.ts new file mode 100644 index 00000000..01b5f949 --- /dev/null +++ b/apps/server/src/testing/http/proforma-creator.integration.test.ts @@ -0,0 +1,707 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import type { CatalogsPublicServicesType } from "@erp/catalogs/api"; +import type { ICompanyPublicServices } from "@erp/companies/api"; +import { SequelizeTransactionManager } from "@erp/core/api"; +import type { ICustomerPublicServices } from "@erp/customers/api"; +import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; +import type { + IAudit, + RecordAuditEventShortcutInput, + RecordAuditInput, + RecordAuditMutationShortcutInput, +} from "@repo/rdx-audit"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize"; + +import { TaxRegimeModel } from "../../../../../modules/catalogs/src/api/infrastructure/tax-regimes/persistence/sequelize/models/sequelize-tax-regime.model"; +import { + buildDuplicateProformaByIdUseCase, + buildProformaArchiver, + buildProformaCatalogResolvers, + buildProformaCreator, + buildProformaDeleter, + buildProformaDuplicator, + buildProformaFinder, + buildProformaInputMappers, + buildProformaReadModelAssemblers, + buildProformaSnapshotBuilders, + buildProformaStatusChanger, + buildProformaUpdater, +} from "../../../../../modules/customer-invoices/src/api/application/proformas"; +import { + ProformaItemModel, + ProformaModel, + ProformaTaxModel, +} from "../../../../../modules/customer-invoices/src/api/infrastructure/common"; +import { buildV2ProformaPersistence } from "../../../../../modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-persistence.di"; +import { DocumentSeriesProformaSeriesValidator } from "../../../../../modules/customer-invoices/src/api/infrastructure/proformas/services/document-series-proforma-series-validator"; +import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../../../../../modules/customer-invoices/src/api/infrastructure/proformas/services/document-series-proforma-target-invoice-series-validator"; +import { CustomerModel } from "../../../../../modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model"; +import { DocumentSeriesModel } from "../../../../../modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/sequelize-document-series.model"; +import { getServiceScoped } from "../../lib/modules/service-registry"; + +import { + buildCompanyHeaders, + createTestAccount, + createTestCompany, + createTestMembership, + loginByApi, +} from "./http-test-fixtures"; +import { createHttpTestRuntime } from "./http-test-runtime"; + +class FailingAudit implements IAudit { + public recordMutationCalls = 0; + + public async record(_input: RecordAuditInput) { + throw new Error("Audit record should not be reached"); + } + + public async recordEvent(_input: RecordAuditEventShortcutInput) { + throw new Error("Audit event should not be reached"); + } + + public async recordMutation(_input: RecordAuditMutationShortcutInput) { + this.recordMutationCalls += 1; + return Result.fail(new Error("Audit failed")); + } +} + +async function seedProformaDependencies( + runtime: Awaited> +) { + const company = await createTestCompany(runtime.database, { + legalName: "Proforma transaction company", + }); + const customerId = randomUUID(); + const documentSeriesId = randomUUID(); + + await CustomerModel.create({ + id: customerId, + company_id: company.id, + is_company: true, + name: "Customer Proforma", + tin: "ESB1234567", + street: "Calle Uno 1", + city: "Madrid", + province: "Madrid", + postal_code: "28001", + country: "ES", + status: "active", + language_code: "es", + currency_code: "EUR", + uses_equivalence_surcharge: false, + uses_retention: false, + payment_term_id: null, + tax_regime_code: null, + }); + await DocumentSeriesModel.create({ + id: documentSeriesId, + company_id: company.id, + branch_id: null, + document_type: "proforma", + code: "PF", + name: "Proformas", + next_number: 1, + padding: 0, + valid_from: null, + valid_to: null, + is_default: true, + is_active: true, + }); + await TaxRegimeModel.create({ + id: randomUUID(), + company_id: company.id, + code: "01", + description: "General", + is_active: true, + is_system: false, + }); + + return { company, customerId, documentSeriesId }; +} + +function buildFailingAuditCollaborators( + runtime: Awaited> +) { + const documentSeriesServices = getServiceScoped( + "customer-invoices", + ["document-series"], + "document-series:general" + ); + const customerServices = getServiceScoped( + "customer-invoices", + ["customers"], + "customers:general" + ); + const companiesServices = getServiceScoped( + "customer-invoices", + ["companies"], + "companies:general" + ); + const taxDefinitions = getServiceScoped( + "customer-invoices", + ["catalogs"], + "catalogs:taxDefinitions" + ); + const taxRegimes = getServiceScoped( + "customer-invoices", + ["catalogs"], + "catalogs:taxRegimes" + ); + const paymentMethods = getServiceScoped( + "customer-invoices", + ["catalogs"], + "catalogs:paymentMethods" + ); + const paymentTerms = getServiceScoped( + "customer-invoices", + ["catalogs"], + "catalogs:paymentTerms" + ); + const persistence = buildV2ProformaPersistence({ + database: runtime.database, + documentSeriesServices, + }); + const catalogResolvers = buildProformaCatalogResolvers({ + taxDefinitionFinder: taxDefinitions.finder, + taxRegimeFinder: taxRegimes.finder, + paymentMethodFinder: paymentMethods.finder, + paymentTermFinder: paymentTerms.finder, + }); + const audit = new FailingAudit(); + const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator( + documentSeriesServices + ); + + return { + audit, + catalogResolvers, + companiesServices, + customerServices, + documentSeriesServices, + paymentMethods, + paymentTerms, + persistence, + taxRegimes, + creator: buildProformaCreator({ + repository: persistence.repository, + numberService: persistence.numberGenerator, + taxResolver: catalogResolvers.taxResolver, + paymentResolver: catalogResolvers.paymentResolver, + proformaSeriesValidator: new DocumentSeriesProformaSeriesValidator(documentSeriesServices), + targetInvoiceSeriesValidator, + companiesServices, + customerServices, + audit, + }), + updater: buildProformaUpdater({ + repository: persistence.repository, + taxResolver: catalogResolvers.taxResolver, + paymentResolver: catalogResolvers.paymentResolver, + targetInvoiceSeriesValidator, + customerServices, + audit, + }), + archiver: buildProformaArchiver({ repository: persistence.repository, audit }), + deleter: buildProformaDeleter({ repository: persistence.repository, audit }), + statusChanger: buildProformaStatusChanger({ repository: persistence.repository, audit }), + }; +} + +function createProformaRequestDto(params: { + id: string; + customerId: string; + description?: string; +}) { + return { + id: params.id, + proforma_date: "2026-08-25", + customer_id: params.customerId, + description: params.description, + language_code: "es", + currency_code: "EUR", + global_discount_percentage: { value: "0", scale: "2" }, + payment_method_id: null, + payment_term_id: null, + tax_regime_code: "01", + items: [], + }; +} + +async function createCommittedProforma(runtime: Awaited>) { + const { company, customerId } = await seedProformaDependencies(runtime); + const account = await createTestAccount(runtime.database, { + email: `proforma-lifecycle-${randomUUID()}@example.test`, + password: "Password123!", + }); + await createTestMembership(runtime.database, { accountId: account.id, companyId: company.id }); + const login = await loginByApi({ + request: runtime.request, + email: account.email, + password: account.password, + }); + assert.equal(login.status, 200); + const accessToken = (login.body as { access_token: string }).access_token; + const id = randomUUID(); + const response = await runtime.request("/api/v1/proformas", { + method: "POST", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify(createProformaRequestDto({ id, customerId })), + }); + assert.equal(response.status, 201); + return { company, id, accessToken }; +} + +test("ProformaCreator persiste una Proforma mínima y registra Audit en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + + const account = await createTestAccount(runtime.database, { + email: `proforma-creator-${randomUUID()}@example.test`, + password: "Password123!", + }); + const company = await createTestCompany(runtime.database, { + legalName: "Proforma creator company", + }); + await createTestMembership(runtime.database, { + accountId: account.id, + companyId: company.id, + }); + + const customerId = randomUUID(); + await CustomerModel.create({ + id: customerId, + company_id: company.id, + is_company: true, + name: "Customer Proforma", + status: "active", + language_code: "es", + currency_code: "EUR", + uses_equivalence_surcharge: false, + uses_retention: false, + payment_term_id: null, + tax_regime_code: null, + }); + + await DocumentSeriesModel.create({ + id: randomUUID(), + company_id: company.id, + branch_id: null, + document_type: "proforma", + code: "PF", + name: "Proformas", + next_number: 1, + padding: 0, + valid_from: null, + valid_to: null, + is_default: true, + is_active: true, + }); + + await TaxRegimeModel.create({ + id: randomUUID(), + company_id: company.id, + code: "01", + description: "General", + is_active: true, + is_system: false, + }); + + 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 proformaId = randomUUID(); + + const response = await runtime.request("/api/v1/proformas", { + method: "POST", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ + id: proformaId, + proforma_date: "2026-08-25", + customer_id: customerId, + language_code: "es", + currency_code: "EUR", + global_discount_percentage: { value: "0", scale: "2" }, + payment_method_id: null, + payment_term_id: null, + tax_regime_code: "01", + items: [], + }), + }); + + assert.equal(response.status, 201); + const proforma = await ProformaModel.findByPk(proformaId); + assert.ok(proforma); + assert.equal(proforma.company_id, company.id); + assert.equal(proforma.proforma_number, "1"); + assert.ok(proforma.proforma_reference); + assert.equal(await ProformaItemModel.count({ where: { proforma_id: proformaId } }), 0); + assert.equal(await ProformaTaxModel.count({ where: { proforma_id: proformaId } }), 0); + + const auditEvent = await AuditEventModel.findOne({ + where: { action: "proforma.created", target_id: proformaId }, + }); + assert.ok(auditEvent); + assert.equal(auditEvent.target_type, "proforma"); +}); + +test("Duplicate persiste la copia y registra proforma.duplicated en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + const { company, id: sourceId, accessToken } = await createCommittedProforma(runtime); + const source = await ProformaModel.findByPk(sourceId); + assert.ok(source); + + const response = await runtime.request(`/api/v1/proformas/${sourceId}/duplicate`, { + method: "POST", + headers: buildCompanyHeaders({ accessToken, companyId: company.id }), + }); + + assert.equal(response.status, 201); + const duplicateId = (response.body as { id: string }).id; + const duplicate = await ProformaModel.findByPk(duplicateId); + assert.ok(duplicate); + assert.notEqual(duplicate.id, source.id); + assert.notEqual(duplicate.proforma_reference, source.proforma_reference); + assert.equal(duplicate.status, "draft"); + assert.equal(duplicate.company_id, company.id); + const duplicatedEvent = await AuditEventModel.findOne({ + where: { action: "proforma.duplicated", target_id: duplicateId }, + }); + assert.ok(duplicatedEvent); + assert.equal(duplicatedEvent.target_type, "proforma"); + assert.equal(duplicatedEvent.target_id, duplicateId); + assert.equal(duplicatedEvent.target_label, duplicate.proforma_reference); + assert.equal( + await AuditEventModel.count({ + where: { action: "proforma.duplicated", target_id: duplicateId }, + }), + 1 + ); + assert.equal( + await AuditEventModel.count({ where: { action: "proforma.created", target_id: duplicateId } }), + 0 + ); +}); + +test("Duplicate revierte la copia y numeración si Audit falla en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + + const { company, id: sourceId } = await createCommittedProforma(runtime); + const sourceBefore = await ProformaModel.findByPk(sourceId); + assert.ok(sourceBefore); + const sourceReference = sourceBefore.proforma_reference; + const seriesBefore = await DocumentSeriesModel.findOne({ + where: { company_id: company.id, document_type: "proforma" }, + }); + assert.ok(seriesBefore); + const proformasBefore = await ProformaModel.count({ where: { company_id: company.id } }); + const auditEventsBefore = await AuditEventModel.count(); + const auditChangesBefore = await AuditChangeModel.count(); + let failedDuplicateId: string | null = null; + let failedDuplicateReference: string | null = null; + + const { + audit, + persistence, + catalogResolvers, + companiesServices, + customerServices, + documentSeriesServices, + paymentMethods, + paymentTerms, + taxRegimes, + } = buildFailingAuditCollaborators(runtime); + const repository = new Proxy(persistence.repository, { + get(target, property, receiver) { + if (property === "create") { + return async (...args: Parameters) => { + const [proforma] = args; + failedDuplicateId = proforma.id.toPrimitive(); + failedDuplicateReference = proforma.proformaReference.toPrimitive(); + return target.create(...args); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const transactionManager = new SequelizeTransactionManager( + runtime.database, + runtime.transactionContext + ); + const duplicator = buildProformaDuplicator({ + repository, + numberService: persistence.numberGenerator, + taxResolver: catalogResolvers.taxResolver, + paymentResolver: catalogResolvers.paymentResolver, + proformaSeriesValidator: new DocumentSeriesProformaSeriesValidator(documentSeriesServices), + targetInvoiceSeriesValidator: new DocumentSeriesProformaTargetInvoiceSeriesValidator( + documentSeriesServices + ), + companiesServices, + customerServices, + documentSeriesServices, + paymentTermFinder: paymentTerms.finder, + audit, + }); + const useCase = buildDuplicateProformaByIdUseCase({ + duplicator, + finder: buildProformaFinder(repository), + fullReadModelAssembler: buildProformaReadModelAssemblers({ + paymentMethodFinder: paymentMethods.finder, + paymentTermFinder: paymentTerms.finder, + taxRegimeFinder: taxRegimes.finder, + }).full, + fullSnapshotBuilder: buildProformaSnapshotBuilders().full, + transactionManager, + }); + + await assert.rejects( + () => + useCase.execute({ + companyId: UniqueID.create(company.id).data, + params: { proforma_id: sourceId }, + }), + /Audit failed/ + ); + + assert.equal(audit.recordMutationCalls, 1); + assert.ok(failedDuplicateId); + assert.ok(failedDuplicateReference); + assert.ok(await ProformaModel.findByPk(sourceId)); + assert.equal(await ProformaModel.findByPk(failedDuplicateId), null); + assert.equal(await ProformaModel.count({ where: { company_id: company.id } }), proformasBefore); + assert.equal(await ProformaItemModel.count({ where: { proforma_id: failedDuplicateId } }), 0); + assert.equal(await ProformaTaxModel.count({ where: { proforma_id: failedDuplicateId } }), 0); + assert.equal(await AuditEventModel.count(), auditEventsBefore); + assert.equal(await AuditChangeModel.count(), auditChangesBefore); + assert.equal( + await AuditEventModel.count({ + where: { action: "proforma.duplicated", target_id: failedDuplicateId }, + }), + 0 + ); + assert.equal( + await AuditEventModel.count({ + where: { action: "proforma.created", target_id: failedDuplicateId }, + }), + 0 + ); + const seriesAfter = await DocumentSeriesModel.findOne({ + where: { company_id: company.id, document_type: "proforma" }, + }); + assert.ok(seriesAfter); + assert.equal(seriesAfter.next_number, seriesBefore.next_number); + assert.equal(sourceBefore.proforma_reference, sourceReference); +}); + +test("ProformaCreator revierte la creación si Audit falla en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + + const { company, customerId, documentSeriesId } = await seedProformaDependencies(runtime); + const proformaId = randomUUID(); + const companyIdResult = UniqueID.create(company.id); + assert.equal(companyIdResult.isSuccess, true); + const mappedInput = buildProformaInputMappers().createInputMapper.map( + createProformaRequestDto({ id: proformaId, customerId }), + { companyId: companyIdResult.data } + ); + assert.equal(mappedInput.isSuccess, true); + + const seriesBefore = await DocumentSeriesModel.findByPk(documentSeriesId); + assert.ok(seriesBefore); + const { audit, creator } = buildFailingAuditCollaborators(runtime); + const transactionManager = new SequelizeTransactionManager( + runtime.database, + runtime.transactionContext + ); + + await assert.rejects( + () => + transactionManager.complete(async (transaction) => + creator.create({ + companyId: companyIdResult.data, + id: mappedInput.data.id, + props: mappedInput.data.props, + transaction, + }) + ), + /Audit failed/ + ); + + assert.equal(audit.recordMutationCalls, 1); + assert.equal(await ProformaModel.count({ where: { id: proformaId } }), 0); + assert.equal(await ProformaItemModel.count({ where: { proforma_id: proformaId } }), 0); + assert.equal(await ProformaTaxModel.count({ where: { proforma_id: proformaId } }), 0); + assert.equal( + await AuditEventModel.count({ where: { action: "proforma.created", target_id: proformaId } }), + 0 + ); + assert.equal(await AuditChangeModel.count(), 0); + const seriesAfter = await DocumentSeriesModel.findByPk(documentSeriesId); + assert.ok(seriesAfter); + assert.equal(seriesAfter.next_number, seriesBefore.next_number); +}); + +test("ProformaUpdater revierte la actualización si Audit falla en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + + const { company, customerId } = await seedProformaDependencies(runtime); + const account = await createTestAccount(runtime.database, { + email: `proforma-updater-${randomUUID()}@example.test`, + password: "Password123!", + }); + 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 proformaId = randomUUID(); + const baselineResponse = await runtime.request("/api/v1/proformas", { + method: "POST", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify( + createProformaRequestDto({ + id: proformaId, + customerId, + description: "Before", + }) + ), + }); + assert.equal(baselineResponse.status, 201); + const baseline = await ProformaModel.findByPk(proformaId); + assert.ok(baseline); + assert.equal(baseline.description, "Before"); + const existingCreatedEvents = await AuditEventModel.count({ + where: { action: "proforma.created", target_id: proformaId }, + }); + const changesBefore = await AuditChangeModel.count(); + const companyIdResult = UniqueID.create(company.id); + const proformaIdResult = UniqueID.create(proformaId); + assert.equal(companyIdResult.isSuccess, true); + assert.equal(proformaIdResult.isSuccess, true); + const mappedPatch = buildProformaInputMappers().updateInputMapper.map( + { description: "After" }, + { companyId: companyIdResult.data } + ); + assert.equal(mappedPatch.isSuccess, true); + const { audit, updater } = buildFailingAuditCollaborators(runtime); + const transactionManager = new SequelizeTransactionManager( + runtime.database, + runtime.transactionContext + ); + + await assert.rejects( + () => + transactionManager.complete(async (transaction) => + updater.update({ + companyId: companyIdResult.data, + id: proformaIdResult.data, + patchProps: mappedPatch.data, + transaction, + }) + ), + /Audit failed/ + ); + + assert.equal(audit.recordMutationCalls, 1); + const persisted = await ProformaModel.findByPk(proformaId); + assert.ok(persisted); + assert.equal(persisted.description, "Before"); + assert.equal(persisted.id, baseline.id); + assert.equal(persisted.proforma_reference, baseline.proforma_reference); + assert.equal( + await AuditEventModel.count({ where: { action: "proforma.created", target_id: proformaId } }), + existingCreatedEvents + ); + assert.equal( + await AuditEventModel.count({ where: { action: "proforma.updated", target_id: proformaId } }), + 0 + ); + assert.equal(await AuditChangeModel.count(), changesBefore); +}); + +for (const scenario of [ + { name: "status", action: "proforma.status_changed", run: "statusChanger" }, + { name: "archive", action: "proforma.archived", run: "archiver" }, + { name: "unarchive", action: "proforma.unarchived", run: "archiver" }, + { name: "delete", action: "proforma.deleted", run: "deleter" }, +] as const) { + test(`Proforma ${scenario.name} revierte la operación si Audit falla en MariaDB`, async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + const { company, id } = await createCommittedProforma(runtime); + if (scenario.name === "unarchive") { + await ProformaModel.update( + { archived_at: new Date("2026-08-26T00:00:00.000Z") }, + { where: { id } } + ); + } + const companyId = UniqueID.create(company.id).data; + const proformaId = UniqueID.create(id).data; + const eventsBefore = await AuditEventModel.count({ + where: { action: scenario.action, target_id: id }, + }); + const { audit, statusChanger, archiver, deleter } = buildFailingAuditCollaborators(runtime); + const transactionManager = new SequelizeTransactionManager( + runtime.database, + runtime.transactionContext + ); + await assert.rejects( + () => + transactionManager.complete(async (transaction) => { + if (scenario.run === "statusChanger") + return statusChanger.changeStatus({ + companyId, + id: proformaId, + newStatus: "sent", + transaction, + }); + if (scenario.run === "deleter") + return deleter.delete({ companyId, id: proformaId, transaction }); + return scenario.name === "unarchive" + ? archiver.unarchive({ companyId, id: proformaId, transaction }) + : archiver.archive({ companyId, id: proformaId, transaction }); + }), + /Audit failed/ + ); + assert.equal(audit.recordMutationCalls, 1); + const persisted = await ProformaModel.findByPk(id); + assert.ok(persisted); + if (scenario.name === "status") assert.equal(persisted.status, "draft"); + if (scenario.name === "archive") assert.equal(persisted.archived_at, null); + if (scenario.name === "unarchive") assert.notEqual(persisted.archived_at, null); + assert.equal( + await AuditEventModel.count({ where: { action: scenario.action, target_id: id } }), + eventsBefore + ); + }); +} diff --git a/docs/README.md b/docs/README.md index ae33cc7a..2c1c70e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP. - Empieza por este índice si necesitas orientarte. - Usa los documentos en subdirectorios temáticos como fuente de verdad operativa. - Trata los documentos marcados como históricos o legacy como contexto, no como contrato vigente. +- Si hay duda sobre qué documento manda, consulta la [auditoría SSOT](./documentation-ssot-audit.md). ## Estructura documental @@ -15,6 +16,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP. #### Arquitectura y runtime - [Arquitectura general](./architecture/README.md) +- [Requisitos de `companies`](./companies/company-requirements.md) - [Contexto tenant y `X-Company-Id`](./architecture/tenant-context.md) - [Tratamiento de errores HTTP en frontend](./architecture/http-error-handling-frontend.md) - [API de autenticación de `identity`](./architecture/identity-auth-api.md) @@ -31,8 +33,10 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP. #### Customer invoices y proformas - [Índice de `customer-invoices`](./customer-invoices/README.md) +- [Auditoría SSOT documental](./documentation-ssot-audit.md) - [Plazos de pago](./catalogs/payment-terms.md) - [Diseño backend del perfil VeriFactu empresarial](./customer-invoices/settings/verifactu-company-profile-backend-design.md) + Documento parcialmente superado: sigue siendo canónico para `CompanyVerifactuProfile`, pero no para reintroducir `IssuedInvoice.verifactuPolicy`. - [Diseño frontend de Settings para VeriFactu](./customer-invoices/settings/frontend-erp-settings-verifactu-design.md) - [Contrato de series de proformas](./customer-invoices/proforma-series-contract.md) - [Contrato de create de proformas](./customer-invoices/proforma-create-contract.md) @@ -90,3 +94,4 @@ Estos documentos se conservan por trazabilidad, auditoría o contexto de decisio - Los documentos operativos vigentes deben vivir en subdirectorios temáticos y tener una responsabilidad clara. - Los documentos raíz en mayúsculas o con naming heredado deben considerarse legacy o auxiliares salvo que este índice los declare explícitamente vigentes. - Si una regla ya tiene fuente canónica, los demás documentos deben resumirla y enlazarla, no copiarla. +- Para VeriFactu e `IssuedInvoice`, el estado vigente es: sin `verifactuPolicy`, cardinalidad actual `1 -> 0..1`, objetivo futuro `1 -> 0..N`. diff --git a/docs/adit/Sistema-transversal-de-auditoría-erp.md b/docs/adit/Sistema-transversal-de-auditoría-erp.md deleted file mode 100644 index 0a079c21..00000000 --- a/docs/adit/Sistema-transversal-de-auditoría-erp.md +++ /dev/null @@ -1,779 +0,0 @@ -> **Documento histórico / deprecado** -> -> Este documento refleja un diseño previo del sistema transversal de auditoría y no constituye la especificación vigente. -> -> La referencia canónica y consolidada actual es: -> -> `docs/architecture/audit-system-design.md` -> -> Este fichero se conserva únicamente como referencia histórica. - -**DOCUMENTO DE DISEÑO** - -Sistema transversal de auditoría - -**Backend ERP · Estado consolidado** - -Versión de diseño: 1.0 -Fecha: 24 de agosto de 2026 -Estado: DISEÑO CERRADO PARA IMPLEMENTACIÓN - -**Propósito -**Definir una infraestructura de auditoría portable, append-only y fácil de integrar, capaz de registrar eventos y mutaciones con contexto de operación, preservar atomicidad con las transacciones Sequelize del ERP y soportar una futura pantalla administrativa con autorización por scopes o acceso global. - -# 1\. Resumen ejecutivo - -La auditoría se diseña como una capacidad técnica transversal, no como un módulo funcional del ERP. El núcleo portable vive en \`@repo/rdx-audit\`; el host configura una única instancia de \`Audit\` y la distribuye por \`ModuleParams\`, igual que \`database\` y \`logger\`. - -- \`IAudit.record()\` es la operación canónica. \`recordEvent()\` y \`recordMutation()\` son atajos tipados sin lógica alternativa. -- \`@repo/rdx-audit\` no depende de Company, tenant, Express, Sequelize, Criteria, roles, permisos ni del \`OperationContext\` concreto del ERP. -- El ámbito de un hecho se modela como \`scope: { type, id, label } | null\`. \`scope=null\` significa evento sin ámbito, nunca acceso global. -- La autorización de lectura es responsabilidad de la aplicación host y queda separada del scope almacenado en el evento. -- Las mutaciones auditadas en producción deben persistirse en la misma transacción Sequelize que la modificación funcional. -- \`SequelizeTransactionContext\` se introduce por DI y solo expone la transacción activa a infraestructura transversal; repositorios y casos de uso siguen recibiendo la transacción explícitamente. -- El store portable solo conoce \`append(entry)\`. \`SequelizeAuditStore\`, \`FileAuditStore\` o un store externo son adapters intercambiables. -- La lectura administrativa usa un \`SequelizeAuditReader\` server-side apoyado en \`@repo/rdx-criteria/server\`, con whitelist propia de filtros y una política de acceso separada. -- No se usa Event Sourcing. La auditoría tampoco sustituye a logs, tracing, historial de versiones de documentos ni BusinessVersion. - -| **Decisión** | **Resultado** | -| ------------------------------- | -------------------------------------------------------------- | -| Arquitectura | Paquete portable + adapters host-specific | -| Persistencia productiva inicial | MariaDB/Sequelize, tablas \`audit_events\` y \`audit_changes\` | -| Mutaciones | Atómicas con la transacción funcional | -| Eventos | Pueden persistirse con o sin transacción activa según contexto | -| Lectura | Reader server-side; no forma parte del core portable | -| Autorización | Fuera de \`rdx-audit\`; puede ser scoped o unrestricted | -| Adopción | Incremental por módulo y operación | - -# 2\. Objetivos y alcance - -Proporcionar un Audit Trail reutilizable para registrar quién o qué realizó una acción, en qué operación, dentro de qué ámbito, sobre qué recurso y qué cambió cuando exista una mutación. - -## 2.1 Objetivos específicos - -- Integración mínima en cualquier módulo mediante \`IAudit\`. -- Persistencia append-only e identidad propia de cada \`AuditEntry\`. -- Modelo portable y serializable compatible con múltiples stores. -- Diff determinista para mutaciones a partir de \`before\` y \`after\` controlados por el módulo propietario. -- Contexto automático de source, operación, actor y scope. -- Atomicidad fuerte con MariaDB/Sequelize para mutaciones funcionales. -- Lectura eficiente para una futura UI administrativa con filtros, detalle y paginación. -- Diseño compatible con un administrador limitado a uno o varios scopes y con un administrador global. - -## 2.2 Fuera de alcance - -- Event Sourcing o reconstrucción del estado de negocio a partir del audit log. -- BusinessVersion de presupuestos, proformas u otros documentos funcionales. -- DocumentVersion para binarios o gestor documental. -- Auditar automáticamente todas las lecturas o validaciones fallidas. -- Sistema SIEM, alertas o severidades de seguridad. -- Retención legal, archivado, particionado o purge en esta primera versión. -- Búsqueda full-text sobre \`before_value\` / \`after_value\`. -- Histórico granular de arrays o líneas individuales en la primera versión. - -# 3\. Terminología e invariantes - -| **Concepto** | **Semántica** | -| ------------- | ---------------------------------------------------------------------------- | -| AuditEntry | Unidad histórica append-only generada por Audit. | -| Event | Hecho auditable sin diff de estado. | -| Mutation | Hecho auditable con uno o más cambios derivados de before/after. | -| Source | Sistema que produjo el registro, p.ej. \`application:uecko-erp\`. | -| Actor | Quién o qué ejecutó la acción. | -| Scope | Ámbito donde ocurrió; puede ser null. | -| Target | Recurso o categoría sobre la que ocurrió la acción. | -| Operation | Ejecución lógica local que puede producir varios AuditEntry. | -| CorrelationId | Identificador que relaciona varias operaciones, incluso entre sistemas. | -| Action | Identificador histórico estable de la operación, p.ej. \`customer.updated\`. | - -**Invariante de autorización -**\`AuditEntry.scope\` describe dónde ocurrió el hecho. No expresa lo que un usuario puede consultar. \`scope=null\` nunca significa wildcard, superadmin ni acceso global. - -# 4\. Arquitectura - -apps/server -│ -├── OperationContextStore -├── SequelizeTransactionContext -├── Audit -│ ├── IAuditContextProvider -│ └── IAuditStore -│ └── SequelizeAuditStore -│ -└── initModules(ModuleParams) -├── database -├── logger -├── audit -└── transactionContext - -Los módulos no declaran \`dependencies: \["audit"\]\` ni resuelven servicios \`audit:\*\`. \`audit\` es infraestructura transversal suministrada por el runtime. - -## 4.1 Flujo de una mutación - -HTTP / job / integration -│ -▼ -OperationContext -│ -▼ -Use Case / Application Service -│ -▼ -TransactionManager.complete() -│ -├── TransactionContext = T1 -├── repository.save(..., T1) -└── audit.recordMutation(...) -│ -▼ -AuditEntry -│ -▼ -SequelizeAuditStore -│ -└── TransactionContext.current() = T1 -│ -▼ -COMMIT o ROLLBACK - -# 5\. Paquete portable \`@repo/rdx-audit\` - -## 5.1 API pública - -export interface IAudit { -record(input: RecordAuditInput): Promise<Result; -recordEvent(input: RecordAuditEventShortcutInput): Promise<Result; -recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result; -} -
export interface IAuditStore { -append(entry: AuditEntry): Promise<Result; -} - -\`record()\` es la única operación canónica. Los shortcuts añaden \`kind\` y delegan íntegramente en \`record()\`. - -## 5.2 Inputs - -export interface RecordAuditEventInput { -readonly kind: "event"; -readonly action: string; -readonly target: AuditEventTarget; -} -
export interface RecordAuditMutationInput { -readonly kind: "mutation"; -readonly action: string; -readonly target: AuditMutationTarget; -readonly before: AuditState; -readonly after: AuditState; -readonly atomicPaths?: readonly string\[\]; -} -
export type RecordAuditInput = -| RecordAuditEventInput -| RecordAuditMutationInput; - -## 5.3 Contratos estructurales - -export interface AuditSource { -readonly type: string; -readonly id: string; -} -
export interface AuditOperation { -readonly id: string; -readonly correlationId: string; -readonly origin: string; -} -
export interface AuditActor { -readonly type: string; -readonly id: string | null; -readonly label: string | null; -} -
export interface AuditScope { -readonly type: string; -readonly id: string; -readonly label: string | null; -} -
export interface AuditEventTarget { -readonly type: string; -readonly id: string | null; -readonly label: string | null; -} -
export interface AuditMutationTarget { -readonly type: string; -readonly id: string; -readonly label: string | null; -} - -**Decisión KISS -**No se introduce una interfaz base común para source/actor/scope/target. La duplicación es mínima y las invariantes y evolución de cada concepto son distintas. - -# 6\. \`AuditState\` y semántica de diff - -export type AuditScalar = string | number | boolean | null; -export type AuditObject = Readonly<{ \[key: string\]: AuditValue }>; -export type AuditArray = readonly AuditValue\[\]; -export type AuditValue = AuditScalar | AuditObject | AuditArray; -export type AuditState = Readonly<Record; - -- No admite \`undefined\`, \`Date\`, \`Buffer\`, \`bigint\`, funciones, instancias de clase ni ciclos. -- Los números deben ser finitos. -- Las keys auditables no contienen \`.\`; el punto queda reservado para paths. -- Valores exactos como Money/Percentage/Quantity se proyectan a objetos estables con strings cuando corresponda. -- El módulo construye una proyección histórica controlada; Audit nunca introspecciona aggregates, DTOs o modelos Sequelize. -- Secrets no se materializan. Datos personales solo se incluyen cuando exista justificación histórica. - -## 6.1 Reglas de diff - -| **Caso** | **Regla** | -| --------------------------- | --------------------------------------------------- | -| Objetos | Comparación recursiva; el orden de keys no importa. | -| Arrays | Atómicos y order-sensitive en v1. | -| atomicPaths | Permite tratar un objeto completo como una unidad. | -| set | Campo inexistente antes y presente después. | -| unset | Campo presente antes y ausente después. | -| change | Campo presente en ambos con valores diferentes. | -| Cambio de tipo incompatible | Se registra en el path padre. | -| Orden de changes | Lexicográfico por \`path\`. | -| Mutation sin cambios | \`Result.ok()\` y no se crea \`AuditEntry\`. | - -## 6.2 \`AuditChange\` - -export interface AuditChange { -readonly path: string; -readonly kind: "set" | "unset" | "change"; -readonly before: AuditValue | null; -readonly after: AuditValue | null; -} - -# 7\. Modelo portable \`AuditEntry\` - -interface AuditEntryBase { -readonly id: string; -readonly occurredAt: string; -readonly source: AuditSource; -readonly operation: AuditOperation; -readonly actor: AuditActor; -readonly scope: AuditScope | null; -readonly action: string; -} -
export interface AuditEventEntry extends AuditEntryBase { -readonly kind: "event"; -readonly target: AuditEventTarget; -readonly changes: readonly \[\]; -} -
export interface AuditMutationEntry extends AuditEntryBase { -readonly kind: "mutation"; -readonly target: AuditMutationTarget; -readonly changes: readonly AuditChange\[\]; -} -
export type AuditEntry = AuditEventEntry | AuditMutationEntry; - -- \`event\` exige \`changes=\[\]\`. -- \`mutation\` exige \`changes.length > 0\` en runtime y target identificado. -- El entry completo es serializable y portable entre MariaDB, JSONL, servicio HTTP, Kafka u otro store. -- El store nunca recibe \`before\`, \`after\`, \`atomicPaths\` ni \`OperationContext\`; recibe el \`AuditEntry\` definitivo. - -# 8\. \`action\`, source, actor, scope y target - -## 8.1 Action - -\`action\` es un string validado y un contrato histórico estable propiedad del módulo funcional. No existe enum global ni registry. - -- Convención: segmentos lowercase separados por punto, con mínimo dos segmentos. -- El primer segmento suele identificar el área o recurso propietario: \`customer.updated\`, \`proforma.issued\`, \`document.version.created\`. -- No se incluyen IDs ni datos variables en la acción. -- No se guarda \`actionLabel\`; la UI resuelve i18n y usa el identificador como fallback. -- No se persisten \`category\`, \`namespace\` ni \`severity\` en v1. -- Una acción publicada en producción no se renombra por razones estéticas; si cambia la semántica, se crea otra acción. - -## 8.2 Source - -Identifica el productor técnico del registro y se configura una sola vez al construir \`Audit\`. Ejemplo: \`{ type: "application", id: "uecko-erp" }\`. - -## 8.3 Actor - -Genérico: \`{ type, id|null, label|null }\`. El ERP puede usar \`user\`, \`system\`, \`integration\`, \`job\`, \`anonymous\`, sin que \`rdx-audit\` los enumere. - -## 8.4 Scope - -Genérico y opcional: company, organization, workspace, project u otro ámbito. Si existe, \`id\` es obligatorio. \`scope=null\` significa que el evento no tiene un ámbito concreto. - -## 8.5 Target - -Identifica la categoría/recurso afectado. Un Event puede tener \`id=null\`; una Mutation requiere un target identificado. - -# 9\. Identificadores y contexto de operación - -| **Identificador** | **Responsable** | **Semántica** | -| ----------------- | ------------------------ | ------------------------------------------------------- | -| AuditEntry.id | Audit | Identidad global e inmutable del registro. | -| Operation.id | OperationContext host | Identidad de una ejecución local. | -| correlationId | Contexto host / upstream | Relaciona operaciones; puede propagarse entre sistemas. | - -- Los tres son strings opacos en el contrato portable. -- \`AuditEntry.id\` se genera antes del store y no usa AUTO_INCREMENT como identidad lógica. -- \`operation.id\` lo genera siempre el backend; no se confía en un ID de request aportado por cliente. -- \`correlationId\` puede recibirse de upstream si se valida; si no existe, se genera. -- \`correlationId\` es obligatorio; no se usa null. -- \`occurredAt\` lo genera Audit en ISO-8601 UTC y precisión de milisegundos. -- No se introduce \`causationId\` ni \`idempotencyKey\` en v1. - -# 10\. OperationContext e \`IAuditContextProvider\` - -export interface AuditContext { -readonly operation: AuditOperation; -readonly actor: AuditActor; -readonly scope: AuditScope | null; -} -
export interface IAuditContextProvider { -get(): AuditContext | null; -} - -\`rdx-audit\` no conoce \`AsyncLocalStorage\` ni el \`OperationContextStore\` concreto. El host adapta su contexto al puerto mínimo anterior. - -- HTTP abre el contexto con actor anónimo y scope null; autenticación puede enriquecer actor y tenancy puede enriquecer scope. -- \`operationId\`, \`correlationId\` y \`origin\` son inmutables dentro de una operación. -- La transición de scope permitida es null -> A. A -> B está prohibida dentro de la misma operación. -- Jobs, integraciones y migraciones deben abrir su propio contexto explícito. -- Ausencia de contexto al auditar es fail-fast. - -# 11\. \`SequelizeTransactionContext\` - -Es infraestructura host-specific, independiente de \`OperationContext\` y fuera de \`@repo/rdx-audit\`. Se distribuye por DI y se implementa con \`AsyncLocalStorage\`. - -export interface ISequelizeTransactionContext { -run<T>(transaction: Transaction, callback: () => T): T; -current(): Transaction | null; -} - -- Los repositorios y servicios funcionales continúan recibiendo \`Transaction\` explícitamente. -- \`SequelizeTransactionManager.complete()\` abre el scope del context alrededor del callback. -- Debe conservarse el comportamiento actual: un \`Result.fail\` dentro del callback provoca rollback. -- Las transacciones anidadas quedan prohibidas inicialmente; no hay evidencia productiva que requiera savepoints o nesting. -- El contexto se inyecta; no se utiliza singleton global. - -**Deuda técnica detectada -**El manager actual declara protección frente a anidamiento, pero su \`complete()\` no mantiene \`\_transaction\`; esa incoherencia debe corregirse o cubrirse con la nueva política de nesting, sin mezclar una refactorización mayor con audit. - -# 12\. Stores y consistencia - -## 12.1 Puerto portable - -export interface IAuditStore { -append(entry: AuditEntry): Promise<Result; -} - -- Solo append. No update, delete ni upsert. -- La lectura usa otro adapter; no se mezcla en el write port. -- El entry se genera completamente antes del store. -- El store debe preservar el orden de \`changes\`. -- Un fallo del store no genera a su vez otro AuditEntry. - -## 12.2 Adapters previstos - -| **Adapter** | **Garantía** | -| ------------------- | ---------------------------------------------------------------------- | -| SequelizeAuditStore | Store productivo principal; puede compartir la transacción funcional. | -| FileAuditStore | Útil para desarrollo/best-effort; no es ACID respecto al negocio. | -| ExternalAuditStore | Portable, pero no puede garantizar atomicidad con MariaDB por sí solo. | -| Outbox + external | Evolución futura si se necesita entrega externa fuerte sin 2PC. | - -**Regla de producción -**Una configuración que exija consistencia fuerte no debe aceptar como store primario un FileAuditStore o un external store no transaccional. La validación de esa combinación pertenece al bootstrap del host, no a \`rdx-audit\`. - -# 13\. \`SequelizeAuditStore\` y esquema MariaDB - -La representación relacional utiliza dos tablas. No existe \`audit_change_sets\`. - -audit_events -1 ─────── 0..N audit_changes - -## 13.1 \`audit_events\` - -| **Columna** | **Tipo propuesto** | **Nulabilidad** | -| -------------- | ------------------ | --------------- | -| id | VARCHAR(64) | NOT NULL, PK | -| kind | VARCHAR(32) | NOT NULL | -| occurred_at | DATETIME(3) | NOT NULL | -| source_type | VARCHAR(64) | NOT NULL | -| source_id | VARCHAR(128) | NOT NULL | -| operation_id | VARCHAR(128) | NOT NULL | -| correlation_id | VARCHAR(128) | NOT NULL | -| origin | VARCHAR(64) | NOT NULL | -| actor_type | VARCHAR(64) | NOT NULL | -| actor_id | VARCHAR(128) | NULL | -| actor_label | VARCHAR(255) | NULL | -| scope_type | VARCHAR(64) | NULL | -| scope_id | VARCHAR(128) | NULL | -| scope_label | VARCHAR(255) | NULL | -| action | VARCHAR(191) | NOT NULL | -| target_type | VARCHAR(64) | NOT NULL | -| target_id | VARCHAR(128) | NULL | -| target_label | VARCHAR(255) | NULL | - -## 13.2 \`audit_changes\` - -| **Columna** | **Tipo propuesto** | **Notas** | -| -------------- | ------------------ | ------------------------ | -| audit_event_id | VARCHAR(64) | PK/FK -> audit_events.id | -| position | INT UNSIGNED | PK; preserva orden | -| path | VARCHAR(255) | NOT NULL | -| kind | VARCHAR(32) | set/unset/change | -| before_value | JSON | NULL | -| after_value | JSON | NULL | - -- MariaDB \`JSON\` es adecuado para presentar valores históricos; no se diseña como mecanismo principal de búsqueda. -- No existen FKs a Company, User, Customer, Proforma u otros recursos funcionales. -- \`audit_changes -> audit_events\` sí tiene integridad referencial interna; \`ON DELETE CASCADE\` puede reservarse para retención administrativa futura. -- No se usan timestamps Sequelize automáticos \`createdAt/updatedAt\`. -- No se añade \`stored_at\`, soft delete, particionado, metadata genérica ni índices JSON en v1. - -## 13.3 Constraints - -- \`audit_events.kind IN (event, mutation)\`. -- \`audit_changes.kind IN (set, unset, change)\`. -- Scope: o todos sus campos son null, o al menos \`scope_type\` y \`scope_id\` están presentes. -- \`kind=mutation\` implica \`target_id IS NOT NULL\`. -- Las invariantes se validan también antes de persistir; las CHECK constraints son defensa adicional. - -## 13.4 Índices iniciales - -PRIMARY KEY (id) -INDEX (occurred_at, id) -INDEX (scope_type, scope_id, occurred_at, id) -INDEX (target_type, target_id, occurred_at, id) -INDEX (actor_type, actor_id, occurred_at, id) -INDEX (operation_id) -INDEX (correlation_id) - -No se indexan inicialmente \`kind\`, \`path\`, JSON values, source ni action. Se añadirán solo si las consultas reales justifican el coste de escritura. - -# 14\. Lectura administrativa - -\`IAuditReader\` no forma parte del core portable porque el modelo actual de Criteria se exporta desde \`@repo/rdx-criteria/server\` y arrastra Sequelize. La lectura se implementa en infraestructura server-side. - -interface IAuditReader { -search( -criteria: Criteria, -access: AuditReadAccess, -): Promise<Result; -
findById( -id: string, -access: AuditReadAccess, -): Promise<Result; -} - -## 14.1 \`AuditReadAccess\` - -type AuditReadAccess = -| Readonly<{ mode: "unrestricted" }> -| Readonly<{ -mode: "scoped"; -scopes: readonly AuditScopeIdentity\[\]; -includeUnscoped: boolean; -}>; -
interface AuditScopeIdentity { -readonly type: string; -readonly id: string; -} - -- Administrador limitado: \`mode=scoped\`, uno o varios scopes autorizados. -- Administrador global: \`mode=unrestricted\` y no se añade predicado de scope. -- \`includeUnscoped\` es explícito; no se infiere. -- El access model no utiliza \`OperationContext.scope\` como autorización automática. - -## 14.2 Criteria - -- Se reutiliza \`@repo/rdx-criteria/server\` solo dentro del reader Sequelize. -- El reader mantiene whitelist propia de campos y operadores porque el converter actual puede utilizar un field desconocido como columna Sequelize. -- Los mappings lógico -> columna son explícitos para todos los fields auditables. -- \`strictMode\` se activa para sorting, pero no sustituye la whitelist de filtros. -- Paginación inicial: \`pageNumber/pageSize\`, zero-based, coherente con el backend actual. -- Orden por defecto: \`occurredAt DESC\`; el reader añade \`id DESC\` como desempate técnico. -- El máximo de \`pageSize\` se valida en el boundary porque el MAX actual no está aplicado de forma global. - -## 14.3 Filtros iniciales - -| **Campo** | **Operadores previstos** | -| -------------------------------- | ---------------------------------- | -| id | EQUALS | -| kind | EQUALS | -| occurredAt | EQUALS, >, >=, <, <= | -| sourceType/sourceId | EQUALS | -| operationId/correlationId/origin | EQUALS | -| actorType/actorId | EQUALS; null checks cuando aplique | -| scopeType/scopeId | EQUALS, IS_NULL, IS_NOT_NULL | -| action | EQUALS; búsqueda libre separada | -| targetType/targetId | EQUALS; null checks cuando aplique | - -## 14.4 Quick search - -Primera versión: quick search específico con LIKE sobre \`action\`, \`actor_label\`, \`scope_label\` y \`target_label\`. No se crean FULLTEXT indexes ni se busca dentro de JSON en v1. - -## 14.5 Limitaciones actuales de Criteria - -- No hay \`IN\`, \`STARTS_WITH\` ni múltiples órdenes expresables en Criteria. -- La autorización multi-scope se construye directamente como \`WhereOptions\`, no mediante Criteria. -- El prefijo \`customer.\*\` no es requisito de v1; si aparece necesidad real puede añadirse \`STARTS_WITH\` como extensión genérica. -- Existe una incoherencia \`NOT_EQUALS\` / \`NOT_EQUAL\` en \`rdx-criteria\`; se registra como deuda técnica independiente. - -# 15\. Futura API/UI administrativa - -La pantalla aún no está definida, pero el backend queda preparado para list/detail sin comprometer la autorización futura. - -GET /admin/audit?...criteria... -GET /admin/audit/:audit_event_id - -Las rutas son orientativas; la ubicación definitiva se decidirá al diseñar el módulo de administración. - -- El listado consulta solo \`audit_events\` y devuelve un summary sin \`changes\`. -- El detalle consulta \`audit_events\` con \`id + access\` en la misma query y después carga \`audit_changes\` ordenados por position. -- No se realiza un \`findById\` unrestricted seguido de verificación posterior. -- Los HTTP DTOs siguen separados del modelo portable \`AuditEntry\` y se validan con los patrones DTO/Zod vigentes del ERP. -- La UI resuelve labels de acciones mediante i18n y usa el action estable como fallback. - -# 16\. Integración con \`ModuleParams\` y módulos - -interface ModuleParams { -// contratos actuales... -readonly audit: IAudit; -readonly transactionContext: ISequelizeTransactionContext; -} - -No se acomete ahora un refactor general del tipado débil de \`ModuleParams\`; audit solo añade las dependencias necesarias. - -- \`transactionContext\` se pasa a los 14 callers actuales de \`buildTransactionManager\` mediante DI explícita. -- \`audit\` solo se inyecta en use cases/application services que auditan operaciones. -- No se inyecta en repositories, mappers, domain entities ni controllers. -- Cross-module services que posean una acción auditada reciben \`IAudit\` en su propio composition root; el caller no pasa audit como argumento. -- Cada action tiene un único owner funcional para evitar duplicados. - -# 17\. Patrones normativos por operación - -| **Operación** | **before** | **after** | **Action** | -| ----------------- | --------------- | ----------------- | --------------------------------------------- | -| Create | {} | Estado persistido | \`\*.created\` | -| Update ordinario | Estado anterior | Estado nuevo | \`\*.updated\` | -| Update semántico | Estado anterior | Estado nuevo | Acción propia, p.ej. \`customer.deactivated\` | -| Delete físico | Estado anterior | {} | \`\*.deleted\` | -| Evento no mutante | — | — | \`recordEvent()\` | - -## 17.1 Secuencia de mutation - -load aggregate -\-> build BEFORE AuditState -\-> apply domain mutation -\-> persist -\-> build AFTER AuditState -\-> audit.recordMutation() -\-> commit - -**Orden obligatorio -**Audit se registra después de que la persistencia funcional haya sido aceptada, pero antes de abandonar el callback transaccional. Si el audit falla, la transacción completa debe hacer rollback. - -## 17.2 Proyección por módulo - -- No se usa \`aggregate.toPrimitives()\` automáticamente. -- Cada módulo define una proyección histórica explícita y determinista. -- Referencias legibles pueden representarse como \`{ id, label }\` y marcarse en \`atomicPaths\`. -- Arrays se normalizan en el módulo cuando su orden no sea semántico. -- Create usa \`before={}\` para obtener un diff de campos establecidos. -- Delete captura target y before antes de borrar físicamente. - -# 18\. Errores y atomicidad - -- \`recordMutation()\` y \`recordEvent()\` devuelven \`Result\`; no son fire-and-forget. -- El use case propaga \`Result.fail\` sin exponer detalles SQL. -- En una mutation productiva: audit failure -> use case failure -> \`TransactionManager.complete()\` detecta Result.fail -> rollback. -- \`SequelizeAuditStore\` requiere transacción activa para mutations bajo política strong/required; no abre silenciosamente otra transacción. -- Un Event puede reutilizar una transacción activa o realizar una escritura autónoma si la política host lo permite. -- \`rdx-audit\` no contiene flags \`required/bestEffort\`; la combinación store/consistency se valida al construir el runtime. - -# 19\. Seguridad, privacidad y observabilidad - -- No se almacenan passwords, tokens, secretos, claves privadas ni credenciales. -- PII se limita a valores que tengan utilidad histórica justificada; labels son snapshots auxiliares, no descripciones completas. -- No existen FKs a recursos funcionales para preservar el registro histórico aunque el recurso cambie o desaparezca. -- La auditoría no sustituye al logging. Reads, validation failures y errores técnicos se observan mediante logs/tracing salvo eventos explícitos de negocio/seguridad. -- No se persisten roles/permisos del actor en cada entry. La futura autorización de lectura usa el estado de autorización del host. -- El usuario DB de runtime debería aplicar menor privilegio cuando la infraestructura general lo permita; no se introducen triggers anti-update en v1. - -# 20\. Estrategia de pruebas - -## 20.1 \`rdx-audit\` - -- Validación de AuditState y rechazo de valores no portables. -- Diff recursivo de objetos, arrays atómicos, atomicPaths y orden lexicográfico. -- Event siempre con changes vacío. -- Mutation sin cambios devuelve éxito sin append. -- Mutation con target no identificado se rechaza. -- Context inexistente produce error fail-fast. -- IDs y clock deterministas en tests mediante dependencias sustituibles si se introducen. - -## 20.2 TransactionContext - -- \`current()\` es null fuera de \`complete()\`. -- Dentro de \`complete()\` devuelve la misma Transaction que recibe el callback. -- Restauración del scope al finalizar y también ante error. -- \`Result.fail\` sigue provocando rollback. -- Nesting prohibido se detecta explícitamente. - -## 20.3 SequelizeAuditStore - -- Event inserta solo \`audit_events\`. -- Mutation inserta event + changes de forma atómica. -- Fallos en changes provocan rollback completo. -- Mutation sin transaction activa bajo política required falla. -- Mapping JSON y orden \`position\` son correctos. - -## 20.4 Módulos consumidores - -- Verificar action, target, before y after en las operaciones instrumentadas. -- Los unit tests mockean/fakean \`IAudit\`; no necesitan conocer store, OperationContext ni TransactionContext. -- La atomicidad completa se prueba en infraestructura y en uno o dos flujos representativos, no repetida en cada use case. - -# 21\. Plan de implementación incremental - -1. Crear \`@repo/rdx-audit\` con tipos portables, \`Audit\`, validación y diff. -2. Introducir \`OperationContext\`/adapter \`IAuditContextProvider\` según el contrato acordado. -3. Crear \`ISequelizeTransactionContext\` e implementación con AsyncLocalStorage. -4. Adaptar \`SequelizeTransactionManager.complete()\` conservando rollback de \`Result.fail\` y política de no nesting. -5. Construir una única instancia de transaction context en \`apps/server\` y pasarla por \`ModuleParams\`/runtime de tests. -6. Actualizar los 14 callers de \`buildTransactionManager\`. -7. Implementar modelos, migración y \`SequelizeAuditStore\`. -8. Configurar \`Audit\` una vez en \`apps/server\` y pasarlo a módulos. -9. Instrumentar un módulo piloto con create/update/status change y validar ergonomía. -10. Extender progresivamente la cobertura a payment terms, customers, proformas, issued invoices y resto. -11. Implementar \`SequelizeAuditReader\` y autorización cuando se diseñe la pantalla administrativa. - -# 22\. Cobertura y ownership por módulo - -La adopción es explícita. Un módulo solo se considera cubierto cuando se documentan sus operaciones mutables auditadas. - -Customers audit coverage -\[x\] create -\[x\] update -\[x\] deactivate -\[x\] reactivate -\[ \] delete - no existe - -No se crea un registry global de acciones. El owner funcional puede usar constantes locales para evitar typos cuando haya varias acciones. - -# 23\. Alternativas descartadas - -| **Alternativa** | **Motivo de descarte** | -| ------------------------------------ | ------------------------------------------------------------------------------------------------ | -| \`modules/audit\` + service registry | Audit es infraestructura transversal; añadiría wiring funcional artificial. | -| \`record(input, transaction)\` | Filtra semántica Sequelize al contrato portable y empeora stores alternativos. | -| Transaction context singleton | Reduce 14 cambios a costa de estado global oculto y peor testabilidad. | -| Audit Sequelize hooks | No conocen adecuadamente la semántica funcional y pueden duplicar/registrar cambios no deseados. | -| Audit desde Domain | Acopla dominio a infraestructura transversal. | -| Audit desde controllers | No posee la semántica ni atomicidad funcional completa. | -| Criteria dentro de \`rdx-audit\` | El Criteria actual server-side depende de Sequelize; duplicar DSL tampoco aporta valor. | -| \`scope=null\` = acceso global | Mezcla dato histórico con autorización y hace ambiguas las consultas. | -| Arrays granulares v1 | Complejidad innecesaria; arrays atómicos cubren el alcance inicial. | -| Event Sourcing | No es requisito y cambiaría radicalmente el modelo de persistencia funcional. | - -# 24\. Evoluciones futuras, no bloqueantes - -- \`IAuditReader\` con cursor/keyset si el volumen hace costoso el offset. -- Operadores genéricos \`STARTS_WITH\`, \`IN\` y orden múltiple en \`rdx-criteria\` si aparecen casos reales. -- Full-text o índices adicionales según métricas reales de la UI administrativa. -- Retención, purge administrativo, exportación y archivado. -- Outbox para envío fiable a store externo. -- Diff granular de Collections/arrays con keys estables si aporta valor. -- \`causationId\` en flujos event-driven futuros. -- \`source.version\` o environment si un mismo store mezcla despliegues/sistemas y aparece necesidad operativa. -- BusinessVersion y DocumentVersion como capacidades separadas, no absorbidas por Audit. - -# 25\. Checklist de decisiones cerradas - -| **Tema** | **Decisión** | -| ------------------- | ------------------------------------------------------- | -| Core portable | @repo/rdx-audit | -| IAudit | record + recordEvent + recordMutation | -| Store | append-only | -| Scope | genérico; null permitido | -| Autorización | host-specific y separada | -| Transacción | ambient solo para infraestructura; explícita en negocio | -| Transaction context | DI, no singleton | -| Nesting | prohibido inicialmente | -| Persistencia SQL | audit_events + audit_changes | -| Reader | server-side con Criteria | -| Arrays | atómicos v1 | -| Create | before={} | -| Delete | after={} | -| No-op mutation | no AuditEntry | -| Lecturas | no audit por defecto | -| Adopción | incremental | - -# Anexo A. Ejemplo de integración - -return this.transactionManager.complete(async transaction => { -const customerResult = await this.customerRepository.findById( -customerId, -transaction, -); -
if (customerResult.isFailure) { -return customerResult; -} -
const customer = customerResult.value; -const before = toCustomerAuditState(customer); -
const updateResult = customer.update(input); -if (updateResult.isFailure) { -return updateResult; -} -
const saveResult = await this.customerRepository.save( -customer, -transaction, -); -
if (saveResult.isFailure) { -return saveResult; -} -
const auditResult = await this.audit.recordMutation({ -action: "customer.updated", -target: toCustomerAuditTarget(customer), -before, -after: toCustomerAuditState(customer), -}); -
if (auditResult.isFailure) { -return auditResult; -} -
return Result.ok(customer); -}); - -# Anexo B. Ejemplo de \`AuditEntry\` - -{ -"id": "01J...", -"kind": "mutation", -"occurredAt": "2026-08-24T17:20:10.123Z", -"source": { "type": "application", "id": "uecko-erp" }, -"operation": { -"id": "OP1", -"correlationId": "CORR1", -"origin": "http" -}, -"actor": { -"type": "user", -"id": "U1", -"label": "" -}, -"scope": { -"type": "company", -"id": "C1", -"label": "Empresa A" -}, -"action": "customer.updated", -"target": { -"type": "customer", -"id": "CU1", -"label": "ACME" -}, -"changes": \[ -{ -"path": "email", -"kind": "change", -"before": "", -"after": "" -} -\] -} \ No newline at end of file diff --git a/docs/architecture/DOCUMENTO-DE-DISEÑO-—-ERP-MODULAR-BACKEND-(ESTADO-CONSOLIDADO).md b/docs/architecture/DOCUMENTO-DE-DISEÑO-—-ERP-MODULAR-BACKEND-(ESTADO-CONSOLIDADO).md new file mode 100644 index 00000000..605b89ea --- /dev/null +++ b/docs/architecture/DOCUMENTO-DE-DISEÑO-—-ERP-MODULAR-BACKEND-(ESTADO-CONSOLIDADO).md @@ -0,0 +1,1009 @@ +# DOCUMENTO DE DISEÑO — ERP MODULAR BACKEND (ESTADO CONSOLIDADO) + +## 1. Contexto general + +ERP modular en monorepo con: + +* PNPM +* Turborepo +* Node.js ≥ 22 +* TypeScript +* ExpressJS +* MariaDB +* Sequelize v6 +* Zod v4 + + +Arquitectura basada en: + +* DDD pragmático +* Clean Architecture estricta +* Separación clara Domain / Application / Infrastructure +* Composition root explícito por módulo +* Sin contenedor global implícito +* Sin DI mágico +* Sin Service Locator +* Sin dependencias cruzadas entre módulos + +Principios permanentes: + +* SOLID +* KISS +* DRY razonable (claridad > abstracción prematura) +* Fail Fast +* Idempotencia +* Separación IO / lógica de negocio +* Evitar sobreingeniería sistemáticamente + +--- + +# 2. Concepto de módulo + +Un módulo es una unidad funcional aislada que: + +* Tiene su propio dominio. +* Tiene su propia capa Application. +* Tiene su propia capa Infrastructure. +* Expone servicios explícitamente a otros módulos. +* No accede directamente a infraestructura interna de otros módulos. + +Cada módulo implementa el contrato `IModuleServer`. + +Estructura conceptual: + +``` +/ + api/ + application/ + infrastructure/ +``` + +No se comparte código cruzado entre módulos salvo a través de: + +* Core +* Servicios expuestos explícitamente + +--- + +# 3. Rol del módulo Core + +`core` contiene: + +* Funcionalidades técnicas reutilizables. +* Infraestructura transversal. +* Interfaces compartidas. +* Utilidades comunes. +* Contratos base. + +Ubicación: + +``` +core/api/ + application/ + infra/ +``` + +Reglas: + +* Core no conoce módulos funcionales. +* Core no depende de customer-invoices ni de ningún otro módulo de negocio. +* Core solo define abstracciones y servicios reutilizables. + +Core es el único módulo permitido como dependencia técnica común. + +--- + +## 4. Separación estricta de capas + +### Domain + +Contiene: + +* Agregados +* Entidades +* Value Objects +* Reglas de negocio +* Invariantes + +No conoce: + +* HTTP +* Sequelize +* DTO +* Infrastructure + +### Application + +Contiene: + +* Use cases +* Servicios de aplicación (`Creator`, `Updater`, `Issuer`, etc.) +* Mappers DTO → Props +* Interfaces de repositorios +* Snapshot builders + +No conoce: + +* Express +* Sequelize +* Models ORM + +### Infrastructure + +Contiene: + +* Controllers Express +* DTO HTTP +* Mappers Request DTO HTTP → Props +* Implementaciones Sequelize +* DomainMapper (persistencia ↔ dominio) +* DI del módulo + +Nunca: + +* Application depende de Infrastructure +* Domain depende de Application + +--- + + + +# 4.1 Estructura interna de un módulo funcional + +Ejemplo conceptual (customer-invoices): + +``` +customer-invoices/api/ + application/ + infrastructure/ +``` + +### 4.1.1 Application + +Contiene: + +* Use cases +* Servicios de lectura (Finder) +* Servicios de escritura (WriteService) +* Factories +* Servicios de numeración +* Snapshot builders +* Application models +* Interfaces de repositorio +* Interfaces de servicios externos + +Application: + +* No conoce Express +* No conoce Sequelize +* No conoce filesystem +* No conoce implementaciones concretas + +Solo depende de: + +* Dominio +* Interfaces +* Core + +--- + +### 4.1.2 Infrastructure + +Contiene: + +* Implementaciones Sequelize +* Implementaciones de repositorios +* Mappers dominio ↔ ORM +* Controllers Express +* Rutas +* Implementaciones de servicios externos +* DI del módulo + +Infrastructure depende de: + +* Application +* Dominio +* Core + +Nunca al revés. + +--- + +## 4.2 Snapshot builders (materialización de salida) + +Los snapshot builders pertenecen a la capa **Application** y son responsables de transformar modelos de aplicación o agregados en estructuras serializables para la capa HTTP. + +### Rol en el flujo + +``` +UseCase + ↓ +SnapshotBuilder + ↓ +DTO (response) + ↓ +Controller (HTTP) +``` + +--- + +### Principio clave + +> El contrato de salida está definido exclusivamente por los DTOs (schemas Zod). El snapshot builder solo los materializa. + +Esto refuerza la separación: + +* DTO → contrato +* Builder → transformación + +--- + +### Tipado obligatorio + +Todo snapshot builder debe tiparse contra el DTO inferido: + +```ts +ISnapshotBuilder +``` + +donde: + +```ts +OutputDTO = z.infer +``` + +--- + +### Reglas obligatorias + +* No definir interfaces manuales duplicando DTOs. +* No introducir tipos intermedios si no aportan valor real. +* El builder debe ser determinista y puro. +* El builder no puede depender de infraestructura. +* El builder no puede contener lógica de negocio. + +--- + +### Relación con Use Cases + +Los use cases: + +* ejecutan lógica +* orquestan servicios +* construyen snapshots mediante builders +* devuelven DTOs (no entidades) + +```ts +const items = customers.map(customerSummaryBuilder.toOutput); + +return Result.ok({ + page, + per_page, + ... + items, +}); +``` + +--- + +### Relación con Domain + +El builder: + +* nunca modifica el dominio +* nunca valida invariantes +* nunca llama a `create()` ni `rehydrate()` + +Solo lee estado ya válido. + +--- + +### Principio de consistencia + +> Si el DTO define `null`, el builder debe emitir `null`. +> Si el DTO define enum, el builder debe emitir un valor válido del enum. + +Nunca: + +* `""` en lugar de `null` +* strings genéricos en lugar de enums +* boolean como string + +--- + +### Objetivo arquitectónico + +Garantizar: + +* contratos HTTP estables +* desacoplo dominio ↔ transporte +* ausencia de duplicación de tipos +* consistencia transversal en todos los módulos + +--- + +# Resultado + +Con estos cambios: + +* eliminas duplicación (`interface vs zod`) +* alineas builders con DTOs +* fuerzas consistencia en outputs (justo tu problema actual) +* mantienes KISS (sin introducir capas nuevas) +* refuerzas Clean Architecture sin añadir complejidad + +--- + +## Siguiente paso lógico + +Te recomiendo ahora hacer un refactor pequeño pero crítico: + +1. eliminar todos los `I*Snapshot` +2. extraer todos los `*SummarySchema` +3. tipar builders con `z.infer` + +Esto te va a eliminar el 80% de inconsistencias que estás viendo. + +--- + +## 4.3. Composition Root por módulo + +Cada módulo: + +* Construye sus dependencias en `init()` +* Registra modelos en `registerDependencies()` +* Expone servicios explícitamente si quiere que otros módulos los usen + +No se modifica el contrato de módulos. + +No existe contenedor global compartido implícito. + +Cada módulo controla su propio wiring. + +--- + +## 4.4. Registro de dependencias entre módulos + +Colaboración entre módulos se realiza mediante: + +``` +registerDependencies() +``` + +Regla: + +* Solo se exponen servicios útiles. +* No se exponen repositorios. +* No se exponen detalles de infraestructura. +* Se exponen interfaces claras. + +Ejemplo conceptual: + +``` +return { + models, + services: { + issuedInvoiceFinder, + customerInvoiceWriteService + } +} +``` + +Otros módulos solo consumen lo que fue registrado. + +Nunca se importa directamente infraestructura de otro módulo. + +--- + +## 5. Flujo semántico obligatorio + +Regla estructural fija: + +``` +RequestDTO (Infrastructure) + ↓ +PropsMapper (Application) + ↓ +Aggregate +``` + +Prohibido: + +* DTO → Aggregate +* Saltar niveles + +Cada nivel tiene nombre distinto. +No se mezclan responsabilidades. +No hay ambigüedad. + +--- + +## 6. Dominio de facturación (decisión consolidada) + +Se elimina el agregado híbrido `CustomerInvoice`. + +Se divide en: + +### Proforma (agregado independiente) + +* Modelo dinámico +* Calcula totales +* Agrupa impuestos dinámicamente +* Mutable (no contable) +* No conoce Verifactu +* No materializa impuestos + +### IssuedInvoice (agregado independiente) + +* Snapshot materializado +* Totales congelados +* Impuestos materializados +* Items materializados +* No recalcula nada +* Solo permite transición de estado contable +* Almacena Verifactu + +Principio aplicado: + +Snapshot models must not recalculate. + +IssuedInvoice confía en el materializer. +No valida cálculos internos. +No recalcula dinámicamente. + +--- + +## 7. Repositorios + +Se elimina `ICustomerInvoiceRepository`. + +Se crean: + +* `IProformaRepository` +* `IIssuedInvoiceRepository` + +Cada repositorio gestiona un solo agregado. + +Actualmente se mantiene una sola tabla `customer_invoice`, pero: + +* El dominio no conoce `is_proforma`. +* El filtrado vive exclusivamente en Infrastructure. + +Se ha decidido duplicar repositorios explícitamente. +No usar clases base compartidas. +No usar repositorios genéricos. + +--- + +## 8. Inyección de dependencias + +* Constructor injection explícita. +* Sin registry dinámico. +* Sin service locator. +* Si hay más de 2 dependencias → usar objeto `deps`. + +Ejemplo: + +```ts +constructor(private readonly deps: { + mapper: IProformaDomainMapper; + database: Database; +}) {} +``` + +Wiring en DI del módulo. + +--- + +## 9. Convención obligatoria de naming para mappers + +### A. HTTP (Infrastructure) + +**Request DTO** + +Formato: + +`RequestDTO` + +Ejemplo: + +* `CreateProformaRequestDTO` + +**HTTP → Command** + +Formato: + +`RequestMapper` + +Ejemplo: + +* `CreateProformaRequestMapper` + +--- + +### B. Application + +**Command** + +Formato: + +`Command` + +Ejemplo: + +* `CreateProformaCommand` + +**Command → Props** + +Formato: + +`PropsMapper` + +Ejemplo: + +* `CreateProformaPropsMapper` + +--- + +### C. Persistencia (Infrastructure / Sequelize) + +Un único mapper bidireccional por agregado: + +Formato: + +`DomainMapper` + +Métodos obligatorios: + +* `toPersistence()` +* `toDomain()` + +Ejemplo: + +* `ProformaDomainMapper` +* `IssuedInvoiceDomainMapper` + +--- + +### Reglas anti-ambigüedad + +Prohibido: + +* `CreateProformaMapper` +* `DtoMapper` +* `Transformer` +* `Adapter` sin necesidad real + +El sufijo debe indicar claramente la dirección. + +--- + +## 10. Servicios de aplicación + +Se adoptan servicios pequeños y explícitos: + +* `ProformaCreator` +* `ProformaUpdater` +* `ProformaDeleter` +* `ProformaIssuer` + +Reglas: + +* Responsabilidad única. +* No contener lógica HTTP. +* No acceder directamente a Sequelize. +* No mezclar múltiples operaciones en un único servicio. + +Se evita `CommandService`. + +--- + +## 11. Casos de uso + +Los use cases: + +* Son ligeros. +* Orquestan. +* Gestionan transacción. +* Delegan en servicios. +* Construyen snapshot de salida. +* Devuelven `Result`. + +No deben: + +* Mapear DTO. +* Construir agregados. +* Persistir directamente. +* Ejecutar lógica de negocio. + +--- + +## 12. Controllers Express + +Ubicación: + +``` +infrastructure/express// +``` + +Responsabilidad: + +* HTTP → RequestDTO +* RequestDTO → Command (mediante RequestMapper) +* Llamar UseCase +* Traducir Result → HTTP + +Nunca: + +* Acceder a repositorios. +* Construir agregados. +* Ejecutar lógica de dominio. + +--- + +## 13. Colaboración entre módulos + +Reglas: + +* Solo se exponen servicios explícitos. +* No se exponen repositorios. +* No se accede a infraestructura interna. +* Core es la única dependencia técnica común. + +No introducir Event Bus global ni abstracciones prematuras. + +--- + +## 14. Decisiones clave adoptadas + +* Separación real Proforma / IssuedInvoice. +* Snapshot materializado para facturas emitidas. +* Un repositorio por agregado. +* Una sola tabla por ahora. +* Duplicación preferida a abstracción prematura. +* Inyección explícita de dependencias. +* Mapper bidireccional único por agregado (persistencia). +* Un mapper por operación (RequestMapper, PropsMapper). +* Flujo obligatorio RequestDTO → Command → Props → Aggregate. + +--- + +# 15. Convenciones de naming adoptadas + +Interfaces: + +* Prefijo obligatorio `I` + + * `ICustomerInvoiceRepository` + * `IIssuedInvoiceFinder` + * `ICustomerInvoiceFactory` + * `ICustomerInvoiceNumberService` + +Servicios: + +* `*Finder` → lectura +* `*WriteService` → escritura +* `*Factory` → construcción +* `*NumberService` → numeración + +Se evita: + +* `CommandService` +* Nombres ambiguos +* ServiceLocator + +--- + +# 16. Separación lectura / escritura (pragmática) + +No se implementa CQRS formal completo. + +Pero sí se separa: + +* Finder (consultas) +* WriteService (mutaciones) + +Motivo: + +* Claridad semántica +* Mejor legibilidad +* Mejor separación de responsabilidades + +Sin introducir sobreingeniería. + +--- + +# 17. Mappers + +Ubicación: + +``` +infrastructure/persistence/sequelize/mappers/ +``` + +Tipos: + +* domain/ +* list/ + +Los mappers: + +* Solo los usa el repositorio. +* No se exponen a Application. +* No se usan fuera de Infrastructure. + +--- + +# 18. Reglas estrictas de dependencia + +Dominio: + +* No depende de nada externo. + +Application: + +* Depende solo de Dominio + interfaces + Core. + +Infrastructure: + +* Depende de Application + Dominio + Core. + +Nunca: + +* Application → Infrastructure +* Dominio → Application +* Módulo A → Infra de módulo B + +--- + +# 19. Desarrollo de nuevos módulos + +Para crear un nuevo módulo: + +1. Crear estructura: + + ``` + new-module/api/application + new-module/api/infrastructure + ``` + +2. Definir: + + * Dominio + * Interfaces en Application + * Use cases + * Servicios de lectura/escritura + +3. Implementar Infra: + + * Repositorios + * Controllers + * DI + +4. Registrar módulo en el loader global. + +5. Exponer solo servicios necesarios mediante `registerDependencies`. + +No copiar lógica transversal: usar Core. + +--- + +# 20. Colaboración entre módulos + +Para que módulo A use lógica de módulo B: + +1. B debe exponer servicio explícito. +2. A debe declarar dependencia. +3. El módulo loader debe garantizar orden de carga. +4. Se usa interfaz, no implementación concreta. + +Nunca: + +* Importar ficheros directamente. +* Acceder a infraestructura interna. +* Compartir repositorios. + +--- + +# 21. Evitar overengineering + +No introducir: + +* Event bus global si no es necesario. +* Contenedores DI complejos. +* Domain events si no hay necesidad real. +* CQRS formal. +* Versionado prematuro. +* Abstracciones sin uso inmediato. + +Cada abstracción debe tener un caso real. + +--- + +# 22. Estilo de trabajo y preferencias + +Responder: + +* En castellano. +* Código en inglés. +* Comentarios en castellano. +* Directo y técnico. +* Sin introducciones innecesarias. +* Sin emoticonos. +* Señalar incoherencias. +* Proponer refactors si mejoran claridad. +* Priorizar arquitectura correcta. +* Minimizar tokens sin perder precisión. +* Revisar siempre riesgo de overengineering. +* Mantener coherencia modular. +* Diseñar nuevos módulos. +* Garantizar separación correcta de capas. +* Establecer contratos claros entre módulos. +* Evitar dependencias cruzadas indebidas. +* Evolucionar el sistema sin romper principios estructurales. + +Ante cualquier nueva propuesta: + +* Validar necesidad real. +* Evaluar impacto estructural. +* Evitar abstracción anticipada. + +--- + +## 23. Convención de creación y rehidratación (obligatoria) + +### 23.1 Principio general + +Se establecen dos operaciones semánticamente distintas en el dominio: + +```text +create() → construcción funcional (nacimiento en el dominio) +rehydrate() → reconstrucción desde persistencia +``` + +Estas operaciones **no son intercambiables** y deben mantenerse separadas en todo el sistema. + +--- + +## 23.2 Entidades y Aggregate Roots + +### create() + +```ts +static create(props: CreateProps, id?: UniqueID): Result +``` + +Responsabilidades: + +* Crear una nueva instancia de dominio +* Aplicar invariantes de negocio +* Validar datos de entrada +* Construir entidades internas +* Registrar domain events (cuando se implementen) + +Características: + +* Devuelve `Result` +* Puede fallar +* Representa “nacimiento” del objeto en el dominio + +--- + +### rehydrate() + +```ts +static rehydrate(props: InternalProps, id: UniqueID): Entity +``` + +Responsabilidades: + +* Reconstruir una instancia desde persistencia +* Restaurar estado previamente válido + +Características: + +* No devuelve `Result` +* No valida reglas de creación +* No genera eventos +* No ejecuta lógica adicional + +--- + +### Reglas obligatorias + +* `create()` **NO se usa en infraestructura** +* `rehydrate()` **NO se usa en casos de uso** +* `create()` y `rehydrate()` **NO deben compartir lógica implícita** +* El constructor debe ser `protected` + +--- + +## 23.3 Value Objects + +### create() + +```ts +static create(props: Props): Result +``` + +Responsabilidades: + +* Validar el valor +* Normalizar si aplica +* Construir una instancia inmutable + +Características: + +* Sin identidad +* Sin eventos +* Sin distinción entre creación y reconstrucción + +--- + +### Reglas obligatorias + +* Los Value Objects **NO tienen `rehydrate()`** +* Los Value Objects **siempre se reconstruyen mediante `create()`** +* Los Value Objects **no dependen de persistencia** + +--- + +## 23.4 Uso en el sistema + +### Flujo de creación funcional + +```text +Controller + ↓ +UseCase + ↓ +Aggregate.create() + ↓ +Repository.save() +``` + +--- + +### Flujo de reconstrucción + +```text +Repository + ↓ +DomainMapper + ↓ +Aggregate.rehydrate() +``` + +--- + +## 23.5 Reglas de diseño adicionales + +* `update()` **nunca debe usar `create()` ni `rehydrate()`** +* Las entidades **mutan su estado**, no se recrean +* Los Value Objects **sí pueden recrearse libremente** +* El dominio **no debe asumir origen de los datos** +* Infraestructura es la única capa que usa `rehydrate()` + +--- + +## 23.6 Naming adicional (opcional) + +Se permiten factories específicas en Value Objects cuando mejore la claridad: + +```ts +UtcDate.createFromISO(...) +Money.fromDecimal(...) +TaxCode.fromString(...) +``` + +Estas no sustituyen a `create()`, sino que complementan su uso. + +--- + +## 23.7 Principio final + +> create() = nuevo estado válido en el dominio +> rehydrate() = estado ya válido restaurado + +Esta distinción es obligatoria en todo el ERP. + diff --git a/docs/DTOS - GUÍA DE DISEÑO.md b/docs/architecture/DTOS - GUÍA DE DISEÑO.md similarity index 100% rename from docs/DTOS - GUÍA DE DISEÑO.md rename to docs/architecture/DTOS - GUÍA DE DISEÑO.md diff --git a/docs/architecture/audit-system-design.md b/docs/audit/audit-system-design.md similarity index 80% rename from docs/architecture/audit-system-design.md rename to docs/audit/audit-system-design.md index 5b604128..19a56fa4 100644 --- a/docs/architecture/audit-system-design.md +++ b/docs/audit/audit-system-design.md @@ -141,11 +141,38 @@ Normalmente no se auditan validation failure, not found, rechazo de dominio, cro | 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 diff --git a/docs/companies/company-requirements.md b/docs/companies/company-requirements.md new file mode 100644 index 00000000..3c20f965 --- /dev/null +++ b/docs/companies/company-requirements.md @@ -0,0 +1,234 @@ +# Requisitos de `companies` + +Estado documental: `CURRENT` + +Fecha de actualizacion: 2026-08-26 + +## Proposito y fuente de verdad + +`companies` es el bounded context y la fuente de verdad de la ficha operativa +de cada empresa tenant del ERP. Este documento define el contrato funcional +vigente del contexto. + +La autenticacion, las cuentas y las memberships no pertenecen a `companies`. +Esas responsabilidades corresponden a `identity`. Para las decisiones de +integracion entre ambos contextos, prevalece +[`identity-and-companies.md`](../architecture/identity-and-companies.md). Para +la resolucion del tenant, prevalece +[`tenant-context.md`](../architecture/tenant-context.md). + +## Alcance actual + +El contexto debe gestionar: + +- la ficha legal y operativa basica de `Company`; +- el identificador funcional `slug`; +- el estado operativo de la empresa (`active` o `disabled`); +- el plazo de pago por defecto de la empresa; +- la consulta de empresas disponibles para una cuenta autenticada; +- la consulta y actualizacion de la empresa activa. + +El contexto no debe gestionar: + +- autenticacion, sesiones, refresh tokens o cuentas; +- `CompanyMembership`, roles o permisos por cuenta; +- datos de la empresa dentro de `identity`; +- clientes como representacion de la empresa tenant; +- fiscalidad avanzada, perfiles VeriFactu, series documentales, medios de pago, + direccion, branding, bancos, idiomas, divisas o zona horaria. + +Los requisitos de VeriFactu empresarial viven en `customer-invoices` y no +amplian por si mismos el aggregate `Company`. + +## Modelo funcional + +Una empresa se identifica por un UUID y contiene los siguientes datos: + +| Campo | Regla vigente | +| --- | --- | +| `legal_name` | Obligatorio. Nombre legal de la empresa. | +| `trade_name` | Opcional y nullable. Nombre comercial. | +| `tin` | Opcional y nullable. Identificador fiscal. | +| `slug` | Obligatorio, unico, normalizado a minusculas; solo letras, numeros y guiones, de 2 a 100 caracteres. | +| `email` | Opcional y nullable; debe tener formato valido. | +| `phone` | Opcional y nullable. | +| `website` | Opcional y nullable; debe tener formato URL valido. | +| `default_payment_term_id` | Opcional y nullable; referencia viva a un plazo de pago activo de la misma empresa. | +| `status` | `active` o `disabled`. | + +La persistencia de `companies` debe conservar tambien `created_at` y +`updated_at`. `slug` es unico; deben existir indices para `status` y `tin`. + +## Separacion con `identity` + +- `identity` es propietario de `Account` y `CompanyMembership`. +- `companies` es propietario de `Company` y de su ficha operativa. +- El access token solo identifica la cuenta; no contiene `companyId`, + `companySlug`, roles ni permisos. +- `identity` resuelve que IDs de empresa son accesibles para una cuenta. +- `companies` resuelve las fichas completas de esas empresas y valida que una + empresa este activa. +- No se deben crear `GET /identity/companies` ni `GET /account/companies`. +- No se debe crear una dependencia `identity -> companies`. + +## Contexto tenant y autorizacion V1 + +Las rutas tenant-scoped deben recibir: + +```http +Authorization: Bearer +X-Company-Id: +``` + +El contexto tenant es valido solo si la cuenta esta autenticada, el header es +un UUID valido, existe una membership `active` para esa empresa y la empresa +tiene estado `active`. La empresa destino se toma de ese contexto, nunca de un +`companyId` libre en body, query o ruta cuando se trate de la API actual de +empresa activa. + +En la V1, cualquier miembro activo de una empresa activa puede actualizar los +datos generales de esa empresa mediante `PATCH /companies/current`. No existe +todavia enforcement runtime de RBAC ni un permiso granular efectivo como +`identity:companies:update`. + +Una empresa deshabilitada no puede actualizarse. Los endpoints legacy con +`:company_id` deben exigir que dicho ID coincida exactamente con la empresa +resuelta en el contexto tenant. + +## Contratos HTTP vigentes + +### Alta y seleccion de empresa + +```http +POST /companies +GET /companies/available +``` + +`POST /companies` crea una empresa activa. Recibe `id`, `legal_name` y `slug` +como datos obligatorios; el resto de datos de ficha son opcionales. Al crearla, +`default_payment_term_id` queda vacio y el estado es `active`. + +`GET /companies/available` requiere autenticacion, pero no `X-Company-Id`. +Debe devolver solo las empresas que cumplan simultaneamente: + +```txt +membership activa de la cuenta ++ +empresa activa +``` + +La respuesta no debe modificar la sesion ni el access token. + +### Empresa activa + +```http +GET /companies/current +PATCH /companies/current +``` + +Estas son las rutas canonicamente usadas por Settings para +`/settings/company/details`. Requieren contexto tenant valido y operan siempre +sobre la empresa resuelta mediante `X-Company-Id`. + +`PATCH /companies/current` admite exclusivamente: + +```txt +legal_name +trade_name +tin +email +phone +website +default_payment_term_id +``` + +No admite modificar `slug` ni `status`. La semantica es PATCH real: + +- un campo omitido conserva su valor; +- un campo nullable presente con `null` limpia su valor; +- un campo presente con valor valido actualiza su valor; +- la respuesta del servidor es el estado autoritativo de la ficha. + +### Rutas legacy por ID + +```http +GET /companies/:company_id +PATCH /companies/:company_id +POST /companies/:company_id/enable +POST /companies/:company_id/disable +``` + +Estas rutas siguen expuestas por compatibilidad. Requieren contexto tenant +valido y el `:company_id` debe ser la misma empresa activa del header. En la +actualizacion legacy se permite adicionalmente modificar `slug`; su unicidad +debe validarse antes de persistir. + +## Plazo de pago por defecto + +`default_payment_term_id` es una referencia nullable y company-scoped hacia +`catalogs`. Su contrato es: + +- omitido: conservar la asociacion actual; +- `null`: desvincular el plazo de pago; +- UUID: asociar solo si identifica un plazo activo de la misma empresa. + +Una referencia inexistente, de otra empresa o inactiva debe responder `422` con +el mensaje opaco `Default payment term is not assignable to the active company.` +No se debe revelar cual de esas causas se produjo. La validacion y la +actualizacion deben formar una unica transaccion: si falla la referencia, no se +persisten otros cambios del mismo PATCH. + +## Respuesta de empresa + +Las consultas y mutaciones de ficha devuelven: + +```ts +type CompanyResponseDTO = { + id: string; + legal_name: string; + trade_name: string | null; + tin: string | null; + slug: string; + email: string | null; + phone: string | null; + website: string | null; + default_payment_term_id: string | null; + status: "active" | "disabled"; +}; +``` + +La lista de `GET /companies/available` devuelve el mismo subconjunto de ficha +necesario para seleccionar empresa, sin `default_payment_term_id`, y con +`status: "active"`. + +## Errores esperados + +| Situacion | Respuesta | +| --- | --- | +| Sin autenticacion, token invalido o cuenta no autenticable | `401` | +| UUID tenant mal formado o body invalido | `400` | +| Sin `X-Company-Id`, membership no activa, empresa no accesible, deshabilitada o destino distinto al tenant | `403` | +| Colision de `slug` | `409` | +| Campo con regla de dominio invalida o plazo de pago no asignable | `422` | +| Error no controlado | `500` | + +## Auditoria y consistencia + +Las operaciones de alta y actualizacion de empresa deben registrar sus eventos +de auditoria. Las mutaciones deben ser transaccionales para no dejar la ficha y +el registro de auditoria en estados divergentes. + +## Fuera de alcance y evolucion futura + +No se deben inferir como requisitos actuales: + +- RBAC granular o administracion global de empresas; +- cambios de `status` autorizados por un rol administrativo real; +- direccion, pais, idioma, moneda, zona horaria o branding; +- cuentas bancarias, logo, medios de pago o `defaultPaymentMethodId`; +- fiscalidad avanzada, configuracion VeriFactu o series documentales. + +Estas capacidades requieren un contrato propio antes de incorporarse a +`companies`. Hasta entonces, los documentos de otros bounded contexts deben +enlazar esta especificacion al describir la ficha de empresa, sin duplicar ni +reasignar su ownership. diff --git a/docs/customer-invoices/README.md b/docs/customer-invoices/README.md index 75f1762a..ca983d07 100644 --- a/docs/customer-invoices/README.md +++ b/docs/customer-invoices/README.md @@ -1,13 +1,25 @@ # customer-invoices -Índice de documentación del área de `customer-invoices`, con separación explícita entre contratos vigentes, soporte operativo e histórico. +Índice de documentación del área de `customer-invoices`, con separación explícita entre SSOT vigente, documentación parcialmente superada y material histórico. -## Documentación vigente +## SSOT de lectura rápida + +- Semántica operativa de proformas: `proforma-series-contract.md`, `proforma-create-contract.md`, `proforma-tax-config.md` +- Numeración documental: `../document-series/README.md` +- Perfil VeriFactu empresarial: `settings/verifactu-company-profile-backend-design.md` +- Auditoría SSOT transversal: `../documentation-ssot-audit.md` + +Regla crítica vigente: + +- `IssuedInvoice` no tiene `verifactuPolicy` como requisito actual. +- La cardinalidad vigente es `IssuedInvoice 1 -> 0..1 VerifactuRecord`. +- La dirección futura deseada es `IssuedInvoice 1 -> 0..N VerifactuRecord`. + +## Documentación vigente (`CURRENT`) ### Contratos funcionales y de transporte - [Plazos de pago](../catalogs/payment-terms.md) -- [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md) - [Contrato de series de proformas](./proforma-series-contract.md) - [Contrato de create de proformas](./proforma-create-contract.md) - [Contrato de readiness de emisión de proformas](./proforma-issuance-readiness-contract.md) @@ -19,6 +31,11 @@ - [Contrato de series de issued invoices](./issued-invoice-series-contract.md) - [Contrato de snapshot documental](./document-series-snapshot-contract.md) +## Documentación vigente con secciones superadas (`PARTIALLY SUPERSEDED`) + +- [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md) + Referencia canónica para `CompanyVerifactuProfile` y settings backend; no usar sus secciones antiguas como requisito para reintroducir `IssuedInvoice.verifactuPolicy`. + ### Reports y flujos integrados - [Reports de proformas](./proformas-reports.md) @@ -28,7 +45,7 @@ - [Índice SQL de `customer-invoices`](./sql/README.md) -## Histórico o transicional +## Histórico o transicional (`HISTORICAL`) - [Split Proformas / Issued Invoices](./split-proformas-issued-invoices-migration.md) - [InvoiceSeries legacy](./invoice-series.md) @@ -38,3 +55,4 @@ - Para semántica vigente de negocio, empieza por `proforma-series-contract.md`, `proforma-create-contract.md` y `proforma-tax-config.md`. - Para SQL manual o runbooks, usa `sql/README.md`. - Para decisiones antiguas, migraciones o trazabilidad, consulta solo la sección histórica. +- Para saber qué documento manda en cada contexto y qué piezas están superadas, consulta `../documentation-ssot-audit.md`. diff --git a/docs/customer-invoices/settings/verifactu-company-profile-backend-design.md b/docs/customer-invoices/settings/verifactu-company-profile-backend-design.md index 1cfbbad2..0ac7e4e6 100644 --- a/docs/customer-invoices/settings/verifactu-company-profile-backend-design.md +++ b/docs/customer-invoices/settings/verifactu-company-profile-backend-design.md @@ -2,7 +2,7 @@ ## 1. Estado del documento -Estado: **IMPLEMENTADO PARCIALMENTE** +Estado: **PARTIALLY SUPERSEDED** Este documento define el diseño backend del perfil VeriFactu de empresa dentro del módulo `customer-invoices`. @@ -14,12 +14,28 @@ Decisiones consolidadas: - El estado de configuración es explícito. - Se permite `obligated_without_verifactu` como estado real. - Los catálogos de clasificación y exención quedan modelados por código pero no se cierran todavía como enums. -- `IssuedInvoice` deberá persistir dos snapshots distintos en una fase posterior: - - clasificación fiscal efectiva; - - política VeriFactu efectiva. +- `IssuedInvoice` no incorpora actualmente `verifactuPolicy` como requisito vigente. +- La integración vigente de `IssuedInvoice` con VeriFactu es: + - snapshot fiscal efectivo cuando aplique; + - relación operativa actual `IssuedInvoice 1 -> 0..1 VerifactuRecord`. +- La dirección objetivo futura sigue siendo: + - `IssuedInvoice 1 -> 0..N VerifactuRecord` para histórico operativo. - `VerifactuRecord` mantiene responsabilidad operativa y no absorbe el perfil empresarial. - El perfil no expone secretos ni passwords de certificados. +Alcance canónico actual de este documento: + +- diseño del agregado `CompanyVerifactuProfile`; +- reglas de configuración empresarial VeriFactu; +- API/settings de perfil empresarial; +- interacción conceptual con emisión solo en lo relativo a clasificación fiscal. + +Secciones superadas dentro de este mismo documento: + +- cualquier requisito que pida persistir `IssuedInvoice.verifactuPolicy`; +- cualquier DDL o criterio de aceptación que reintroduzca `verifactu_profile_status`, `uses_verifactu` o `verifactu_effective_at` en `issued_invoices`; +- cualquier lectura que presente `IssuedInvoice 1 -> 0..N VerifactuRecord` como comportamiento ya implantado. + ## 2. Propósito Proporcionar una fuente de verdad empresarial para: @@ -30,7 +46,7 @@ Proporcionar una fuente de verdad empresarial para: - fecha efectiva de activación; - renovación automática si aplica; - defaults de clasificación fiscal; -- snapshot histórico al emitir una factura. +- snapshot histórico fiscal al emitir una factura. El diseño debe integrarse con el flujo actual: @@ -246,17 +262,23 @@ export interface IssuedInvoiceFiscalClassificationSnapshot { Queda diseñado, pero no implementado todavía en esta fase. -## 12. Snapshot de política VeriFactu +## 12. Nota superada sobre política VeriFactu en `IssuedInvoice` -```ts -export interface IssuedInvoiceVerifactuPolicySnapshot { - profileStatus: CompanyVerifactuProfileStatus; - usesVerifactu: boolean; - effectiveAt: string | null; -} -``` +La propuesta anterior de añadir un snapshot `verifactuPolicy` a `IssuedInvoice` +queda superada y no forma parte del SSOT vigente. -Queda diseñado, pero no implementado todavía en esta fase. +Estado actual: + +- `IssuedInvoice` no debe añadir ni persistir `verifactuPolicy`; +- no debe reintroducir columnas legacy como `verifactu_profile_status`, + `uses_verifactu` o `verifactu_effective_at`; +- `VerifactuRecord` sigue siendo la pieza operativa para el lifecycle VeriFactu; +- la cardinalidad vigente implementada es `IssuedInvoice 1 -> 0..1 VerifactuRecord`. + +Dirección futura: + +- evolucionar a `IssuedInvoice 1 -> 0..N VerifactuRecord` cuando el histórico + operativo entre en alcance real. ## 13. Resolución durante la emisión @@ -275,8 +297,7 @@ ProformaIssueReadModelAssembler proforma-to-issued-invoice-props-converter │ ├── resuelve precedencia - ├── construye FiscalClassificationSnapshot - └── construye VerifactuPolicySnapshot + └── construye FiscalClassificationSnapshot │ ▼ IssuedInvoice.create(...) @@ -287,7 +308,7 @@ IssuedInvoice.create(...) `IssuedInvoice`: - snapshot documental e histórico; - clasificación fiscal aplicada; -- política VeriFactu aplicada. +- relación documental con el contexto de emisión. `VerifactuRecord`: - estado operativo; @@ -295,9 +316,15 @@ IssuedInvoice.create(...) - QR; - UUID; - tipo de operación. +- histórico operativo futuro. No debe contener defaults empresariales ni obligación/exención. +Cardinalidad: + +- vigente: `IssuedInvoice 1 -> 0..1 VerifactuRecord`; +- objetivo futuro: `IssuedInvoice 1 -> 0..N VerifactuRecord`. + ## 15. Certificados El perfil no contiene: @@ -434,7 +461,14 @@ DDL operativo vigente: ```txt docs/customer-invoices/sql/add-company-verifactu-profiles.sql +``` + +SQL histórico/superado relacionado con la antigua propuesta de snapshots +VeriFactu en `issued_invoices`: + +```txt docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql +docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql ``` ## 22. Concurrencia @@ -454,12 +488,11 @@ Orden recomendado: ```txt 1. ejecutar DDL de `company_verifactu_profiles` -2. ejecutar DDL de snapshots VeriFactu en `issued_invoices` -3. persistencia y GET seguro -4. PUT/configuración -5. UI Settings -6. uso de defaults en emisión -7. enforcement posterior +2. persistencia y GET seguro +3. PUT/configuración +4. UI Settings +5. uso de defaults en emisión +6. enforcement posterior ``` La ausencia de perfil no debe romper automáticamente la emisión actual durante transición. @@ -545,32 +578,40 @@ versionado ## 28. Integración en `IssuedInvoice` -Implementado en Fase B: +Estado documental vigente: ```txt CompanyVerifactuProfile -→ ProformaIssueReadModelAssembler -→ resolución backend de clasificación/política -→ ProformaToIssuedInvoiceConverter -→ IssuedInvoice -→ persistencia física en issued_invoices +→ configuración empresarial +→ resolución backend de clasificación fiscal cuando aplique +→ IssuedInvoice / emisión +→ VerifactuRecord operativo ``` -`IssuedInvoice` mantiene `taxRegime` como snapshot legacy existente y añade además: +`IssuedInvoice` mantiene `taxRegime` como snapshot legacy existente y, como +evolución vigente de diseño, solo puede incorporar snapshots fiscales +compatibles con el documento emitido. + +No forma parte del SSOT actual: ```txt -fiscalClassification verifactuPolicy ``` -Decisiones reales: +Sí forma parte del SSOT actual: + +```txt +fiscalClassification +``` + +Decisiones vigentes: - `taxRegimeCode` se resuelve con precedencia `document -> customer -> company -> sin valor`. - `operationQualificationCode` se resuelve hoy desde defaults empresariales o `null`. - `exemptionReasonCode` se resuelve hoy desde defaults empresariales o `null`. -- `verifactuPolicy.profileStatus` se persiste como señal de snapshot V2. -- facturas antiguas sin esa señal cargan con `fiscalClassification = null` y `verifactuPolicy = null`. - no se inventan labels de `operationQualification` ni `exemptionReason`; se exponen como `null` hasta disponer de catálogo backend real. +- la cardinalidad implementada entre `IssuedInvoice` y `VerifactuRecord` sigue siendo `1 -> 0..1`. +- la evolución futura deseada es `1 -> 0..N`, pero no debe documentarse como runtime actual. ## 29. Tests implementados @@ -591,7 +632,7 @@ Use cases / controllers: Fase B: - precedencia `document -> customer -> company` para `taxRegimeCode`; - fallback legacy sin perfil; -- validación de `IssuedInvoiceVerifactuPolicy`. +- separación explícita entre perfil empresarial y estado operativo VeriFactu. ## 30. Criterios de aceptación @@ -606,7 +647,9 @@ Fase B: 9. `VerifactuRecord` sigue siendo operativo. 10. No se exponen secretos. 11. Empresas sin perfil se leen como `not_configured`. -12. `IssuedInvoice` persiste snapshots de clasificación/política sin reescribir facturas legacy. +12. `IssuedInvoice` no reintroduce `verifactuPolicy` como requisito vigente. +13. La cardinalidad vigente es `IssuedInvoice 1 -> 0..1 VerifactuRecord`. +14. La dirección futura deseada es `IssuedInvoice 1 -> 0..N VerifactuRecord`. ## 31. Regla mental final @@ -618,10 +661,12 @@ CompanyVerifactuProfile → política/defaults empresariales IssuedInvoice -→ snapshot histórico efectivo +→ snapshot fiscal histórico efectivo cuando aplique +→ relación actual 1 -> 0..1 con VerifactuRecord VerifactuRecord → estado operativo +→ futuro histórico 0..N AEAT integration → lifecycle futuro diff --git a/docs/customer-invoices/split-proformas-issued-invoices-migration.md b/docs/customer-invoices/split-proformas-issued-invoices-migration.md index 5ee7a0cd..e7a7d03c 100644 --- a/docs/customer-invoices/split-proformas-issued-invoices-migration.md +++ b/docs/customer-invoices/split-proformas-issued-invoices-migration.md @@ -9,6 +9,12 @@ No describe el runtime minimo vigente por si solo; la fuente operativa actual pa - `docs/customer-invoices/proforma-tax-config.md` - `docs/catalogs/payment-terms.md` +Nota SSOT VeriFactu: + +- cardinalidad vigente implementada: `IssuedInvoice 1 -> 0..1 VerifactuRecord`; +- direccion futura deseada: `IssuedInvoice 1 -> 0..N VerifactuRecord`; +- este documento no debe usarse para reintroducir `IssuedInvoice.verifactuPolicy`. + Estado: diseno tecnico / fases 1A-1D + 2A-2B Estado del backend: persistencia V2 activada; integracion con `document-series` activa para numeracion nueva; sin ejecucion confirmada de migracion DB diff --git a/docs/customer-invoices/sql/README.md b/docs/customer-invoices/sql/README.md index 03b054c6..e33a656b 100644 --- a/docs/customer-invoices/sql/README.md +++ b/docs/customer-invoices/sql/README.md @@ -8,10 +8,16 @@ Las tablas legacy `customer_invoices`, `customer_invoice_items` y `customer_invoice_taxes` no forman parte del runtime actual. Solo pueden aparecer en SQL historico de migracion o validacion. +Regla SSOT relevante para VeriFactu: + +- `issued_invoices` no debe reintroducir `verifactu_profile_status`, + `uses_verifactu` ni `verifactu_effective_at` como columnas vigentes; +- la cardinalidad implementada sigue siendo `IssuedInvoice 1 -> 0..1 VerifactuRecord`; +- `0..N` es objetivo futuro, no estado operativo actual. + ## SQL vigentes - `add-company-verifactu-profiles.sql` -- `add-issued-invoice-verifactu-snapshots.sql` - `add-proforma-document-series-columns.sql` - `add-proforma-tax-config-columns.sql` - `add-issued-invoice-reference.sql` @@ -22,6 +28,8 @@ en SQL historico de migracion o validacion. ## SQL históricos / transicionales +- `add-issued-invoice-verifactu-snapshots.sql` +- `drop-issued-invoice-verifactu-snapshot-columns.sql` - `dev-split-proformas-issued-invoices.sql` - `validate-split-proformas-issued-invoices.sql` @@ -32,11 +40,17 @@ Estos archivos no deben tratarse como el camino normal de entornos nuevos salvo 1. Verificar que la BD objetivo es inequívocamente de desarrollo. 2. Hacer backup o confirmar que los datos son descartables. 3. Ejecutar `add-company-verifactu-profiles.sql` si el entorno aún no tiene el perfil VeriFactu empresarial. -4. Ejecutar `add-issued-invoice-verifactu-snapshots.sql` si el entorno aún no tiene los snapshots fiscales/política en `issued_invoices`. -5. Ejecutar el DDL vigente que falte para `proformas`. -6. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado. -7. Ejecutar las validaciones SQL vigentes. -8. Levantar backend V2 y validar flujos funcionales. +4. Ejecutar el DDL vigente que falte para `proformas`. +5. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado. +6. Ejecutar las validaciones SQL vigentes. +7. Levantar backend V2 y validar flujos funcionales. + +## SQL expresamente superados para VeriFactu en `issued_invoices` + +- `add-issued-invoice-verifactu-snapshots.sql` responde a una propuesta anterior + que mezclaba snapshots fiscales con un `verifactuPolicy` hoy descartado. +- `drop-issued-invoice-verifactu-snapshot-columns.sql` se conserva como runbook + correctivo para entornos legacy donde esas columnas todavía existan. ## Propiedades de los scripts históricos de split diff --git a/docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql b/docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql index c3eaadb6..28cb592e 100644 --- a/docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql +++ b/docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql @@ -1,6 +1,9 @@ --- DDL vigente para snapshots fiscales y de politica VeriFactu en issued_invoices. --- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado. --- No ejecutar automaticamente en produccion. +-- Estado documental: SUPERSEDED / solo auditoria legacy. +-- Este script responde a una propuesta antigua que reintroducia +-- `verifactu_profile_status`, `uses_verifactu` y `verifactu_effective_at` +-- en `issued_invoices`. +-- No es SSOT vigente y no debe ejecutarse como camino normal en entornos nuevos. +-- Ejecutar solo para auditar drift historico o comparar esquemas legacy. SET @target_schema = DATABASE(); diff --git a/docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql b/docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql index a0f96046..50f87513 100644 --- a/docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql +++ b/docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql @@ -1,4 +1,7 @@ --- Elimina las columnas legacy de snapshot VeriFactu de `issued_invoices`. +-- Estado documental: CURRENT como runbook correctivo para entornos legacy. +-- Elimina columnas obsoletas de snapshot VeriFactu en `issued_invoices`. +-- Refuerza el SSOT vigente: `IssuedInvoice` no debe persistir +-- `verifactu_profile_status`, `uses_verifactu` ni `verifactu_effective_at`. -- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado. -- No ejecutar automaticamente en produccion. diff --git a/docs/customer-invoices/sql/validate-split-proformas-issued-invoices.sql b/docs/customer-invoices/sql/validate-split-proformas-issued-invoices.sql index 52fe8725..eebe6c50 100644 --- a/docs/customer-invoices/sql/validate-split-proformas-issued-invoices.sql +++ b/docs/customer-invoices/sql/validate-split-proformas-issued-invoices.sql @@ -112,6 +112,9 @@ WHERE ii.id IS NULL; -- --------------------------------------------------------------------------- -- Verifactu -- --------------------------------------------------------------------------- +-- Cardinalidad vigente esperada: IssuedInvoice 1 -> 0..1 VerifactuRecord. +-- Direccion futura deseada: IssuedInvoice 1 -> 0..N VerifactuRecord. +-- Este SQL historico no debe interpretarse como prueba de que 0..N ya esta implantado. SELECT COUNT(*) AS orphan_verifactu_records FROM verifactu_records vr diff --git a/docs/documentation-ssot-audit.md b/docs/documentation-ssot-audit.md new file mode 100644 index 00000000..713cd9ec --- /dev/null +++ b/docs/documentation-ssot-audit.md @@ -0,0 +1,210 @@ +# Auditoría documental SSOT + +Fecha de actualización: 2026-08-26 + +## Objetivo + +Fijar una lectura única y consistente de la documentación técnica del ERP para +evitar que documentos históricos, propuestas o secciones parcialmente superadas +se interpreten como contrato vigente. + +Estados usados en esta auditoría: + +- `CURRENT`: fuente canónica vigente. +- `PARTIALLY SUPERSEDED`: documento útil, pero con secciones ya superadas. +- `SUPERSEDED`: documento conservado por trazabilidad, pero no vigente. +- `PROPOSAL`: diseño o dirección futura aún no implantada. +- `HISTORICAL`: contexto, migración o auditoría de un estado anterior. + +## Reglas transversales + +- Si un área tiene SSOT explícito, el resto de documentos deben enlazarlo y no duplicar semántica. +- Un objetivo futuro no debe presentarse como comportamiento operativo actual. +- En `customer-invoices`, el estado vigente es: + - `IssuedInvoice` no tiene `verifactuPolicy` como requisito actual. + - La cardinalidad vigente es `IssuedInvoice 1 -> 0..1 VerifactuRecord`. + - La dirección futura deseada es `IssuedInvoice 1 -> 0..N VerifactuRecord`. + +## Referencia canónica por bounded context + +### `identity` + +Estado del contexto: `CURRENT` + +Referencias canónicas: + +- `docs/architecture/README.md` +- `docs/architecture/tenant-context.md` +- `docs/architecture/identity-auth-api.md` +- `docs/architecture/identity-and-companies.md` +- `docs/architecture/identity-migration-status.md` + +Documentos a vigilar: + +- `docs/architecture/identity-backend-architecture.md` + Clasificación: `PARTIALLY SUPERSEDED` + Acción: mantener como nota complementaria y no como contrato operativo principal. + +### `companies` + +Estado del contexto: `CURRENT` + +Referencias canónicas: + +- `docs/companies/company-requirements.md` +- `docs/architecture/identity-and-companies.md` +- `modules/companies/README.md` + +Documentos a vigilar: + +- Cualquier documento que presente datos de empresa dentro de `identity` como ownership de negocio. + Acción: enlazar a `identity-and-companies.md`. + +### `settings` (shell frontend transversal) + +Estado del contexto: `CURRENT` + +Referencias canónicas: + +- `docs/settings/frontend-erp-settings-system-design.md` +- `docs/settings/frontend-erp-settings-module-integration-design.md` + +Documentos a vigilar: + +- Cualquier diseño de módulo que describa el shell de Settings con comportamiento distinto al sistema modular actual. + Acción: resumir y enlazar a estos dos documentos. + +### `catalogs` + +Estado del contexto: `CURRENT` + +Referencias canónicas: + +- `docs/catalogs/payment-terms.md` + +Documentos a vigilar: + +- SQL auxiliares en `docs/catalogs/sql/` + Clasificación: `CURRENT` como soporte/manual, no como contrato funcional. + +### `document-series` + +Estado del contexto: `CURRENT` + +Referencias canónicas: + +- `docs/document-series/README.md` +- `docs/document-series/document-series-design.md` + +Documentos a vigilar: + +- `docs/document-series/migration-from-customer-invoice-series.md` + Clasificación: `HISTORICAL` + Acción: usar solo para trazabilidad de migración. + +### `customer-invoices` + +Estado del contexto: `CURRENT` con material mezclado de varias fases + +Referencias canónicas: + +- `docs/customer-invoices/README.md` +- `docs/customer-invoices/proforma-series-contract.md` +- `docs/customer-invoices/proforma-create-contract.md` +- `docs/customer-invoices/proforma-tax-config.md` +- `docs/customer-invoices/document-series-snapshot-contract.md` +- `docs/customer-invoices/settings/frontend-erp-settings-verifactu-design.md` + +Referencias canónicas con alcance parcial: + +- `docs/customer-invoices/settings/verifactu-company-profile-backend-design.md` + Clasificación: `PARTIALLY SUPERSEDED` + Canónico solo para: + - `CompanyVerifactuProfile` + - settings/backend de perfil VeriFactu + - separación entre defaults empresariales y estado operativo + No canónico para: + - reintroducir `IssuedInvoice.verifactuPolicy` + - presentar `IssuedInvoice 1 -> 0..N VerifactuRecord` como runtime vigente + +Documentos a corregir o retirar de la lectura operativa: + +- `docs/customer-invoices/split-proformas-issued-invoices-migration.md` + Clasificación: `HISTORICAL` + Acción: mantener solo como trazabilidad de split y migración. + +- `docs/customer-invoices/invoice-series.md` + Clasificación: `HISTORICAL` + Acción: enlazar siempre hacia `document-series`. + +- `docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql` + Clasificación: `SUPERSEDED` + Acción: no usar en entornos nuevos; conserva una propuesta descartada. + +- `docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql` + Clasificación: `CURRENT` + Acción: mantener como runbook correctivo para limpiar drift legacy. + +- `docs/customer-invoices/sql/validate-split-proformas-issued-invoices.sql` + Clasificación: `HISTORICAL` + Acción: usar solo como validación de un split legacy, no como contrato del runtime actual. + +### `reports` / generación documental + +Estado del contexto: `PARTIALLY SUPERSEDED` + +Referencias canónicas actuales en este árbol: + +- `docs/customer-invoices/proformas-reports.md` + +Riesgo documental: + +- todavía puede confundirse renderer concreto con arquitectura general de reporting. + +Acción: + +- cuando exista SSOT específico de `reports`, enlazarlo desde `customer-invoices` y evitar que documentos de módulo definan por sí solos la arquitectura transversal. + +## Clasificación de documentos clave + +| Documento | Estado | Referencia/acción | +|---|---|---| +| `docs/README.md` | `CURRENT` | Índice raíz y regla de navegación | +| `docs/architecture/README.md` | `CURRENT` | Índice canónico de arquitectura | +| `docs/settings/frontend-erp-settings-system-design.md` | `CURRENT` | SSOT del shell de Settings | +| `docs/settings/frontend-erp-settings-module-integration-design.md` | `CURRENT` | SSOT de integración modular en Settings | +| `docs/catalogs/payment-terms.md` | `CURRENT` | SSOT de plazos de pago | +| `docs/document-series/README.md` | `CURRENT` | SSOT operativo de series documentales | +| `docs/document-series/document-series-design.md` | `CURRENT` | Diseño canónico de `document-series` | +| `docs/document-series/migration-from-customer-invoice-series.md` | `HISTORICAL` | Solo migración y trazabilidad | +| `docs/customer-invoices/README.md` | `CURRENT` | Índice canónico del área | +| `docs/customer-invoices/proforma-series-contract.md` | `CURRENT` | SSOT de semántica de series en proformas | +| `docs/customer-invoices/proforma-create-contract.md` | `CURRENT` | SSOT de create de proformas | +| `docs/customer-invoices/proforma-tax-config.md` | `CURRENT` | SSOT de persistencia/configuración fiscal de proformas | +| `docs/customer-invoices/document-series-snapshot-contract.md` | `CURRENT` | SSOT de snapshot documental | +| `docs/customer-invoices/settings/frontend-erp-settings-verifactu-design.md` | `CURRENT` | SSOT frontend de Settings VeriFactu | +| `docs/customer-invoices/settings/verifactu-company-profile-backend-design.md` | `PARTIALLY SUPERSEDED` | Mantener como SSOT del perfil empresarial, no de `IssuedInvoice.verifactuPolicy` | +| `docs/customer-invoices/split-proformas-issued-invoices-migration.md` | `HISTORICAL` | Solo migración/split | +| `docs/customer-invoices/invoice-series.md` | `HISTORICAL` | Legacy | +| `docs/customer-invoices/sql/README.md` | `CURRENT` | Índice canónico de SQL manual | +| `docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql` | `SUPERSEDED` | No ejecutar como DDL vigente | +| `docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql` | `CURRENT` | Runbook correctivo | +| `docs/customer-invoices/sql/validate-split-proformas-issued-invoices.sql` | `HISTORICAL` | Validación de split legacy | +| `docs/API.md` | `HISTORICAL` | Borrador legacy | +| `docs/REQUISITOS GENERALES.md` | `HISTORICAL` | No usar como contrato vigente | +| `docs/REQUISITOS CLIENTES.md` | `HISTORICAL` | No usar como contrato vigente | +| `docs/FACTURAS DE CLIENTE.md` | `HISTORICAL` | Notas heredadas | + +## Correcciones prioritarias ya aplicadas + +- Eliminado el requisito vigente de `IssuedInvoice.verifactuPolicy` del diseño backend de VeriFactu. +- Explicitada la cardinalidad actual `IssuedInvoice 1 -> 0..1 VerifactuRecord`. +- Explicitada la dirección futura `IssuedInvoice 1 -> 0..N VerifactuRecord`. +- Reclasificado el SQL que añadía columnas VeriFactu legacy en `issued_invoices` como `SUPERSEDED`. +- Reforzada la navegación de índices para que el lector llegue antes al SSOT que al material histórico. + +## Siguientes correcciones recomendadas + +- Añadir estado documental visible al inicio de cualquier documento nuevo (`CURRENT`, `PARTIALLY SUPERSEDED`, `PROPOSAL`, etc.). +- Cuando aparezca una decisión nueva que invalide una anterior, marcar la sección desplazada dentro del documento antiguo en vez de dejar ambas coexistiendo sin etiqueta. +- Si se crea un SSOT transversal de `reports`, actualizar `docs/README.md` y `docs/customer-invoices/README.md` para enlazarlo como referencia canónica. diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index ffda90b6..4bf0749f 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -49,6 +49,7 @@ "@lglab/react-qr-code": "^1.4.10", "@repo/i18next": "workspace:*", "@repo/rdx-criteria": "workspace:*", + "@repo/rdx-audit": "workspace:*", "@repo/rdx-ddd": "workspace:*", "@repo/rdx-logger": "workspace:*", "@repo/rdx-ui": "workspace:*", @@ -69,4 +70,4 @@ "sequelize": "^6.37.8", "zod": "^4.3.6" } -} \ No newline at end of file +} diff --git a/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts new file mode 100644 index 00000000..0f9bf10d --- /dev/null +++ b/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.test.ts @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DiscountPercentage, TaxPercentage } from "@erp/core/api"; +import { + City, + Country, + CurrencyCode, + LanguageCode, + Name, + PostalCode, + Province, + Street, + TINNumber, + UniqueID, + UtcDate, +} from "@repo/rdx-ddd"; +import { Maybe, type Result } from "@repo/rdx-utils"; + +import { + InvoiceAmount, + InvoiceNumber, + InvoicePaymentMethod, + InvoicePaymentTerm, + InvoiceSerie, + InvoiceStatus, + InvoiceTaxRegime, + IssuedInvoice, + IssuedInvoiceFiscalClassification, + IssuedInvoiceRecipient, + IssuedInvoiceTax, + IssuedInvoiceTaxes, + ItemAmount, + ItemDescription, + ItemQuantity, + VerifactuRecord, + VerifactuRecordEstado, +} from "../../../domain"; + +import { toIssuedInvoiceAuditState } from "./issued-invoice-audit"; + +const required = (result: Result): T => { + if (result.isFailure) throw result.error; + return result.data; +}; +const amount = (value: number) => required(ItemAmount.create({ value, currency_code: "EUR" })); +const total = (value: number) => required(InvoiceAmount.create({ value, currency_code: "EUR" })); + +const createIssuedInvoiceFixture = (withOptionals = true): IssuedInvoice => { + const languageCode = required(LanguageCode.create("es")); + const currencyCode = required(CurrencyCode.create("EUR")); + const discount = required(DiscountPercentage.create({ value: 1500 })); + const globalDiscount = required(DiscountPercentage.create({ value: 250 })); + const iva = required(TaxPercentage.create({ value: 2100 })); + const rec = required(TaxPercentage.create({ value: 52 })); + const retention = required(TaxPercentage.create({ value: 1500 })); + const item = (description: string, quantity: number) => ({ + description: Maybe.some(required(ItemDescription.create(description))), + quantity: Maybe.some(required(ItemQuantity.create({ value: quantity }))), + unitAmount: Maybe.some(amount(1250)), + subtotalAmount: amount(3750), + itemDiscountPercentage: Maybe.some(discount), + itemDiscountAmount: amount(563), + globalDiscountPercentage: globalDiscount, + globalDiscountAmount: amount(80), + totalDiscountAmount: amount(643), + taxableAmount: amount(3107), + ivaCode: Maybe.some("iva_21"), + ivaPercentage: Maybe.some(iva), + ivaAmount: amount(652), + recCode: Maybe.some("rec_52"), + recPercentage: Maybe.some(rec), + recAmount: amount(162), + retentionCode: Maybe.some("ret_15"), + retentionPercentage: Maybe.some(retention), + retentionAmount: amount(466), + taxesAmount: amount(348), + totalAmount: amount(3455), + languageCode, + currencyCode, + }); + const recipient = required( + IssuedInvoiceRecipient.create({ + name: required(Name.create("Cliente histórico")), + tin: required(TINNumber.create("ESB1234567")), + street: required(Street.create("Calle Uno 1")), + street2: withOptionals ? Maybe.some(required(Street.create("Piso 2"))) : Maybe.none(), + city: required(City.create("Madrid")), + postalCode: required(PostalCode.create("28001")), + province: withOptionals ? Maybe.some(required(Province.create("Madrid"))) : Maybe.none(), + country: withOptionals ? Maybe.some(required(Country.create("ES"))) : Maybe.none(), + }) + ); + const paymentTerm = required( + InvoicePaymentTerm.create( + { + name: "30 días", + description: Maybe.some("Pago estándar"), + dueRules: [{ dueDays: "30", percentage: { value: "10000", scale: 2 } }], + }, + required(UniqueID.create("01900000-0000-7000-8000-000000000010")) + ) + ); + const tax = required( + IssuedInvoiceTax.create({ + taxableAmount: total(6214), + ivaCode: "iva_21", + ivaPercentage: iva, + ivaAmount: total(1305), + recCode: Maybe.some("rec_52"), + recPercentage: Maybe.some(rec), + recAmount: total(323), + retentionCode: Maybe.some("ret_15"), + retentionPercentage: Maybe.some(retention), + retentionAmount: total(932), + taxesAmount: total(696), + }) + ); + const fiscal = required( + IssuedInvoiceFiscalClassification.create({ + taxRegimeCode: "01", + taxRegimeLabel: Maybe.some("Régimen general"), + operationQualificationCode: Maybe.some("S1"), + exemptionReasonCode: Maybe.none(), + }) + ); + const verifactu = required( + VerifactuRecord.create({ + estado: VerifactuRecordEstado.createPendiente(), + url: Maybe.none(), + qrCode: Maybe.some("QR-001"), + uuid: Maybe.some("vf-001"), + operacion: Maybe.some("alta"), + }) + ); + return required( + IssuedInvoice.create( + { + companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000001")), + status: InvoiceStatus.issued(), + linkedProformaId: required(UniqueID.create("01900000-0000-7000-8000-000000000002")), + documentSeriesId: withOptionals + ? Maybe.some("01900000-0000-7000-8000-000000000003") + : Maybe.none(), + series: required(InvoiceSerie.create("F26")), + invoiceNumber: required(InvoiceNumber.create("173")), + invoiceReference: "F260173", + invoiceDate: required(UtcDate.createFromISO("2026-08-18")), + operationDate: withOptionals + ? Maybe.some(required(UtcDate.createFromISO("2026-08-06"))) + : Maybe.none(), + customerId: required(UniqueID.create("01900000-0000-7000-8000-000000000004")), + recipient, + reference: withOptionals ? Maybe.some("PO-42") : Maybe.none(), + description: "Factura emitida", + notes: Maybe.none(), + languageCode, + currencyCode, + paymentMethod: required( + InvoicePaymentMethod.create( + { name: "Transferencia" }, + required(UniqueID.create("01900000-0000-7000-8000-000000000005")) + ) + ), + paymentTerm: withOptionals ? Maybe.some(paymentTerm) : Maybe.none(), + taxRegime: required( + InvoiceTaxRegime.create({ code: "01", description: "Régimen general" }) + ), + items: [item("Servicio A", 3), item("Servicio B", 1)], + taxes: IssuedInvoiceTaxes.create({ taxes: [tax], languageCode, currencyCode }), + subtotalAmount: total(7500), + itemsDiscountAmount: total(1126), + globalDiscountPercentage: globalDiscount, + globalDiscountAmount: total(160), + totalDiscountAmount: total(1286), + taxableAmount: total(6214), + ivaAmount: total(1305), + recAmount: total(323), + retentionAmount: total(932), + taxesAmount: total(696), + totalAmount: total(5978), + fiscalClassification: Maybe.some(fiscal), + verifactu: withOptionals ? Maybe.some(verifactu) : Maybe.none(), + }, + required(UniqueID.create("01900000-0000-7000-8000-000000000006")) + ) + ); +}; + +test("toIssuedInvoiceAuditState proyecta una factura emitida histórica", () => { + const state = toIssuedInvoiceAuditState(createIssuedInvoiceFixture()); + assert.equal(state.status, "issued"); + assert.equal(state.invoiceReference, "F260173"); + assert.equal(state.invoiceSeriesCode, "F26"); + assert.equal(state.invoiceNumber, "173"); + assert.equal(state.invoiceDate, "2026-08-18"); + assert.equal(state.operationDate, "2026-08-06"); + assert.equal(state.linkedProformaId, "01900000-0000-7000-8000-000000000002"); + assert.equal(state.customerId, "01900000-0000-7000-8000-000000000004"); + assert.deepEqual(state.recipient, { + name: "Cliente histórico", + tin: "ESB1234567", + street: "Calle Uno 1", + street2: "Piso 2", + city: "Madrid", + postalCode: "28001", + province: "Madrid", + country: "ES", + }); + assert.deepEqual(state.paymentMethod, { name: "Transferencia" }); + assert.deepEqual(state.paymentTerm, { + name: "30 días", + description: "Pago estándar", + dueRules: [{ dueDays: "30", percentage: "10000:2" }], + }); + assert.equal(state.globalDiscountPercentage, "250:2"); + assert.equal(state.items.length, 2); + assert.equal(state.items[0]?.description, "Servicio A"); + assert.equal(state.items[1]?.description, "Servicio B"); + assert.equal(state.items[0]?.quantity, "3:2"); + assert.equal(state.items[0]?.unitAmount, "EUR:1250:4"); + assert.equal(state.items[0]?.itemDiscountPercentage, "1500:2"); + assert.equal(state.taxes[0]?.ivaPercentage, "2100:2"); + assert.equal(state.taxes[0]?.taxableAmount, "EUR:6214:2"); + assert.deepEqual(state.verifactu, { + status: "Pendiente", + url: null, + qrCode: "QR-001", + uuid: "vf-001", + operation: "alta", + }); + assert.equal(state.totals.totalAmount, "EUR:5978:2"); + assert.equal("companyId" in state, false); + assert.equal("id" in state.items[0]!, false); + assert.equal("id" in state.taxes[0]!, false); +}); +test("toIssuedInvoiceAuditState normaliza opcionales a null", () => { + const state = toIssuedInvoiceAuditState(createIssuedInvoiceFixture(false)); + assert.equal(state.documentSeriesId, null); + assert.equal(state.operationDate, null); + assert.equal(state.reference, null); + assert.equal(state.notes, null); + assert.equal(state.paymentTerm, null); + assert.equal(state.verifactu, null); + assert.equal(state.recipient.street2, null); + assert.equal(state.recipient.province, null); + assert.equal(state.recipient.country, null); +}); diff --git a/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.ts b/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.ts new file mode 100644 index 00000000..16801e46 --- /dev/null +++ b/modules/customer-invoices/src/api/application/issued-invoices/audit/issued-invoice-audit.ts @@ -0,0 +1,234 @@ +import type { AuditState } from "@repo/rdx-audit"; + +import type { IssuedInvoice } from "../../../domain"; + +type AuditRecipient = Readonly<{ + name: string; + tin: string; + street: string; + street2: string | null; + city: string; + postalCode: string; + province: string | null; + country: string | null; +}>; +type AuditItem = Readonly<{ + description: string | null; + quantity: string | null; + unitAmount: string | null; + itemDiscountPercentage: string | null; + subtotalAmount: string; + totalDiscountAmount: string; + taxableAmount: string; + ivaCode: string | null; + ivaPercentage: string | null; + ivaAmount: string; + recCode: string | null; + recPercentage: string | null; + recAmount: string; + retentionCode: string | null; + retentionPercentage: string | null; + retentionAmount: string; + taxesAmount: string; + totalAmount: string; +}>; +type AuditTax = Readonly<{ + taxableAmount: string; + ivaCode: string; + ivaPercentage: string; + ivaAmount: string; + recCode: string | null; + recPercentage: string | null; + recAmount: string; + retentionCode: string | null; + retentionPercentage: string | null; + retentionAmount: string; + taxesAmount: string; +}>; + +export type IssuedInvoiceAuditState = Readonly<{ + status: string; + documentSeriesId: string | null; + invoiceSeriesCode: string; + invoiceNumber: string; + invoiceReference: string; + invoiceDate: string; + operationDate: string | null; + linkedProformaId: string; + customerId: string; + recipient: AuditRecipient; + reference: string | null; + description: string; + notes: string | null; + languageCode: string; + currencyCode: string; + paymentMethod: Readonly<{ name: string }>; + paymentTerm: Readonly<{ + name: string; + description: string | null; + dueRules: readonly Readonly<{ dueDays: string; percentage: string }>[]; + }> | null; + taxRegime: Readonly<{ code: string; description: string }>; + fiscalClassification: Readonly<{ + taxRegimeCode: string; + taxRegimeLabel: string | null; + operationQualificationCode: string | null; + exemptionReasonCode: string | null; + }> | null; + globalDiscountPercentage: string; + items: readonly AuditItem[]; + taxes: readonly AuditTax[]; + totals: Readonly<{ + subtotalAmount: string; + itemsDiscountAmount: string; + globalDiscountAmount: string; + totalDiscountAmount: string; + taxableAmount: string; + ivaAmount: string; + recAmount: string; + retentionAmount: string; + taxesAmount: string; + totalAmount: string; + }>; + verifactu: Readonly<{ + status: string; + url: string | null; + qrCode: string | null; + uuid: string | null; + operation: string | null; + }> | null; +}>; + +const nullablePrimitive = (value: { + match: (some: (item: T) => R, none: () => R) => R; +}): string | null => + value.match( + (item) => item.toPrimitive(), + () => null + ); +const nullableString = (value: { + match: (some: (item: string) => R, none: () => R) => R; +}): string | null => + value.match( + (item) => item, + () => null + ); +const scaled = (value: { value: number; scale: number }): string => `${value.value}:${value.scale}`; +const nullableScaled = (value: { + match: (some: (item: { value: number; scale: number }) => R, none: () => R) => R; +}): string | null => value.match(scaled, () => null); +const money = (value: { value: number; scale: number; currencyCode: string }): string => + `${value.currencyCode}:${value.value}:${value.scale}`; +const nullableMoney = (value: { + match: ( + some: (item: { value: number; scale: number; currencyCode: string }) => R, + none: () => R + ) => R; +}): string | null => value.match(money, () => null); + +/** Proyección histórica pura de la factura emitida, sin identidades técnicas ni consultas externas. */ +export const toIssuedInvoiceAuditState = (invoice: IssuedInvoice): IssuedInvoiceAuditState => + ({ + status: invoice.status.toPrimitive(), + documentSeriesId: nullableString(invoice.documentSeriesId), + invoiceSeriesCode: invoice.series.toPrimitive(), + invoiceNumber: invoice.invoiceNumber.toPrimitive(), + invoiceReference: invoice.invoiceReference, + invoiceDate: invoice.invoiceDate.toPrimitive(), + operationDate: nullablePrimitive(invoice.operationDate), + linkedProformaId: invoice.linkedProformaId.toPrimitive(), + customerId: invoice.customerId.toPrimitive(), + recipient: { + name: invoice.recipient.name.toPrimitive(), + tin: invoice.recipient.tin.toPrimitive(), + street: invoice.recipient.street.toPrimitive(), + street2: nullablePrimitive(invoice.recipient.street2), + city: invoice.recipient.city.toPrimitive(), + postalCode: invoice.recipient.postalCode.toPrimitive(), + province: nullablePrimitive(invoice.recipient.province), + country: nullablePrimitive(invoice.recipient.country), + }, + reference: nullableString(invoice.reference), + description: invoice.description, + notes: nullablePrimitive(invoice.notes), + languageCode: invoice.languageCode.toPrimitive(), + currencyCode: invoice.currencyCode.toPrimitive(), + paymentMethod: { name: invoice.paymentMethod.name }, + paymentTerm: invoice.paymentTerm.match( + (term) => ({ + name: term.name, + description: nullableString(term.description), + dueRules: term.dueRules.map((rule) => ({ + dueDays: rule.dueDays, + percentage: `${rule.percentage.value}:${rule.percentage.scale}`, + })), + }), + () => null + ), + taxRegime: { code: invoice.taxRegime.code, description: invoice.taxRegime.description }, + fiscalClassification: invoice.fiscalClassification.match( + (value) => ({ + taxRegimeCode: value.taxRegimeCode, + taxRegimeLabel: nullableString(value.taxRegimeLabel), + operationQualificationCode: nullableString(value.operationQualificationCode), + exemptionReasonCode: nullableString(value.exemptionReasonCode), + }), + () => null + ), + globalDiscountPercentage: scaled(invoice.globalDiscountPercentage), + items: invoice.items.getAll().map((item) => ({ + description: nullablePrimitive(item.description), + quantity: nullableScaled(item.quantity), + unitAmount: nullableMoney(item.unitAmount), + itemDiscountPercentage: nullableScaled(item.itemDiscountPercentage), + subtotalAmount: money(item.subtotalAmount), + totalDiscountAmount: money(item.totalDiscountAmount), + taxableAmount: money(item.taxableAmount), + ivaCode: nullableString(item.ivaCode), + ivaPercentage: nullableScaled(item.ivaPercentage), + ivaAmount: money(item.ivaAmount), + recCode: nullableString(item.recCode), + recPercentage: nullableScaled(item.recPercentage), + recAmount: money(item.recAmount), + retentionCode: nullableString(item.retentionCode), + retentionPercentage: nullableScaled(item.retentionPercentage), + retentionAmount: money(item.retentionAmount), + taxesAmount: money(item.taxesAmount), + totalAmount: money(item.totalAmount), + })), + taxes: invoice.taxes.getAll().map((tax) => ({ + taxableAmount: money(tax.taxableAmount), + ivaCode: tax.ivaCode, + ivaPercentage: scaled(tax.ivaPercentage), + ivaAmount: money(tax.ivaAmount), + recCode: nullableString(tax.recCode), + recPercentage: nullableScaled(tax.recPercentage), + recAmount: money(tax.recAmount), + retentionCode: nullableString(tax.retentionCode), + retentionPercentage: nullableScaled(tax.retentionPercentage), + retentionAmount: money(tax.retentionAmount), + taxesAmount: money(tax.taxesAmount), + })), + totals: { + subtotalAmount: money(invoice.subtotalAmount), + itemsDiscountAmount: money(invoice.itemsDiscountAmount), + globalDiscountAmount: money(invoice.globalDiscountAmount), + totalDiscountAmount: money(invoice.totalDiscountAmount), + taxableAmount: money(invoice.taxableAmount), + ivaAmount: money(invoice.ivaAmount), + recAmount: money(invoice.recAmount), + retentionAmount: money(invoice.retentionAmount), + taxesAmount: money(invoice.taxesAmount), + totalAmount: money(invoice.totalAmount), + }, + verifactu: invoice.verifactu.match( + (record) => ({ + status: record.estado.toPrimitive(), + url: nullablePrimitive(record.url), + qrCode: nullableString(record.qrCode), + uuid: nullableString(record.uuid), + operation: nullableString(record.operacion), + }), + () => null + ), + }) satisfies AuditState; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-public-services.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-public-services.interface.ts index df12af55..ca3ead65 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-public-services.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-public-services.interface.ts @@ -16,4 +16,8 @@ export interface IIssuedInvoicePublicServices { props: IIssuedInvoiceCreatorParams["props"], context: IIssuedInvoiceServicesContext ) => Promise>; + getIssuedInvoiceById: ( + id: UniqueID, + context: IIssuedInvoiceServicesContext + ) => Promise>; } diff --git a/modules/customer-invoices/src/api/application/proformas/audit/proforma-audit.test.ts b/modules/customer-invoices/src/api/application/proformas/audit/proforma-audit.test.ts new file mode 100644 index 00000000..13eebdfd --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/audit/proforma-audit.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DiscountPercentage } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { ItemAmount, ItemDescription, ItemQuantity, ProformaItemTaxes } from "../../../domain"; +import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; + +import { toProformaAuditState } from "./proforma-audit"; + +const unwrap = (result: { data: T; isSuccess: boolean }): T => { + assert.equal(result.isSuccess, true); + return result.data; +}; + +test("serializa el estado Audit de una Proforma real con sus items en orden", () => { + const paymentTermId = unwrap(UniqueID.create("01900000-0000-7000-8000-000000000005")); + const firstItem = { + description: Maybe.some(unwrap(ItemDescription.create("Línea A"))), + quantity: Maybe.some(unwrap(ItemQuantity.create({ value: 3 }))), + unitAmount: Maybe.some(unwrap(ItemAmount.create({ value: 1250, currency_code: "EUR" }))), + itemDiscountPercentage: Maybe.some(unwrap(DiscountPercentage.create({ value: 15 }))), + taxes: ProformaItemTaxes.empty().getProps(), + }; + const secondItem = { + description: Maybe.some(unwrap(ItemDescription.create("Línea B"))), + quantity: Maybe.some(unwrap(ItemQuantity.create({ value: 2 }))), + unitAmount: Maybe.some(unwrap(ItemAmount.create({ value: 500, currency_code: "EUR" }))), + itemDiscountPercentage: Maybe.none(), + taxes: ProformaItemTaxes.empty().getProps(), + }; + const proforma = createProformaTestFixture({ + description: "Descripción Audit", + paymentTermId: Maybe.some(paymentTermId), + items: [firstItem, secondItem], + }); + + const state = toProformaAuditState(proforma); + + assert.equal(state.status, "draft"); + assert.equal(state.proformaReference, "PF-0042"); + assert.equal(state.customerId, proforma.customerId.toPrimitive()); + assert.equal(state.description, "Descripción Audit"); + assert.equal(state.languageCode, "es"); + assert.equal(state.currencyCode, "EUR"); + assert.equal(state.paymentTermId, paymentTermId.toPrimitive()); + assert.equal(state.paymentMethodId, null); + + assert.deepEqual(state.recipient, { + name: "Cliente de prueba", + tin: "ESB1234567", + street: "Calle Uno 1", + street2: "Piso 2", + city: "Madrid", + postalCode: "28001", + province: "Madrid", + country: "ES", + }); + assert.deepEqual(state.taxConfig, { + taxMode: "single", + defaultIvaCode: "iva_21", + usesEquivalenceSurcharge: false, + defaultRecCode: null, + usesRetention: false, + defaultRetentionCode: null, + }); + + assert.equal(state.items.length, 2); + assert.equal(state.items[0]?.description, "Línea A"); + assert.equal(state.items[1]?.description, "Línea B"); + assert.equal(state.items[0]?.quantity, "3:2"); + assert.equal(state.items[0]?.unitAmount, "EUR:1250:4"); + assert.equal(state.items[0]?.itemDiscountPercentage, "15:2"); + + assert.equal("companyId" in state, false); + assert.equal("id" in state, false); + assert.equal("domainEvents" in state, false); + assert.equal("totals" in state, false); + assert.equal("id" in (state.items[0] ?? {}), false); +}); + +test("serializa ausencias opcionales como null", () => { + const state = toProformaAuditState(createProformaTestFixture()); + + assert.equal(state.paymentTermId, null); + assert.equal(state.paymentMethodId, null); + assert.equal(state.operationDate, null); + assert.equal(state.linkedInvoiceId, null); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/audit/proforma-audit.ts b/modules/customer-invoices/src/api/application/proformas/audit/proforma-audit.ts new file mode 100644 index 00000000..e1511c78 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/audit/proforma-audit.ts @@ -0,0 +1,161 @@ +import type { AuditMutationTarget, AuditState } from "@repo/rdx-audit"; + +import type { Proforma } from "../../../domain"; + +export const PROFORMA_AUDIT_ACTIONS = { + CREATED: "proforma.created", + UPDATED: "proforma.updated", + STATUS_CHANGED: "proforma.status_changed", + ARCHIVED: "proforma.archived", + UNARCHIVED: "proforma.unarchived", + DELETED: "proforma.deleted", +} as const; + +type ProformaAuditState = Readonly<{ + status: string; + archivedAt: string | null; + documentSeriesId: string | null; + proformaSeriesCode: string | null; + proformaNumber: string | null; + proformaReference: string; + targetInvoiceSeriesCode: string | null; + linkedInvoiceId: string | null; + proformaDate: string; + operationDate: string | null; + customerId: string; + recipient: Readonly<{ + name: string | null; + tin: string | null; + street: string | null; + street2: string | null; + city: string | null; + postalCode: string | null; + province: string | null; + country: string | null; + }> | null; + reference: string | null; + description: string | null; + notes: string | null; + languageCode: string; + currencyCode: string; + paymentTermId: string | null; + paymentMethodId: string | null; + taxRegimeCode: string | null; + taxConfig: Readonly<{ + taxMode: string; + defaultIvaCode: string | null; + usesEquivalenceSurcharge: boolean; + defaultRecCode: string | null; + usesRetention: boolean; + defaultRetentionCode: string | null; + }>; + globalDiscountPercentage: string; + items: readonly Readonly<{ + description: string | null; + quantity: string | null; + unitAmount: string | null; + itemDiscountPercentage: string | null; + ivaCode: string | null; + recCode: string | null; + retentionCode: string | null; + }>[]; +}>; + +const toNullablePrimitive = (value: { + match: (some: (item: T) => R, none: () => R) => R; +}): string | null => + value.match( + (item) => item.toPrimitive(), + () => null + ); + +const toNullableString = (value: { + match: (some: (item: string) => R, none: () => R) => R; +}): string | null => + value.match( + (item) => item, + () => null + ); + +const toNullableScaledString = (value: { + match: (some: (item: { value: number; scale: number }) => R, none: () => R) => R; +}): string | null => + value.match( + (item) => `${item.value}:${item.scale}`, + () => null + ); + +const toNullableMoneyString = (value: { + match: ( + some: (item: { value: number; scale: number; currencyCode: string }) => R, + none: () => R + ) => R; +}): string | null => + value.match( + (item) => `${item.currencyCode}:${item.value}:${item.scale}`, + () => null + ); + +/** Proyección histórica explícita: excluye IDs técnicos de líneas y totales derivados. */ +export const toProformaAuditState = (proforma: Proforma): ProformaAuditState => { + const recipient = proforma.recipient.match( + (value) => ({ + name: value.name.toPrimitive(), + tin: toNullablePrimitive(value.tin), + street: toNullablePrimitive(value.street), + street2: toNullablePrimitive(value.street2), + city: toNullablePrimitive(value.city), + postalCode: toNullablePrimitive(value.postalCode), + province: toNullablePrimitive(value.province), + country: toNullablePrimitive(value.country), + }), + () => null + ); + + return { + status: proforma.status.toPrimitive(), + archivedAt: toNullablePrimitive(proforma.archivedAt), + documentSeriesId: toNullableString(proforma.documentSeriesId), + proformaSeriesCode: toNullablePrimitive(proforma.proformaSeriesCode), + proformaNumber: toNullablePrimitive(proforma.proformaNumber), + proformaReference: proforma.proformaReference.toPrimitive(), + targetInvoiceSeriesCode: toNullablePrimitive(proforma.targetInvoiceSeriesCode), + linkedInvoiceId: toNullablePrimitive(proforma.linkedInvoiceId), + proformaDate: proforma.proformaDate.toPrimitive(), + operationDate: toNullablePrimitive(proforma.operationDate), + customerId: proforma.customerId.toPrimitive(), + recipient, + reference: toNullableString(proforma.reference), + description: toNullableString(proforma.description), + notes: toNullablePrimitive(proforma.notes), + languageCode: proforma.languageCode.toPrimitive(), + currencyCode: proforma.currencyCode.toPrimitive(), + paymentTermId: toNullablePrimitive(proforma.paymentTermId), + paymentMethodId: toNullablePrimitive(proforma.paymentMethodId), + taxRegimeCode: toNullableString(proforma.taxRegimeCode), + taxConfig: { + taxMode: proforma.taxConfig.taxMode, + defaultIvaCode: toNullableString(proforma.taxConfig.defaultIvaCode), + usesEquivalenceSurcharge: proforma.taxConfig.usesEquivalenceSurcharge, + defaultRecCode: toNullableString(proforma.taxConfig.defaultRecCode), + usesRetention: proforma.taxConfig.usesRetention, + defaultRetentionCode: toNullableString(proforma.taxConfig.defaultRetentionCode), + }, + globalDiscountPercentage: `${proforma.globalDiscountPercentage.value}:${proforma.globalDiscountPercentage.scale}`, + items: proforma.items.getAll().map((item) => ({ + description: toNullablePrimitive(item.description), + quantity: toNullableScaledString(item.quantity), + unitAmount: toNullableMoneyString(item.unitAmount), + itemDiscountPercentage: toNullableScaledString(item.itemDiscountPercentage), + ivaCode: toNullableString(item.ivaCode()), + recCode: toNullableString(item.recCode()), + retentionCode: toNullableString(item.retentionCode()), + })), + } satisfies AuditState; +}; + +export const toProformaAuditTarget = (proforma: Proforma): AuditMutationTarget => ({ + type: "proforma", + id: proforma.id.toPrimitive(), + label: proforma.proformaReference.toPrimitive(), +}); diff --git a/modules/customer-invoices/src/api/application/proformas/di/index.ts b/modules/customer-invoices/src/api/application/proformas/di/index.ts index 907c61ff..628919dd 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/index.ts @@ -2,6 +2,7 @@ export * from "./proforma-archiver.di"; export * from "./proforma-catalog-resolvers.di"; export * from "./proforma-creator.di"; export * from "./proforma-deleter.di"; +export * from "./proforma-duplicator.di"; export * from "./proforma-finder.di"; export * from "./proforma-input-mappers.di"; export * from "./proforma-issuer.di"; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-archiver.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-archiver.di.ts index c7b856ea..f0969407 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-archiver.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-archiver.di.ts @@ -1,10 +1,14 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IProformaRepository } from "../repositories"; import { type IProformaArchiver, ProformaArchiver } from "../services"; export function buildProformaArchiver(deps: { repository: IProformaRepository; + audit: IAudit; }): IProformaArchiver { return new ProformaArchiver({ repository: deps.repository, + audit: deps.audit, }); } diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts index af087dbb..afaaa634 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts @@ -1,5 +1,6 @@ import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ICustomerPublicServices } from "@erp/customers/api"; +import type { IAudit } from "@repo/rdx-audit"; import type { IProformaRepository } from "../repositories"; import type { ProformaPaymentResolver } from "../services"; @@ -12,7 +13,7 @@ import { type ProformaTaxResolver, } from "../services"; -export const buildProformaCreator = (params: { +export const buildProformaCreator = (deps: { taxResolver: ProformaTaxResolver; paymentResolver: ProformaPaymentResolver; numberService: IProformaNumberGenerator; @@ -21,6 +22,7 @@ export const buildProformaCreator = (params: { targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; companiesServices: ICompanyPublicServices; customerServices: ICustomerPublicServices; + audit: IAudit; }): IProformaCreator => { - return new ProformaCreator(params); + return new ProformaCreator(deps); }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts index 2c05717c..7111d67f 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts @@ -1,8 +1,14 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IProformaRepository } from "../repositories"; import { type IProformaDeleter, ProformaDeleter } from "../services"; -export function buildProformaDeleter(deps: { repository: IProformaRepository }): IProformaDeleter { +export function buildProformaDeleter(deps: { + repository: IProformaRepository; + audit: IAudit; +}): IProformaDeleter { return new ProformaDeleter({ repository: deps.repository, + audit: deps.audit, }); } diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-duplicator.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-duplicator.di.ts new file mode 100644 index 00000000..cec868a6 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-duplicator.di.ts @@ -0,0 +1,30 @@ +import type { IPaymentTermPublicFinder } from "@erp/catalogs/api"; +import type { ICompanyPublicServices } from "@erp/companies/api"; +import type { ICustomerPublicServices } from "@erp/customers/api"; +import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; +import type { IAudit } from "@repo/rdx-audit"; + +import type { IProformaRepository } from "../repositories"; +import type { + IProformaDuplicator, + IProformaNumberGenerator, + ProformaPaymentResolver, + ProformaSeriesValidator, + ProformaTargetInvoiceSeriesValidator, + ProformaTaxResolver, +} from "../services"; +import { ProformaDuplicator } from "../services"; + +export const buildProformaDuplicator = (deps: { + repository: IProformaRepository; + numberService: IProformaNumberGenerator; + taxResolver: ProformaTaxResolver; + paymentResolver: ProformaPaymentResolver; + proformaSeriesValidator: ProformaSeriesValidator; + targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; + companiesServices: ICompanyPublicServices; + customerServices: ICustomerPublicServices; + documentSeriesServices: DocumentSeriesPublicServicesType; + paymentTermFinder: IPaymentTermPublicFinder; + audit: IAudit; +}): IProformaDuplicator => new ProformaDuplicator(deps); diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts index d53b5595..c580029e 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-status-changer.di.ts @@ -1,8 +1,11 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { IProformaRepository } from "../repositories"; import { type IProformaStatusChanger, ProformaStatusChanger } from "../services"; export const buildProformaStatusChanger = (params: { repository: IProformaRepository; + audit: IAudit; }): IProformaStatusChanger => { return new ProformaStatusChanger(params); }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts index c10f242e..e877cb9a 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts @@ -1,5 +1,6 @@ // modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts import type { ICustomerPublicServices } from "@erp/customers/api"; +import type { IAudit } from "@repo/rdx-audit"; import type { IProformaRepository } from "../repositories"; import type { ProformaPaymentResolver } from "../services"; @@ -16,6 +17,7 @@ export const buildProformaUpdater = (params: { paymentResolver: ProformaPaymentResolver; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; customerServices: ICustomerPublicServices; + audit: IAudit; }): IProformaUpdater => { return new ProformaUpdater(params); }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts index c8b18e61..e35e82ad 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts @@ -1,7 +1,5 @@ -import type { IPaymentTermPublicFinder } from "@erp/catalogs/api"; -import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ITransactionManager } from "@erp/core/api"; -import type { ICustomerPublicServices } from "@erp/customers/api"; +import type { IAudit } from "@repo/rdx-audit"; import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers"; @@ -11,6 +9,7 @@ import type { IProformaArchiver, IProformaCreator, IProformaDeleter, + IProformaDuplicator, IProformaFinder, IProformaFullReadModelAssembler, IProformaIssuanceReadinessChecker, @@ -142,25 +141,17 @@ export function buildCreateProformaUseCase(deps: { } export function buildDuplicateProformaByIdUseCase(deps: { - creator: IProformaCreator; + duplicator: IProformaDuplicator; finder: IProformaFinder; fullReadModelAssembler: IProformaFullReadModelAssembler; fullSnapshotBuilder: IProformaFullSnapshotBuilder; - documentSeriesServices: import("@erp/document-series/api").DocumentSeriesPublicServicesType; - companiesServices: ICompanyPublicServices; - customerServices: ICustomerPublicServices; - paymentTermFinder: IPaymentTermPublicFinder; transactionManager: ITransactionManager; }) { return new DuplicateProformaByIdUseCase({ - creator: deps.creator, + duplicator: deps.duplicator, finder: deps.finder, fullReadModelAssembler: deps.fullReadModelAssembler, fullSnapshotBuilder: deps.fullSnapshotBuilder, - documentSeriesServices: deps.documentSeriesServices, - companiesServices: deps.companiesServices, - customerServices: deps.customerServices, - paymentTermFinder: deps.paymentTermFinder, transactionManager: deps.transactionManager, }); } @@ -175,6 +166,7 @@ export function buildIssueProformaUseCase(deps: { issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder; repository: IProformaRepository; transactionManager: ITransactionManager; + audit: IAudit; }) { const { finder, @@ -183,6 +175,7 @@ export function buildIssueProformaUseCase(deps: { issueResponseSnapshotBuilder, repository, transactionManager, + audit, publicServices: { issuedInvoiceServices }, } = deps; @@ -194,6 +187,7 @@ export function buildIssueProformaUseCase(deps: { issueResponseSnapshotBuilder, repository, transactionManager, + audit, }); } diff --git a/modules/customer-invoices/src/api/application/proformas/services/index.ts b/modules/customer-invoices/src/api/application/proformas/services/index.ts index 32e33895..82800bb0 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/index.ts @@ -1,14 +1,15 @@ -export * from "./proforma-archiver"; export * from "./assemblers"; export * from "./catalog-resolver"; export * from "./company-report-profile-finder"; +export * from "./proforma-archiver"; export * from "./proforma-creator"; +export * from "./proforma-deleter"; export * from "./proforma-document-generator.interface"; export * from "./proforma-document-properties-factory"; -export * from "./proforma-deleter"; +export * from "./proforma-duplicator"; export * from "./proforma-finder"; -export * from "./proforma-issuer"; export * from "./proforma-issuance-readiness-checker"; +export * from "./proforma-issuer"; export * from "./proforma-number-generator.interface"; export * from "./proforma-public-services.interface"; export * from "./proforma-series-validator.interface"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.test.ts new file mode 100644 index 00000000..4b205afe --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { + IAudit, + RecordAuditEventShortcutInput, + RecordAuditInput, + RecordAuditMutationShortcutInput, +} from "@repo/rdx-audit"; +import { UtcDate } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { InvoiceStatus } from "../../../domain"; +import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; + +import { ProformaArchiver } from "./proforma-archiver"; + +class FakeAudit implements IAudit { + public readonly calls: RecordAuditMutationShortcutInput[] = []; + public async record(_input: RecordAuditInput) { + return Result.ok(); + } + public async recordEvent(_input: RecordAuditEventShortcutInput) { + return Result.ok(); + } + public async recordMutation(input: RecordAuditMutationShortcutInput) { + this.calls.push(input); + return Result.ok(); + } +} + +const required = (result: { data: T }): T => result.data; + +test("archive persiste y registra proforma.archived", async () => { + const proforma = createProformaTestFixture(); + const archived = createProformaTestFixture({ + archivedAt: Maybe.some(required(UtcDate.createFromISO("2026-08-26"))), + }); + const audit = new FakeAudit(); + let persistenceCalls = 0; + const archiver = new ProformaArchiver({ + repository: { + getByIdIncludingArchivedInCompany: async () => + Result.ok(persistenceCalls === 0 ? proforma : archived), + archiveByIdInCompany: async () => { + persistenceCalls += 1; + return Result.ok(true); + }, + } as never, + audit, + }); + + const result = await archiver.archive({ + companyId: proforma.companyId, + id: proforma.id, + transaction: {}, + }); + + assert.equal(result.isSuccess, true); + assert.equal(persistenceCalls, 1); + assert.equal(audit.calls.length, 1); + assert.equal(audit.calls[0].action, "proforma.archived"); + assert.equal(audit.calls[0].target.id, proforma.id.toPrimitive()); + assert.equal(audit.calls[0].before.archivedAt, null); + assert.notEqual(audit.calls[0].after.archivedAt, null); +}); + +test("archive rechaza una Proforma sent sin persistir ni auditar", async () => { + const proforma = createProformaTestFixture({ status: required(InvoiceStatus.create("sent")) }); + const audit = new FakeAudit(); + let persistenceCalls = 0; + const archiver = new ProformaArchiver({ + repository: { + getByIdIncludingArchivedInCompany: async () => Result.ok(proforma), + archiveByIdInCompany: async () => { + persistenceCalls += 1; + return Result.ok(true); + }, + } as never, + audit, + }); + + const result = await archiver.archive({ + companyId: proforma.companyId, + id: proforma.id, + transaction: {}, + }); + + assert.equal(result.isFailure, true); + assert.equal(persistenceCalls, 0); + assert.equal(audit.calls.length, 0); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.ts index 778f730a..8ef5c430 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.ts @@ -1,14 +1,21 @@ +import type { IAudit } from "@repo/rdx-audit"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; +import { ProformaCannotBeArchivedError, ProformaCannotBeUnarchivedError } from "../../../domain"; import { - ProformaCannotBeArchivedError, - ProformaCannotBeUnarchivedError, -} from "../../../domain"; + PROFORMA_AUDIT_ACTIONS, + toProformaAuditState, + toProformaAuditTarget, +} from "../audit/proforma-audit"; import type { IProformaRepository } from "../repositories"; export interface IProformaArchiver { - archive(params: { companyId: UniqueID; id: UniqueID; transaction: unknown }): Promise>; + archive(params: { + companyId: UniqueID; + id: UniqueID; + transaction: unknown; + }): Promise>; unarchive(params: { companyId: UniqueID; id: UniqueID; @@ -17,7 +24,7 @@ export interface IProformaArchiver { } export class ProformaArchiver implements IProformaArchiver { - public constructor(private readonly deps: { repository: IProformaRepository }) {} + public constructor(private readonly deps: { repository: IProformaRepository; audit: IAudit }) {} public async archive(params: { companyId: UniqueID; @@ -35,6 +42,7 @@ export class ProformaArchiver implements IProformaArchiver { } const proforma = proformaResult.data; + const before = toProformaAuditState(proforma); const canArchiveResult = proforma.ensureCanBeArchived(); if (canArchiveResult.isFailure) { @@ -57,6 +65,27 @@ export class ProformaArchiver implements IProformaArchiver { ); } + const persistedResult = await this.deps.repository.getByIdIncludingArchivedInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (persistedResult.isFailure) { + return Result.fail(persistedResult.error); + } + + const auditResult = await this.deps.audit.recordMutation({ + action: PROFORMA_AUDIT_ACTIONS.ARCHIVED, + target: toProformaAuditTarget(persistedResult.data), + before, + after: toProformaAuditState(persistedResult.data), + }); + + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(); } @@ -76,6 +105,7 @@ export class ProformaArchiver implements IProformaArchiver { } const proforma = proformaResult.data; + const before = toProformaAuditState(proforma); const canUnarchiveResult = proforma.ensureCanBeUnarchived(); if (canUnarchiveResult.isFailure) { @@ -94,13 +124,31 @@ export class ProformaArchiver implements IProformaArchiver { if (!persistenceResult.data) { return Result.fail( - new ProformaCannotBeUnarchivedError( - params.id.toString(), - "Proforma is not archived." - ) + new ProformaCannotBeUnarchivedError(params.id.toString(), "Proforma is not archived.") ); } + const persistedResult = await this.deps.repository.getByIdIncludingArchivedInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (persistedResult.isFailure) { + return Result.fail(persistedResult.error); + } + + const auditResult = await this.deps.audit.recordMutation({ + action: PROFORMA_AUDIT_ACTIONS.UNARCHIVED, + target: toProformaAuditTarget(persistedResult.data), + before, + after: toProformaAuditState(persistedResult.data), + }); + + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(); } } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.test.ts index f52e756a..2c4d907a 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.test.ts @@ -2,25 +2,57 @@ import assert from "node:assert/strict"; import test from "node:test"; import { DiscountPercentage } from "@erp/core/api"; +import type { + IAudit, + RecordAuditEventShortcutInput, + RecordAuditInput, + RecordAuditMutationShortcutInput, +} from "@repo/rdx-audit"; import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; +import { InvoiceStatus, type Proforma } from "../../../domain"; +import type { ProformaCreateInputProps } from "../models"; + import { ProformaCreator } from "./proforma-creator"; +class FakeAudit implements IAudit { + public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = []; + private failure: Error | null = null; + + public failWith(error: Error): void { + this.failure = error; + } + + async record(_input: RecordAuditInput) { + return Result.ok(undefined); + } + async recordEvent(_input: RecordAuditEventShortcutInput) { + return Result.ok(undefined); + } + async recordMutation(input: RecordAuditMutationShortcutInput) { + this.recordMutationCalls.push(input); + if (this.failure) return Result.fail(this.failure); + return Result.ok(undefined); + } +} + const buildCreateProps = () => { const proformaDateResult = UtcDate.createFromISO("2026-08-16"); const languageCodeResult = LanguageCode.create("es"); const currencyCodeResult = CurrencyCode.create("EUR"); const globalDiscountPercentageResult = DiscountPercentage.create({ value: 0 }); + const statusResult = InvoiceStatus.create("draft"); assert.equal(proformaDateResult.isSuccess, true); assert.equal(languageCodeResult.isSuccess, true); assert.equal(currencyCodeResult.isSuccess, true); assert.equal(globalDiscountPercentageResult.isSuccess, true); + assert.equal(statusResult.isSuccess, true); return { companyId: UniqueID.generateNewID(), - status: {} as never, + status: statusResult.data, proformaSeriesCode: Maybe.none(), targetInvoiceSeriesCode: Maybe.none(), proformaDate: proformaDateResult.data, @@ -45,7 +77,7 @@ const buildCreateProps = () => { }, globalDiscountPercentage: globalDiscountPercentageResult.data, items: [], - }; + } satisfies ProformaCreateInputProps; }; test("create hereda payment term del customer cuando no llega explicito", async () => { @@ -53,16 +85,23 @@ test("create hereda payment term del customer cuando no llega explicito", async const props = buildCreateProps(); const proformaId = UniqueID.generateNewID(); let capturedPaymentTermId = Maybe.none(); + let persistedProforma: Proforma | null = null; let companyLookups = 0; + const audit = new FakeAudit(); const creator = new ProformaCreator({ repository: { - create: async (proforma) => { + create: async (proforma: Proforma) => { + persistedProforma = proforma; capturedPaymentTermId = proforma.paymentTermId; return Result.ok(undefined); }, - getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never), + getByIdInCompany: async () => { + if (!persistedProforma) throw new Error("Proforma was not persisted before reload"); + return Result.ok(persistedProforma); + }, } as never, + audit, numberService: { getNextForCompany: async () => Result.ok({ @@ -117,6 +156,15 @@ test("create hereda payment term del customer cuando no llega explicito", async assert.equal(capturedPaymentTermId.isSome(), true); assert.equal(capturedPaymentTermId.unwrap().equals(customerPaymentTermId), true); assert.equal(companyLookups, 0); + assert.equal(audit.recordMutationCalls.length, 1); + const [auditCall] = audit.recordMutationCalls; + assert.equal(auditCall.action, "proforma.created"); + assert.equal(auditCall.target.type, "proforma"); + assert.equal(auditCall.target.id, proformaId.toPrimitive()); + assert.equal(auditCall.target.label, "1"); + assert.deepEqual(auditCall.before, {}); + assert.equal(auditCall.after.status, "draft"); + assert.equal(auditCall.after.proformaReference, "1"); }); test("create usa fallback a company default cuando customer no tiene payment term", async () => { @@ -124,15 +172,21 @@ test("create usa fallback a company default cuando customer no tiene payment ter const props = buildCreateProps(); const proformaId = UniqueID.generateNewID(); let capturedPaymentTermId = Maybe.none(); + let persistedProforma: Proforma | null = null; const creator = new ProformaCreator({ repository: { - create: async (proforma) => { + create: async (proforma: Proforma) => { + persistedProforma = proforma; capturedPaymentTermId = proforma.paymentTermId; return Result.ok(undefined); }, - getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never), + getByIdInCompany: async () => { + if (!persistedProforma) throw new Error("Proforma was not persisted before reload"); + return Result.ok(persistedProforma); + }, } as never, + audit: { recordMutation: async () => Result.ok(undefined) } as never, numberService: { getNextForCompany: async () => Result.ok({ @@ -206,15 +260,21 @@ test("create con payment_term_id null explicito conserva el clear", async () => }; const proformaId = UniqueID.generateNewID(); let capturedPaymentTermId = Maybe.some(UniqueID.generateNewID()); + let persistedProforma: Proforma | null = null; const creator = new ProformaCreator({ repository: { - create: async (proforma) => { + create: async (proforma: Proforma) => { + persistedProforma = proforma; capturedPaymentTermId = proforma.paymentTermId; return Result.ok(undefined); }, - getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never), + getByIdInCompany: async () => { + if (!persistedProforma) throw new Error("Proforma was not persisted before reload"); + return Result.ok(persistedProforma); + }, } as never, + audit: { recordMutation: async () => Result.ok(undefined) } as never, numberService: { getNextForCompany: async () => Result.ok({ @@ -265,3 +325,94 @@ test("create con payment_term_id null explicito conserva el clear", async () => assert.equal(result.isSuccess, true); assert.equal(capturedPaymentTermId.isNone(), true); }); + +test("create rechaza cuando no puede cargar el customer sin registrar Audit", async () => { + const audit = new FakeAudit(); + let createCalls = 0; + const creator = new ProformaCreator({ + repository: { + create: async () => { + createCalls += 1; + return Result.ok(undefined); + }, + } as never, + customerServices: { + getCustomerById: async () => Result.fail(new Error("Customer not found")), + } as never, + audit, + } as never); + const props = buildCreateProps(); + + const result = await creator.create({ + companyId: props.companyId, + id: UniqueID.generateNewID(), + props, + transaction: undefined, + }); + + assert.equal(result.isFailure, true); + assert.equal(createCalls, 0); + assert.equal(audit.recordMutationCalls.length, 0); +}); + +test("create propaga el fallo de Audit", async () => { + const audit = new FakeAudit(); + const auditError = new Error("Audit failed"); + const customerPaymentTermId = UniqueID.generateNewID(); + audit.failWith(auditError); + let persistedProforma: Proforma | null = null; + const creator = new ProformaCreator({ + repository: { + create: async (proforma: Proforma) => { + persistedProforma = proforma; + return Result.ok(undefined); + }, + getByIdInCompany: async () => { + if (!persistedProforma) throw new Error("Proforma was not persisted before reload"); + return Result.ok(persistedProforma); + }, + } as never, + audit, + customerServices: { + getCustomerById: async () => + Result.ok({ + paymentTermId: Maybe.some(customerPaymentTermId), + taxRegimeCode: Maybe.none(), + usesEquivalenceSurcharge: false, + usesRetention: false, + }), + } as never, + numberService: { + getNextForCompany: async () => + Result.ok({ + documentSeriesId: UniqueID.generateNewID().toPrimitive(), + documentSeriesCode: "PF", + number: "1", + reference: "1", + }), + }, + taxResolver: { + ensureTaxRegimeByCode: async () => Result.ok(undefined), + ensureItemTaxes: async () => Result.fail(new Error("unused")), + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + }, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => Result.ok(undefined), + }, + proformaSeriesValidator: { ensureValidProformaSeries: async () => Result.ok(undefined) }, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + }, + companiesServices: { finder: { findById: async () => Result.fail(new Error("unused")) } }, + } as never); + const props = buildCreateProps(); + const result = await creator.create({ + companyId: props.companyId, + id: UniqueID.generateNewID(), + props, + transaction: undefined, + }); + assert.equal(result.isFailure, true); + assert.equal(audit.recordMutationCalls.length, 1); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts index 522217ab..c0da5177 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts @@ -1,5 +1,6 @@ import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ICustomerPublicServices } from "@erp/customers/api"; +import type { IAudit } from "@repo/rdx-audit"; import { UniqueID, type UniqueID as UniqueIDType } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; @@ -11,6 +12,11 @@ import { Proforma, ProformaTaxConfig, } from "../../../domain"; +import { + PROFORMA_AUDIT_ACTIONS, + toProformaAuditState, + toProformaAuditTarget, +} from "../audit/proforma-audit"; import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models"; import type { IProformaRepository } from "../repositories"; @@ -42,6 +48,7 @@ export class ProformaCreator implements IProformaCreator { targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; companiesServices: ICompanyPublicServices; customerServices: ICustomerPublicServices; + audit: IAudit; } ) {} @@ -144,6 +151,17 @@ export class ProformaCreator implements IProformaCreator { return Result.fail(verifyResult.error); } + const auditResult = await this.deps.audit.recordMutation({ + action: PROFORMA_AUDIT_ACTIONS.CREATED, + target: toProformaAuditTarget(verifyResult.data), + before: {}, + after: toProformaAuditState(verifyResult.data), + }); + + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(verifyResult.data); } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.test.ts new file mode 100644 index 00000000..0c59877a --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { + IAudit, + RecordAuditEventShortcutInput, + RecordAuditInput, + RecordAuditMutationShortcutInput, +} from "@repo/rdx-audit"; +import { Result } from "@repo/rdx-utils"; + +import { InvoiceStatus } from "../../../domain"; +import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; + +import { ProformaDeleter } from "./proforma-deleter"; + +class FakeAudit implements IAudit { + public readonly calls: RecordAuditMutationShortcutInput[] = []; + public async record(_input: RecordAuditInput) { + return Result.ok(); + } + public async recordEvent(_input: RecordAuditEventShortcutInput) { + return Result.ok(); + } + public async recordMutation(input: RecordAuditMutationShortcutInput) { + this.calls.push(input); + return Result.ok(); + } +} + +test("delete elimina y registra proforma.deleted", async () => { + const proforma = createProformaTestFixture(); + const audit = new FakeAudit(); + let deleteCalls = 0; + const deleter = new ProformaDeleter({ + repository: { + getByIdIncludingArchivedInCompany: async () => Result.ok(proforma), + existsIssuedInvoiceBySourceProformaIdInCompany: async () => Result.ok(false), + deleteByIdInCompany: async () => { + deleteCalls += 1; + return Result.ok(true); + }, + } as never, + audit, + }); + const result = await deleter.delete({ + companyId: proforma.companyId, + id: proforma.id, + transaction: {}, + }); + assert.equal(result.isSuccess, true); + assert.equal(deleteCalls, 1); + assert.equal(audit.calls.length, 1); + assert.equal(audit.calls[0].action, "proforma.deleted"); + assert.equal(audit.calls[0].target.type, "proforma"); + assert.equal(audit.calls[0].target.id, proforma.id.toPrimitive()); + assert.equal(audit.calls[0].target.label, proforma.proformaReference.toPrimitive()); + assert.equal(audit.calls[0].before.status, "draft"); + assert.equal(audit.calls[0].before.proformaReference, proforma.proformaReference.toPrimitive()); + assert.equal(audit.calls[0].before.customerId, proforma.customerId.toPrimitive()); + assert.deepEqual(audit.calls[0].after, {}); +}); + +test("delete rechaza una Proforma sent sin eliminar ni auditar", async () => { + const proforma = createProformaTestFixture({ status: InvoiceStatus.create("sent").data }); + const audit = new FakeAudit(); + let deleteCalls = 0; + const deleter = new ProformaDeleter({ + repository: { + getByIdIncludingArchivedInCompany: async () => Result.ok(proforma), + deleteByIdInCompany: async () => { + deleteCalls += 1; + return Result.ok(true); + }, + } as never, + audit, + }); + const result = await deleter.delete({ + companyId: proforma.companyId, + id: proforma.id, + transaction: {}, + }); + assert.equal(result.isFailure, true); + assert.equal(deleteCalls, 0); + assert.equal(audit.calls.length, 0); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts index 590b58a1..f2e09295 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.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 { ProformaCannotBeDeletedError } from "../../../domain"; +import { + PROFORMA_AUDIT_ACTIONS, + toProformaAuditState, + toProformaAuditTarget, +} from "../audit/proforma-audit"; import type { IProformaRepository } from "../repositories"; export interface IProformaDeleter { @@ -16,6 +22,7 @@ export class ProformaDeleter implements IProformaDeleter { public constructor( private readonly deps: { repository: IProformaRepository; + audit: IAudit; } ) {} @@ -35,6 +42,7 @@ export class ProformaDeleter implements IProformaDeleter { } const proforma = proformaResult.data; + const before = toProformaAuditState(proforma); const canDeleteResult = proforma.ensureCanBeDeleted(); if (canDeleteResult.isFailure) { @@ -54,7 +62,10 @@ export class ProformaDeleter implements IProformaDeleter { if (hasIssuedInvoiceResult.data) { return Result.fail( - new ProformaCannotBeDeletedError(params.id.toString(), "Issued proformas cannot be deleted.") + new ProformaCannotBeDeletedError( + params.id.toString(), + "Issued proformas cannot be deleted." + ) ); } @@ -68,6 +79,17 @@ export class ProformaDeleter implements IProformaDeleter { return Result.fail(deleteResult.error); } + const auditResult = await this.deps.audit.recordMutation({ + action: PROFORMA_AUDIT_ACTIONS.DELETED, + target: toProformaAuditTarget(proforma), + before, + after: {}, + }); + + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(); } } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-duplicator.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-duplicator.test.ts new file mode 100644 index 00000000..cb8f72cd --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-duplicator.test.ts @@ -0,0 +1,408 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { + IAudit, + RecordAuditEventShortcutInput, + RecordAuditInput, + RecordAuditMutationShortcutInput, +} from "@repo/rdx-audit"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { ItemAmount, ItemDescription, ItemQuantity, ProformaItemTaxes } from "../../../domain"; +import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; + +import { ProformaDuplicator } from "./proforma-duplicator"; + +const required = (result: { data: T }): T => result.data; + +class FakeAudit implements IAudit { + public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = []; + private failure: Error | null = null; + + public failWith(error: Error): void { + this.failure = error; + } + + async record(_input: RecordAuditInput) { + return Result.ok(undefined); + } + + async recordEvent(_input: RecordAuditEventShortcutInput) { + return Result.ok(undefined); + } + + async recordMutation(input: RecordAuditMutationShortcutInput) { + this.recordMutationCalls.push(input); + return this.failure ? Result.fail(this.failure) : Result.ok(undefined); + } +} + +test("duplicate persiste y recarga una nueva Proforma", async () => { + const paymentTermId = required(UniqueID.create("01900000-0000-7000-8000-000000000005")); + const item = { + description: Maybe.some(required(ItemDescription.create("Línea original"))), + quantity: Maybe.some(required(ItemQuantity.create({ value: 1 }))), + unitAmount: Maybe.some(required(ItemAmount.create({ value: 1000, currency_code: "EUR" }))), + itemDiscountPercentage: Maybe.none(), + taxes: ProformaItemTaxes.empty().getProps(), + }; + const source = createProformaTestFixture({ + items: [item], + paymentTermId: Maybe.some(paymentTermId), + }); + const audit = new FakeAudit(); + let createCalls = 0; + let getByIdCalls = 0; + let persistedProforma: typeof source | null = null; + + const duplicator = new ProformaDuplicator({ + repository: { + create: async (proforma) => { + createCalls += 1; + persistedProforma = proforma; + return Result.ok(undefined); + }, + getByIdInCompany: async () => { + getByIdCalls += 1; + if (!persistedProforma) return Result.fail(new Error("must persist before reload")); + const recipientResult = persistedProforma.update({ recipient: source.recipient }); + if (recipientResult.isFailure) return Result.fail(recipientResult.error); + return Result.ok(persistedProforma); + }, + } as never, + audit, + numberService: { + getNextForCompany: async () => + Result.ok({ + documentSeriesId: "01900000-0000-7000-8000-000000000002", + documentSeriesCode: "PF", + number: "43", + reference: "PF-0043", + }), + }, + taxResolver: { + ensureTaxRegimeByCode: async () => Result.ok(undefined), + ensureItemTaxes: async () => Result.ok(ProformaItemTaxes.empty().getProps()), + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + } as never, + proformaSeriesValidator: { + ensureValidProformaSeries: async () => Result.ok(undefined), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + documentSeriesServices: { + listActiveSeries: async ({ documentType }: { documentType: string }) => + Result.ok( + documentType === "proforma" + ? [ + { + id: "01900000-0000-7000-8000-000000000002", + code: "PF", + is_default: true, + }, + ] + : [] + ), + } as never, + paymentTermFinder: { + findByIdInCompany: async () => Result.ok(Maybe.some({ isActive: true })), + } as never, + customerServices: { + getCustomerById: async () => + Result.ok({ + paymentTermId: Maybe.some(paymentTermId), + taxRegimeCode: Maybe.some("01"), + usesEquivalenceSurcharge: false, + usesRetention: false, + }), + } as never, + companiesServices: { + finder: { + findById: async () => Result.fail(new Error("company fallback must not run")), + }, + } as never, + }); + + const result = await duplicator.duplicate({ + companyId: source.companyId, + source, + }); + + assert.equal(result.isSuccess, true); + const duplicated = result.data.proforma; + assert.notEqual(duplicated.id.toPrimitive(), source.id.toPrimitive()); + assert.equal(duplicated.status.toPrimitive(), "draft"); + assert.equal(duplicated.recipient.isSome(), true); + assert.equal(duplicated.archivedAt.isNone(), true); + assert.equal(duplicated.linkedInvoiceId.isNone(), true); + assert.equal(duplicated.operationDate.isNone(), true); + assert.equal(duplicated.proformaReference.toPrimitive(), "PF-0043"); + assert.equal(duplicated.customerId.equals(source.customerId), true); + assert.equal(duplicated.paymentTermId.unwrap().equals(paymentTermId), true); + assert.equal(duplicated.taxRegimeCode.unwrap(), "01"); + assert.deepEqual(duplicated.taxConfig, source.taxConfig); + assert.equal(duplicated.items.getAll().length, 1); + assert.equal(duplicated.items.getAll()[0]?.description.unwrap().toPrimitive(), "Línea original"); + assert.equal(result.data.paymentTermAdaptation, null); + assert.equal(createCalls, 1); + assert.equal(getByIdCalls, 1); + assert.equal(duplicated.id.equals(persistedProforma?.id), true); + assert.equal(audit.recordMutationCalls.length, 1); + const [auditCall] = audit.recordMutationCalls; + assert.equal(auditCall.action, "proforma.duplicated"); + assert.equal(auditCall.target.type, "proforma"); + assert.equal(auditCall.target.id, duplicated.id.toPrimitive()); + assert.equal(auditCall.target.label, "PF-0043"); + assert.deepEqual(auditCall.before, {}); + assert.equal(auditCall.after.status, "draft"); + assert.equal(auditCall.after.customerId, source.customerId.toPrimitive()); + assert.equal(auditCall.after.proformaReference, "PF-0043"); +}); + +test("duplicate propaga el fallo de Audit después de persistence y reload", async () => { + const paymentTermId = required(UniqueID.create("01900000-0000-7000-8000-000000000005")); + const source = createProformaTestFixture({ + paymentTermId: Maybe.some(paymentTermId), + }); + const audit = new FakeAudit(); + const auditError = new Error("Audit failed"); + audit.failWith(auditError); + let createCalls = 0; + let getByIdCalls = 0; + let persistedProforma: typeof source | null = null; + + const duplicator = new ProformaDuplicator({ + repository: { + create: async (proforma) => { + createCalls += 1; + persistedProforma = proforma; + return Result.ok(undefined); + }, + getByIdInCompany: async () => { + getByIdCalls += 1; + if (!persistedProforma) return Result.fail(new Error("must persist before reload")); + return Result.ok(persistedProforma); + }, + } as never, + audit, + numberService: { + getNextForCompany: async () => + Result.ok({ + documentSeriesId: "01900000-0000-7000-8000-000000000002", + documentSeriesCode: "PF", + number: "43", + reference: "PF-0043", + }), + }, + taxResolver: { + ensureTaxRegimeByCode: async () => Result.ok(undefined), + ensureItemTaxes: async () => Result.ok(ProformaItemTaxes.empty().getProps()), + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + } as never, + proformaSeriesValidator: { + ensureValidProformaSeries: async () => Result.ok(undefined), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + documentSeriesServices: { + listActiveSeries: async ({ documentType }: { documentType: string }) => + Result.ok( + documentType === "proforma" + ? [ + { + id: "01900000-0000-7000-8000-000000000002", + code: "PF", + is_default: true, + }, + ] + : [] + ), + } as never, + paymentTermFinder: { + findByIdInCompany: async () => Result.ok(Maybe.some({ isActive: true })), + } as never, + customerServices: { + getCustomerById: async () => + Result.ok({ + paymentTermId: Maybe.some(paymentTermId), + taxRegimeCode: Maybe.some("01"), + usesEquivalenceSurcharge: false, + usesRetention: false, + }), + } as never, + companiesServices: { + finder: { + findById: async () => Result.fail(new Error("company fallback must not run")), + }, + } as never, + }); + + const result = await duplicator.duplicate({ + companyId: source.companyId, + source, + }); + + assert.equal(result.isFailure, true); + assert.equal(result.error, auditError); + assert.equal(createCalls, 1); + assert.equal(getByIdCalls, 1); + assert.equal(audit.recordMutationCalls.length, 1); + assert.equal(audit.recordMutationCalls[0]?.action, "proforma.duplicated"); +}); + +test("duplicate informa adaptación cuando el payment term original está inactivo y usa el del Customer", async () => { + const originalPaymentTermId = required(UniqueID.create("01900000-0000-7000-8000-000000000005")); + const customerPaymentTermId = required(UniqueID.create("01900000-0000-7000-8000-000000000006")); + const source = createProformaTestFixture({ + paymentTermId: Maybe.some(originalPaymentTermId), + }); + const audit = new FakeAudit(); + let persistedProforma: typeof source | null = null; + + const duplicator = new ProformaDuplicator({ + repository: { + create: async (proforma) => { + persistedProforma = proforma; + return Result.ok(undefined); + }, + getByIdInCompany: async () => + persistedProforma + ? Result.ok(persistedProforma) + : Result.fail(new Error("must persist before reload")), + } as never, + audit, + numberService: { + getNextForCompany: async () => + Result.ok({ + documentSeriesId: "01900000-0000-7000-8000-000000000002", + documentSeriesCode: "PF", + number: "43", + reference: "PF-0043", + }), + }, + taxResolver: { + ensureTaxRegimeByCode: async () => Result.ok(undefined), + ensureItemTaxes: async () => Result.ok(ProformaItemTaxes.empty().getProps()), + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + } as never, + proformaSeriesValidator: { + ensureValidProformaSeries: async () => Result.ok(undefined), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + documentSeriesServices: { + listActiveSeries: async ({ documentType }: { documentType: string }) => + Result.ok( + documentType === "proforma" + ? [ + { + id: "01900000-0000-7000-8000-000000000002", + code: "PF", + is_default: true, + }, + ] + : [] + ), + } as never, + paymentTermFinder: { + findByIdInCompany: async ({ id }: { id: UniqueID }) => + Result.ok(Maybe.some({ isActive: id.equals(customerPaymentTermId) })), + } as never, + customerServices: { + getCustomerById: async () => + Result.ok({ + paymentTermId: Maybe.some(customerPaymentTermId), + taxRegimeCode: Maybe.some("01"), + usesEquivalenceSurcharge: false, + usesRetention: false, + }), + } as never, + companiesServices: { + finder: { + findById: async () => Result.fail(new Error("company fallback must not run")), + }, + } as never, + }); + + const result = await duplicator.duplicate({ + companyId: source.companyId, + source, + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.proforma.paymentTermId.unwrap().equals(customerPaymentTermId), true); + assert.equal(result.data.proforma.paymentTermId.unwrap().equals(originalPaymentTermId), false); + assert.deepEqual(result.data.paymentTermAdaptation, { + source: "inactive_original", + resolution: "customer", + }); + assert.equal(audit.recordMutationCalls.length, 1); + assert.equal(audit.recordMutationCalls[0]?.action, "proforma.duplicated"); +}); + +test("duplicate no persiste ni audita si falla la resolución funcional", async () => { + const source = createProformaTestFixture(); + const audit = new FakeAudit(); + const functionalError = new Error("Functional failure"); + let createCalls = 0; + let getByIdCalls = 0; + + const duplicator = new ProformaDuplicator({ + repository: { + create: async () => { + createCalls += 1; + return Result.ok(undefined); + }, + getByIdInCompany: async () => { + getByIdCalls += 1; + return Result.fail(new Error("must not reload")); + }, + } as never, + audit, + numberService: {} as never, + taxResolver: {} as never, + paymentResolver: {} as never, + proformaSeriesValidator: {} as never, + targetInvoiceSeriesValidator: {} as never, + documentSeriesServices: { + listActiveSeries: async () => + Result.ok([ + { + id: "01900000-0000-7000-8000-000000000002", + code: "PF", + is_default: true, + }, + ]), + } as never, + paymentTermFinder: {} as never, + customerServices: { + getCustomerById: async () => Result.fail(functionalError), + } as never, + companiesServices: {} as never, + }); + + const result = await duplicator.duplicate({ + companyId: source.companyId, + source, + }); + + assert.equal(result.isFailure, true); + assert.equal(result.error, functionalError); + assert.equal(createCalls, 0); + assert.equal(getByIdCalls, 0); + assert.equal(audit.recordMutationCalls.length, 0); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-duplicator.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-duplicator.ts new file mode 100644 index 00000000..35c45660 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-duplicator.ts @@ -0,0 +1,410 @@ +import type { IPaymentTermPublicFinder } from "@erp/catalogs/api"; +import type { ICompanyPublicServices } from "@erp/companies/api"; +import type { ICustomerPublicServices } from "@erp/customers/api"; +import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; +import type { IAudit } from "@repo/rdx-audit"; +import { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { InvoiceNumber, InvoiceSerie, InvoiceStatus, Proforma } from "../../../domain"; +import { InvoiceSeriesNotFoundError } from "../../invoice-series"; +import { toProformaAuditState, toProformaAuditTarget } from "../audit/proforma-audit"; +import type { IProformaRepository } from "../repositories"; + +import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver"; +import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-resolver"; +import type { + IProformaNumberGenerator, + ProformaNumberAssignment, +} from "./proforma-number-generator.interface"; +import type { ProformaSeriesValidator } from "./proforma-series-validator.interface"; +import type { ProformaTargetInvoiceSeriesValidator } from "./proforma-target-invoice-series-validator.interface"; + +export interface IProformaDuplicatorParams { + companyId: UniqueID; + source: Proforma; + transaction?: unknown; +} + +export type PaymentTermAdaptation = { + source: "inactive_original"; + resolution: "customer" | "company" | "none"; +} | null; + +export interface IProformaDuplicatorResult { + proforma: Proforma; + paymentTermAdaptation: PaymentTermAdaptation; +} + +export interface IProformaDuplicator { + duplicate(params: IProformaDuplicatorParams): Promise>; +} + +type ResolvedDuplicatePaymentTerm = { + paymentTermId: Maybe; + paymentTermAdaptation: PaymentTermAdaptation; +}; + +/** + * Preparación explícita para separar la semántica de duplicación de la creación manual. + * La lógica funcional se incorporará cuando Duplicate migre a este servicio. + */ +export class ProformaDuplicator implements IProformaDuplicator { + public constructor( + private readonly deps: { + repository: IProformaRepository; + numberService: IProformaNumberGenerator; + taxResolver: ProformaTaxResolver; + paymentResolver: ProformaPaymentResolver; + proformaSeriesValidator: ProformaSeriesValidator; + targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; + companiesServices: ICompanyPublicServices; + customerServices: ICustomerPublicServices; + documentSeriesServices: DocumentSeriesPublicServicesType; + paymentTermFinder: IPaymentTermPublicFinder; + audit: IAudit; + } + ) {} + + public async duplicate( + params: IProformaDuplicatorParams + ): Promise> { + const dateResult = UtcDate.createFromISO(new Date().toISOString().slice(0, 10)); + if (dateResult.isFailure) return Result.fail(dateResult.error); + const proformaSeriesCodeResult = await this.resolveDuplicateSeriesCode({ + companyId: params.companyId, + sourceDocumentSeriesId: params.source.documentSeriesId, + transaction: params.transaction, + }); + if (proformaSeriesCodeResult.isFailure) return Result.fail(proformaSeriesCodeResult.error); + const targetInvoiceSeriesCodeResult = await this.resolveValidTargetInvoiceSeriesCode({ + companyId: params.companyId, + targetInvoiceSeriesCode: params.source.targetInvoiceSeriesCode, + transaction: params.transaction, + }); + if (targetInvoiceSeriesCodeResult.isFailure) + return Result.fail(targetInvoiceSeriesCodeResult.error); + const paymentTermIdResult = await this.resolveDuplicatePaymentTermId({ + companyId: params.companyId, + sourceCustomerId: params.source.customerId, + sourcePaymentTermId: params.source.paymentTermId, + transaction: params.transaction, + }); + if (paymentTermIdResult.isFailure) return Result.fail(paymentTermIdResult.error); + const resolvedPaymentTerm = paymentTermIdResult.data; + const customerResult = await this.deps.customerServices.getCustomerById( + params.source.customerId, + { + companyId: params.companyId, + transaction: params.transaction, + } + ); + if (customerResult.isFailure) return Result.fail(customerResult.error); + const taxRegimeCode = params.source.taxRegimeCode.isNone() + ? customerResult.data.taxRegimeCode.isSome() + ? customerResult.data.taxRegimeCode + : Maybe.some("01") + : params.source.taxRegimeCode; + const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({ + companyId: params.companyId, + code: taxRegimeCode, + }); + if (taxRegimeResult.isFailure) return Result.fail(taxRegimeResult.error); + const paymentMethodResult = await this.deps.paymentResolver.ensurePaymentMethodById({ + companyId: params.companyId, + id: params.source.paymentMethodId, + transaction: params.transaction, + }); + if (paymentMethodResult.isFailure) return Result.fail(paymentMethodResult.error); + const itemsResult = await this.resolveItems({ + companyId: params.companyId, + atDate: dateResult.data, + source: params.source, + }); + if (itemsResult.isFailure) return Result.fail(itemsResult.error); + const numberResult = await this.getNextNumber({ + companyId: params.companyId, + proformaSeriesCode: proformaSeriesCodeResult.data, + targetInvoiceSeriesCode: targetInvoiceSeriesCodeResult.data, + transaction: params.transaction, + }); + if (numberResult.isFailure) return Result.fail(numberResult.error); + const referenceResult = InvoiceNumber.create(numberResult.data.reference); + const numberValueResult = InvoiceNumber.create(numberResult.data.number); + const seriesResult = InvoiceSerie.create(numberResult.data.documentSeriesCode); + if (referenceResult.isFailure || numberValueResult.isFailure || seriesResult.isFailure) + return Result.fail(new Error("Invalid duplicate numbering.")); + const proformaResult = Proforma.create( + { + companyId: params.companyId, + status: InvoiceStatus.draft(), + archivedAt: Maybe.none(), + documentSeriesId: Maybe.some(numberResult.data.documentSeriesId), + proformaSeriesCode: Maybe.some(seriesResult.data), + proformaNumber: Maybe.some(numberValueResult.data), + proformaReference: referenceResult.data, + targetInvoiceSeriesCode: targetInvoiceSeriesCodeResult.data, + proformaDate: dateResult.data, + operationDate: Maybe.none(), + customerId: params.source.customerId, + recipient: Maybe.none(), + reference: params.source.reference, + description: params.source.description, + notes: params.source.notes, + languageCode: params.source.languageCode, + currencyCode: params.source.currencyCode, + linkedInvoiceId: Maybe.none(), + paymentMethodId: params.source.paymentMethodId, + paymentTermId: resolvedPaymentTerm.paymentTermId, + taxRegimeCode, + taxConfig: params.source.taxConfig, + globalDiscountPercentage: params.source.globalDiscountPercentage, + items: itemsResult.data, + }, + UniqueID.generateNewID() + ); + if (proformaResult.isFailure) return Result.fail(proformaResult.error); + + return this.persistAndReload({ + companyId: params.companyId, + proforma: proformaResult.data, + paymentTermAdaptation: resolvedPaymentTerm.paymentTermAdaptation, + transaction: params.transaction, + }); + } + + private async persistAndReload(params: { + companyId: UniqueID; + proforma: Proforma; + paymentTermAdaptation: PaymentTermAdaptation; + transaction?: unknown; + }): Promise> { + const persistenceResult = await this.deps.repository.create( + params.proforma, + params.transaction + ); + if (persistenceResult.isFailure) return Result.fail(persistenceResult.error); + + const reloadResult = await this.deps.repository.getByIdInCompany( + params.companyId, + params.proforma.id, + params.transaction + ); + if (reloadResult.isFailure) return Result.fail(reloadResult.error); + + const auditResult = await this.deps.audit.recordMutation({ + action: "proforma.duplicated", + target: toProformaAuditTarget(reloadResult.data), + before: {}, + after: toProformaAuditState(reloadResult.data), + }); + if (auditResult.isFailure) return Result.fail(auditResult.error); + + return Result.ok({ + proforma: reloadResult.data, + paymentTermAdaptation: params.paymentTermAdaptation, + }); + } + + private async getNextNumber(params: { + companyId: UniqueID; + proformaSeriesCode: Maybe; + targetInvoiceSeriesCode: Maybe; + transaction?: unknown; + }): Promise> { + const seriesValidationResult = + await this.deps.proformaSeriesValidator.ensureValidProformaSeries({ + companyId: params.companyId, + proformaSeriesCode: params.proformaSeriesCode, + transaction: params.transaction, + }); + if (seriesValidationResult.isFailure) return Result.fail(seriesValidationResult.error); + const numberResult = await this.deps.numberService.getNextForCompany( + params.companyId, + params.proformaSeriesCode, + params.transaction + ); + if (numberResult.isFailure) return Result.fail(numberResult.error); + const targetValidationResult = + await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({ + companyId: params.companyId, + targetInvoiceSeriesCode: params.targetInvoiceSeriesCode, + transaction: params.transaction, + }); + return targetValidationResult.isFailure + ? Result.fail(targetValidationResult.error) + : Result.ok(numberResult.data); + } + + private async resolveDuplicateSeriesCode(params: { + companyId: UniqueID; + sourceDocumentSeriesId: Maybe; + transaction?: unknown; + }): Promise, Error>> { + const listResult = await this.deps.documentSeriesServices.listActiveSeries({ + companyId: params.companyId.toString(), + documentType: "proforma", + transaction: params.transaction, + }); + if (listResult.isFailure) return Result.fail(listResult.error); + const sourceSeries = listResult.data.find( + (series) => series.id === params.sourceDocumentSeriesId.getOrUndefined() + ); + const selectedSeries = sourceSeries ?? listResult.data.find((series) => series.is_default); + if (!selectedSeries) { + return Result.fail( + new InvoiceSeriesNotFoundError({ + cause: "No active proforma document series is available.", + }) + ); + } + const codeResult = InvoiceSerie.create(selectedSeries.code); + return codeResult.isFailure + ? Result.fail(codeResult.error) + : Result.ok(Maybe.some(codeResult.data)); + } + + private async resolveValidTargetInvoiceSeriesCode(params: { + companyId: UniqueID; + targetInvoiceSeriesCode: Maybe; + transaction?: unknown; + }): Promise, Error>> { + if (params.targetInvoiceSeriesCode.isNone()) return Result.ok(Maybe.none()); + const listResult = await this.deps.documentSeriesServices.listActiveSeries({ + companyId: params.companyId.toString(), + documentType: "issued_invoice", + transaction: params.transaction, + }); + if (listResult.isFailure) return Result.fail(listResult.error); + const targetCode = params.targetInvoiceSeriesCode.unwrap().toPrimitive(); + return Result.ok( + listResult.data.some((series) => series.code === targetCode) + ? params.targetInvoiceSeriesCode + : Maybe.none() + ); + } + + private async resolveDuplicatePaymentTermId(params: { + companyId: UniqueID; + sourceCustomerId: UniqueID; + sourcePaymentTermId: Maybe; + transaction?: unknown; + }): Promise> { + const sourceResult = await this.resolveActivePaymentTermId({ + companyId: params.companyId, + paymentTermId: params.sourcePaymentTermId, + transaction: params.transaction, + }); + if ( + sourceResult.isFailure || + sourceResult.data.isSome() || + params.sourcePaymentTermId.isNone() + ) { + return sourceResult.isFailure + ? Result.fail(sourceResult.error) + : Result.ok({ + paymentTermId: sourceResult.data, + paymentTermAdaptation: null, + }); + } + const customerResult = await this.deps.customerServices.getCustomerById( + params.sourceCustomerId, + { + companyId: params.companyId, + transaction: params.transaction, + } + ); + if (customerResult.isFailure) return Result.fail(customerResult.error); + const customerResultPaymentTerm = await this.resolveActivePaymentTermId({ + companyId: params.companyId, + paymentTermId: customerResult.data.paymentTermId, + transaction: params.transaction, + }); + if (customerResultPaymentTerm.isFailure || customerResultPaymentTerm.data.isSome()) { + return customerResultPaymentTerm.isFailure + ? Result.fail(customerResultPaymentTerm.error) + : Result.ok({ + paymentTermId: customerResultPaymentTerm.data, + paymentTermAdaptation: { + source: "inactive_original", + resolution: "customer", + }, + }); + } + const companyResult = await this.deps.companiesServices.finder.findById({ + companyId: params.companyId, + transaction: params.transaction, + }); + if (companyResult.isFailure) return Result.fail(companyResult.error); + if (companyResult.data.isNone()) { + return Result.fail(new Error("Company not found while duplicating proforma.")); + } + const defaultPaymentTermId = companyResult.data.unwrap().defaultPaymentTermId; + if (defaultPaymentTermId == null) { + return Result.ok({ + paymentTermId: Maybe.none(), + paymentTermAdaptation: { source: "inactive_original", resolution: "none" }, + }); + } + const idResult = UniqueID.create(defaultPaymentTermId); + if (idResult.isFailure) return Result.fail(idResult.error); + const companyPaymentTermResult = await this.resolveActivePaymentTermId({ + companyId: params.companyId, + paymentTermId: Maybe.some(idResult.data), + transaction: params.transaction, + }); + if (companyPaymentTermResult.isFailure) return Result.fail(companyPaymentTermResult.error); + return Result.ok({ + paymentTermId: companyPaymentTermResult.data, + paymentTermAdaptation: { + source: "inactive_original", + resolution: companyPaymentTermResult.data.isSome() ? "company" : "none", + }, + }); + } + + private async resolveActivePaymentTermId(params: { + companyId: UniqueID; + paymentTermId: Maybe; + transaction?: unknown; + }): Promise, Error>> { + if (params.paymentTermId.isNone()) return Result.ok(Maybe.none()); + const result = await this.deps.paymentTermFinder.findByIdInCompany({ + companyId: params.companyId, + id: params.paymentTermId.unwrap(), + transaction: params.transaction, + }); + if (result.isFailure) return Result.fail(result.error); + return result.data.isSome() && result.data.unwrap().isActive + ? Result.ok(params.paymentTermId) + : Result.ok(Maybe.none()); + } + + private async resolveItems(params: { + companyId: UniqueID; + atDate: UtcDate; + source: Proforma; + }): Promise[0]["items"], Error>> { + const items: Parameters[0]["items"] = []; + for (const item of params.source.items.getAll()) { + const taxesResult = await this.deps.taxResolver.ensureItemTaxes({ + companyId: params.companyId, + atDate: params.atDate, + taxCodes: { + ivaCode: item.ivaCode(), + recCode: item.recCode(), + retentionCode: item.retentionCode(), + }, + }); + if (taxesResult.isFailure) return Result.fail(taxesResult.error); + items.push({ + description: item.description, + quantity: item.quantity, + unitAmount: item.unitAmount, + itemDiscountPercentage: item.itemDiscountPercentage, + taxes: taxesResult.data, + }); + } + return Result.ok(items); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.test.ts new file mode 100644 index 00000000..c7552391 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { + IAudit, + RecordAuditEventShortcutInput, + RecordAuditInput, + RecordAuditMutationShortcutInput, +} from "@repo/rdx-audit"; +import { Result } from "@repo/rdx-utils"; + +import { InvoiceStatus } from "../../../domain"; +import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; + +import { ProformaStatusChanger } from "./proforma-status-changer"; + +class FakeAudit implements IAudit { + public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = []; + + public async record(_input: RecordAuditInput) { + return Result.ok(); + } + + public async recordEvent(_input: RecordAuditEventShortcutInput) { + return Result.ok(); + } + + public async recordMutation(input: RecordAuditMutationShortcutInput) { + this.recordMutationCalls.push(input); + return Result.ok(); + } +} + +const required = (result: { data: T }): T => result.data; + +test("cambia draft a sent y registra proforma.status_changed", async () => { + const proforma = createProformaTestFixture(); + const audit = new FakeAudit(); + let updateCalls = 0; + const changer = new ProformaStatusChanger({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(); + }, + } as never, + audit, + }); + + const result = await changer.changeStatus({ + companyId: proforma.companyId, + id: proforma.id, + newStatus: "sent", + }); + + assert.equal(result.isSuccess, true); + assert.equal(updateCalls, 1); + assert.equal(audit.recordMutationCalls.length, 1); + assert.equal(audit.recordMutationCalls[0].action, "proforma.status_changed"); + assert.equal(audit.recordMutationCalls[0].target.type, "proforma"); + assert.equal(audit.recordMutationCalls[0].target.id, proforma.id.toPrimitive()); + assert.deepEqual(audit.recordMutationCalls[0].before, { + ...audit.recordMutationCalls[0].before, + status: "draft", + }); + assert.deepEqual(audit.recordMutationCalls[0].after, { + ...audit.recordMutationCalls[0].after, + status: "sent", + }); +}); + +test("no persiste ni audita draft a draft", async () => { + const proforma = createProformaTestFixture(); + const audit = new FakeAudit(); + let updateCalls = 0; + const changer = new ProformaStatusChanger({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(); + }, + } as never, + audit, + }); + + const result = await changer.changeStatus({ + companyId: proforma.companyId, + id: proforma.id, + newStatus: "draft", + }); + + assert.equal(result.isSuccess, true); + assert.equal(updateCalls, 0); + assert.equal(audit.recordMutationCalls.length, 0); +}); + +test("rechaza una transición manual inválida sin persistir ni auditar", async () => { + const proforma = createProformaTestFixture(); + const sent = required(InvoiceStatus.create("sent")); + assert.equal(proforma.changeStatus(sent).isSuccess, true); + const audit = new FakeAudit(); + let updateCalls = 0; + const changer = new ProformaStatusChanger({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(); + }, + } as never, + audit, + }); + + const result = await changer.changeStatus({ + companyId: proforma.companyId, + id: proforma.id, + newStatus: "draft", + }); + + assert.equal(result.isFailure, true); + assert.equal(updateCalls, 0); + assert.equal(audit.recordMutationCalls.length, 0); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts index 8995eb35..8f624d72 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-status-changer.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 { InvoiceStatus, type Proforma } from "../../../domain"; +import { + PROFORMA_AUDIT_ACTIONS, + toProformaAuditState, + toProformaAuditTarget, +} from "../audit/proforma-audit"; import type { IProformaRepository } from "../repositories"; export interface IProformaStatusChanger { @@ -17,6 +23,7 @@ export class ProformaStatusChanger implements IProformaStatusChanger { public constructor( private readonly deps: { repository: IProformaRepository; + audit: IAudit; } ) {} @@ -43,6 +50,7 @@ export class ProformaStatusChanger implements IProformaStatusChanger { } const proforma = proformaResult.data; + const before = toProformaAuditState(proforma); const changeResult = proforma.changeStatus(statusResult.data); @@ -60,6 +68,17 @@ export class ProformaStatusChanger implements IProformaStatusChanger { return Result.fail(updateResult.error); } + const auditResult = await this.deps.audit.recordMutation({ + action: PROFORMA_AUDIT_ACTIONS.STATUS_CHANGED, + target: toProformaAuditTarget(proforma), + before, + after: toProformaAuditState(proforma), + }); + + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(proforma); } } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-unarchiver.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-unarchiver.test.ts new file mode 100644 index 00000000..39805f5f --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-unarchiver.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { + IAudit, + RecordAuditEventShortcutInput, + RecordAuditInput, + RecordAuditMutationShortcutInput, +} from "@repo/rdx-audit"; +import { UtcDate } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; + +import { ProformaArchiver } from "./proforma-archiver"; + +class FakeAudit implements IAudit { + public readonly calls: RecordAuditMutationShortcutInput[] = []; + public async record(_input: RecordAuditInput) { + return Result.ok(); + } + public async recordEvent(_input: RecordAuditEventShortcutInput) { + return Result.ok(); + } + public async recordMutation(input: RecordAuditMutationShortcutInput) { + this.calls.push(input); + return Result.ok(); + } +} + +test("unarchive persiste y registra proforma.unarchived", async () => { + const archived = createProformaTestFixture({ + archivedAt: Maybe.some(UtcDate.createFromISO("2026-08-26").data), + }); + const unarchived = createProformaTestFixture(); + const audit = new FakeAudit(); + let persistenceCalls = 0; + const archiver = new ProformaArchiver({ + repository: { + getByIdIncludingArchivedInCompany: async () => + Result.ok(persistenceCalls === 0 ? archived : unarchived), + unarchiveByIdInCompany: async () => { + persistenceCalls += 1; + return Result.ok(true); + }, + } as never, + audit, + }); + const result = await archiver.unarchive({ + companyId: archived.companyId, + id: archived.id, + transaction: {}, + }); + assert.equal(result.isSuccess, true); + assert.equal(persistenceCalls, 1); + assert.equal(audit.calls.length, 1); + assert.equal(audit.calls[0].action, "proforma.unarchived"); + assert.equal(audit.calls[0].target.type, "proforma"); + assert.equal(audit.calls[0].target.id, archived.id.toPrimitive()); + assert.notEqual(audit.calls[0].before.archivedAt, null); + assert.equal(audit.calls[0].after.archivedAt, null); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.test.ts index 1e42c823..07b8d36a 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.test.ts @@ -1,13 +1,61 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { Result } from "@repo/rdx-utils"; -import { Maybe } from "@repo/rdx-utils"; -import { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import type { + IAudit, + RecordAuditEventShortcutInput, + RecordAuditInput, + RecordAuditMutationShortcutInput, +} from "@repo/rdx-audit"; +import { + City, + Country, + Name, + PostalCode, + Province, + Street, + TINNumber, + UniqueID, + UtcDate, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { ItemAmount, ItemDescription, ItemQuantity, ProformaItemTaxes } from "../../../domain"; +import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; import { ProformaUpdater } from "./proforma-updater"; -const buildProformaDouble = (paymentTermId?: UniqueID) => { +class FakeAudit implements IAudit { + public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = []; + private error: Error | undefined; + + failWith(error: Error): void { + this.error = error; + } + + async record(_input: RecordAuditInput) { + return Result.ok(undefined); + } + async recordEvent(_input: RecordAuditEventShortcutInput) { + return Result.ok(undefined); + } + async recordMutation(input: RecordAuditMutationShortcutInput) { + this.recordMutationCalls.push(input); + + if (this.error) { + return Result.fail(this.error); + } + + return Result.ok(undefined); + } +} + +const unwrap = (result: { data: T; isSuccess: boolean }): T => { + assert.equal(result.isSuccess, true); + return result.data; +}; + +const buildProformaDouble = (params?: { paymentTermId?: UniqueID; didChange?: boolean }) => { const proformaDateResult = UtcDate.createFromISO("2026-08-16"); assert.equal(proformaDateResult.isSuccess, true); @@ -20,7 +68,7 @@ const buildProformaDouble = (paymentTermId?: UniqueID) => { isApproved: () => false, }, proformaDate: proformaDateResult.data, - paymentTermId: paymentTermId ? Maybe.some(paymentTermId) : Maybe.none(), + paymentTermId: params?.paymentTermId ? Maybe.some(params.paymentTermId) : Maybe.none(), taxConfig: { taxMode: "single", defaultIvaCode: Maybe.none(), @@ -31,16 +79,312 @@ const buildProformaDouble = (paymentTermId?: UniqueID) => { }, update: (patch: unknown) => { receivedPatch = patch; - return Result.ok(undefined); + return Result.ok(params?.didChange ?? true); }, } as never, getReceivedPatch: () => receivedPatch, }; }; +test("update cambia la descripción en una Proforma real", async () => { + const proforma = createProformaTestFixture({ description: "Before" }); + let updateCalls = 0; + const audit = new FakeAudit(); + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(undefined); + }, + } as never, + audit, + taxResolver: {} as never, + paymentResolver: {} as never, + targetInvoiceSeriesValidator: {} as never, + customerServices: {} as never, + }); + + const result = await updater.update({ + companyId: proforma.companyId, + id: proforma.id, + patchProps: { description: Maybe.some("After") }, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(updateCalls, 1); + assert.equal(result.data.description.unwrap(), "After"); + assert.equal(audit.recordMutationCalls.length, 1); + const [auditCall] = audit.recordMutationCalls; + assert.equal(auditCall.action, "proforma.updated"); + assert.equal(auditCall.target.type, "proforma"); + assert.equal(auditCall.target.id, proforma.id.toPrimitive()); + assert.equal(auditCall.target.label, proforma.proformaReference.toPrimitive()); + assert.equal(auditCall.before.description, "Before"); + assert.equal(auditCall.after.description, "After"); +}); + +test("update registra customer y recipient en una sola mutación Audit", async () => { + const customerAId = UniqueID.generateNewID(); + const customerBId = UniqueID.generateNewID(); + const proforma = createProformaTestFixture({ customerId: customerAId }); + let updateCalls = 0; + const audit = new FakeAudit(); + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(undefined); + }, + } as never, + audit, + taxResolver: {} as never, + paymentResolver: {} as never, + targetInvoiceSeriesValidator: {} as never, + customerServices: { + getCustomerById: async () => + Result.ok({ + id: customerBId, + name: unwrap(Name.create("Cliente B")), + tin: Maybe.some(unwrap(TINNumber.create("ESB1234567"))), + address: { + street: Maybe.some(unwrap(Street.create("Calle Uno 1"))), + street2: Maybe.some(unwrap(Street.create("Piso 2"))), + city: Maybe.some(unwrap(City.create("Madrid"))), + postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), + province: Maybe.some(unwrap(Province.create("Madrid"))), + country: Maybe.some(unwrap(Country.create("ES"))), + }, + }), + } as never, + }); + + const result = await updater.update({ + companyId: proforma.companyId, + id: proforma.id, + patchProps: { customerId: customerBId }, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(updateCalls, 1); + assert.equal(result.data.customerId.equals(customerBId), true); + assert.equal(result.data.recipient.unwrap().name.toPrimitive(), "Cliente B"); + assert.equal(audit.recordMutationCalls.length, 1); + const [auditCall] = audit.recordMutationCalls; + assert.equal(auditCall.action, "proforma.updated"); + assert.equal(auditCall.before.customerId, customerAId.toPrimitive()); + assert.equal(auditCall.after.customerId, customerBId.toPrimitive()); + assert.notDeepEqual(auditCall.before.recipient, auditCall.after.recipient); + assert.equal(auditCall.before.recipient?.name, "Cliente de prueba"); + assert.equal(auditCall.after.recipient?.name, "Cliente B"); +}); + +test("update registra los items antes y después de un cambio funcional", async () => { + const initialItem = { + description: Maybe.some(unwrap(ItemDescription.create("Línea A"))), + quantity: Maybe.some(unwrap(ItemQuantity.create({ value: 1 }))), + unitAmount: Maybe.some(unwrap(ItemAmount.create({ value: 1000, currency_code: "EUR" }))), + itemDiscountPercentage: Maybe.none(), + taxes: ProformaItemTaxes.empty().getProps(), + }; + const proforma = createProformaTestFixture({ items: [initialItem] }); + let updateCalls = 0; + const audit = new FakeAudit(); + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(undefined); + }, + } as never, + audit, + taxResolver: { + ensureItemTaxes: async () => Result.ok(ProformaItemTaxes.empty().getProps()), + } as never, + paymentResolver: {} as never, + targetInvoiceSeriesValidator: {} as never, + customerServices: {} as never, + }); + + const result = await updater.update({ + companyId: proforma.companyId, + id: proforma.id, + patchProps: { + items: [ + { + ...initialItem, + description: Maybe.some(unwrap(ItemDescription.create("Línea B"))), + }, + ], + }, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(updateCalls, 1); + assert.equal(audit.recordMutationCalls.length, 1); + const [auditCall] = audit.recordMutationCalls; + assert.equal(auditCall.action, "proforma.updated"); + assert.equal(auditCall.before.items.length, auditCall.after.items.length); + assert.notDeepEqual(auditCall.before.items, auditCall.after.items); + assert.equal(auditCall.before.items[0]?.description, "Línea A"); + assert.equal(auditCall.after.items[0]?.description, "Línea B"); +}); + +test("update limpia el payment term y lo registra en Audit", async () => { + const existingPaymentTermId = unwrap(UniqueID.create("01900000-0000-7000-8000-000000000005")); + const proforma = createProformaTestFixture({ + paymentTermId: Maybe.some(existingPaymentTermId), + }); + let updateCalls = 0; + let paymentTermValidationCalls = 0; + const audit = new FakeAudit(); + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(undefined); + }, + } as never, + audit, + taxResolver: {} as never, + paymentResolver: { + ensurePaymentTermById: async () => { + paymentTermValidationCalls += 1; + return Result.fail(new Error("A cleared payment term must not be validated")); + }, + } as never, + targetInvoiceSeriesValidator: {} as never, + customerServices: {} as never, + }); + + const result = await updater.update({ + companyId: proforma.companyId, + id: proforma.id, + patchProps: { paymentTermId: Maybe.none() }, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(paymentTermValidationCalls, 0); + assert.equal(updateCalls, 1); + assert.equal(result.data.paymentTermId.isNone(), true); + assert.equal(audit.recordMutationCalls.length, 1); + const [auditCall] = audit.recordMutationCalls; + assert.equal(auditCall.action, "proforma.updated"); + assert.equal(auditCall.before.paymentTermId, existingPaymentTermId.toPrimitive()); + assert.equal(auditCall.after.paymentTermId, null); +}); + +test("update no persiste ni registra Audit cuando no puede cargar la Proforma", async () => { + let updateCalls = 0; + const audit = new FakeAudit(); + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.fail(new Error("Proforma not found")), + update: async () => { + updateCalls += 1; + return Result.ok(undefined); + }, + } as never, + audit, + taxResolver: {} as never, + paymentResolver: {} as never, + targetInvoiceSeriesValidator: {} as never, + customerServices: {} as never, + }); + + const result = await updater.update({ + companyId: UniqueID.generateNewID(), + id: UniqueID.generateNewID(), + patchProps: {}, + transaction: undefined, + }); + + assert.equal(result.isFailure, true); + assert.equal(updateCalls, 0); + assert.equal(audit.recordMutationCalls.length, 0); +}); + +test("update sin cambio funcional no persiste ni registra Audit", async () => { + const proforma = createProformaTestFixture({ description: "Same" }); + let updateCalls = 0; + const audit = new FakeAudit(); + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(undefined); + }, + } as never, + audit, + taxResolver: {} as never, + paymentResolver: {} as never, + targetInvoiceSeriesValidator: {} as never, + customerServices: {} as never, + }); + + const result = await updater.update({ + companyId: proforma.companyId, + id: proforma.id, + patchProps: { description: Maybe.some("Same") }, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(updateCalls, 0); + assert.equal(audit.recordMutationCalls.length, 0); + assert.equal(result.data.description.unwrap(), "Same"); +}); + +test("update propaga el fallo de Audit tras un cambio funcional", async () => { + const proforma = createProformaTestFixture({ description: "Before" }); + let updateCalls = 0; + const audit = new FakeAudit(); + audit.failWith(new Error("Audit failed")); + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(undefined); + }, + } as never, + audit, + taxResolver: {} as never, + paymentResolver: {} as never, + targetInvoiceSeriesValidator: {} as never, + customerServices: {} as never, + }); + + const result = await updater.update({ + companyId: proforma.companyId, + id: proforma.id, + patchProps: { description: Maybe.some("After") }, + transaction: undefined, + }); + + assert.equal(updateCalls, 1); + assert.equal(audit.recordMutationCalls.length, 1); + assert.equal(result.isFailure, true); +}); + test("update conserva un payment term inactivo ya asignado si no cambia", async () => { const existingPaymentTermId = UniqueID.generateNewID(); - const { proforma } = buildProformaDouble(existingPaymentTermId); + const { proforma } = buildProformaDouble({ paymentTermId: existingPaymentTermId }); let validationCalls = 0; const updater = new ProformaUpdater({ @@ -81,7 +425,7 @@ test("update conserva un payment term inactivo ya asignado si no cambia", async test("update permite limpiar el payment term con null explicito", async () => { const existingPaymentTermId = UniqueID.generateNewID(); - const { proforma } = buildProformaDouble(existingPaymentTermId); + const { proforma } = buildProformaDouble({ paymentTermId: existingPaymentTermId }); let validationCalls = 0; const updater = new ProformaUpdater({ @@ -121,7 +465,7 @@ test("update permite limpiar el payment term con null explicito", async () => { }); test("update rechaza asignar un nuevo payment term inactivo", async () => { - const { proforma } = buildProformaDouble(UniqueID.generateNewID()); + const { proforma } = buildProformaDouble({ paymentTermId: UniqueID.generateNewID() }); const updater = new ProformaUpdater({ repository: { @@ -203,6 +547,90 @@ test("update refresca el snapshot del destinatario al cambiar el customer", asyn assert.equal(result.isSuccess, true); assert.equal(getReceivedPatch() !== undefined, true); - assert.equal((getReceivedPatch() as { customerId: UniqueID }).customerId.equals(customerId), true); + assert.equal( + (getReceivedPatch() as { customerId: UniqueID }).customerId.equals(customerId), + true + ); assert.equal((getReceivedPatch() as { recipient: Maybe }).recipient.isSome(), true); }); + +test("update no persiste ni relee cuando el aggregate no tiene cambios funcionales", async () => { + const { proforma } = buildProformaDouble({ didChange: false }); + let updateCalls = 0; + let getByIdCalls = 0; + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => { + getByIdCalls += 1; + return Result.ok(proforma); + }, + update: async () => { + updateCalls += 1; + return Result.ok(undefined); + }, + } as never, + taxResolver: { + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => Result.ok(undefined), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + customerServices: { + getCustomerById: async () => Result.fail(new Error("unused")), + } as never, + }); + + const result = await updater.update({ + companyId: UniqueID.generateNewID(), + id: UniqueID.generateNewID(), + patchProps: {}, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(updateCalls, 0); + assert.equal(getByIdCalls, 1); +}); + +test("update persiste una vez cuando el aggregate informa un cambio funcional", async () => { + const { proforma } = buildProformaDouble({ didChange: true }); + let updateCalls = 0; + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => { + updateCalls += 1; + return Result.ok(undefined); + }, + } as never, + taxResolver: { + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => Result.ok(undefined), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + customerServices: { + getCustomerById: async () => Result.fail(new Error("unused")), + } as never, + }); + + const result = await updater.update({ + companyId: UniqueID.generateNewID(), + id: UniqueID.generateNewID(), + patchProps: {}, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(updateCalls, 1); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts index 0714e41c..699c452d 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts @@ -1,14 +1,20 @@ +import type { ICustomerPublicServices } from "@erp/customers/api"; +import type { IAudit } from "@repo/rdx-audit"; import { DomainValidationError, type UniqueID, type UtcDate } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -import type { ICustomerPublicServices } from "@erp/customers/api"; import { type Proforma, type ProformaItemPatchProps, type ProformaPatchProps, - ProformaTaxConfig, ProformaRecipient, + ProformaTaxConfig, } from "../../../domain"; +import { + PROFORMA_AUDIT_ACTIONS, + toProformaAuditState, + toProformaAuditTarget, +} from "../audit/proforma-audit"; import type { ProformaPatchInputProps } from "../models"; import type { IProformaRepository } from "../repositories"; @@ -33,6 +39,7 @@ export class ProformaUpdater implements IProformaUpdater { paymentResolver: ProformaPaymentResolver; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; customerServices: ICustomerPublicServices; + audit: IAudit; } ) {} @@ -52,6 +59,7 @@ export class ProformaUpdater implements IProformaUpdater { } const proforma = existingResult.data; + const before = toProformaAuditState(proforma); if (proforma.status.isIssued()) { return Result.fail( @@ -97,6 +105,10 @@ export class ProformaUpdater implements IProformaUpdater { return Result.fail(updateResult.error); } + if (!updateResult.data) { + return Result.ok(proforma); + } + // Persistir cambios const saveResult = await this.deps.repository.update(proforma, transaction); @@ -104,6 +116,17 @@ export class ProformaUpdater implements IProformaUpdater { return Result.fail(saveResult.error); } + const auditResult = await this.deps.audit.recordMutation({ + action: PROFORMA_AUDIT_ACTIONS.UPDATED, + target: toProformaAuditTarget(proforma), + before, + after: toProformaAuditState(proforma), + }); + + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + // Volvemos a recuperar lo guardado para asegurarnos de que los cambios se reflejen correctamente const verifyResult = await this.deps.repository.getByIdInCompany(companyId, id, transaction); @@ -148,7 +171,9 @@ export class ProformaUpdater implements IProformaUpdater { } if (patch.paymentTermId !== undefined) { - if ( + if (patch.paymentTermId.isNone()) { + // Una limpieza explícita no referencia ningún término que validar. + } else if ( patch.paymentTermId.isSome() && params.currentPaymentTermId.isSome() && patch.paymentTermId.unwrap().equals(params.currentPaymentTermId.unwrap()) diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.test.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.test.ts index 4997c599..7fa2dd64 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.test.ts @@ -1,224 +1,106 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { IPaymentTermPublicFinder } from "@erp/catalogs/api"; -import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ITransactionManager } from "@erp/core/api"; -import type { ICustomerPublicServices } from "@erp/customers/api"; -import { UniqueID } from "@repo/rdx-ddd"; -import { Maybe, Result } from "@repo/rdx-utils"; +import { Result } from "@repo/rdx-utils"; + +import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture"; import { DuplicateProformaByIdUseCase } from "./duplicate-proforma-by-id.use-case"; -const buildTransactionManager = (): ITransactionManager => ({ +const transactionManager: ITransactionManager = { start: async () => undefined, getTransaction: () => undefined, complete: async (work: (transaction: unknown) => Promise) => work(undefined), commit: async () => undefined, rollback: async () => undefined, -}); +}; -const buildUseCase = (params?: { - sourcePaymentTermId?: Maybe; - customerPaymentTermId?: Maybe; - companyDefaultPaymentTermId?: string | null; - activePaymentTermIds?: Set; -}) => { - const companyId = UniqueID.generateNewID(); - const sourceId = UniqueID.generateNewID(); - const customerId = UniqueID.generateNewID(); - const activePaymentTermIds = params?.activePaymentTermIds ?? new Set(); - let receivedPaymentTermId: Maybe | undefined; - - const paymentTermFinder: IPaymentTermPublicFinder = { - findByIdInCompany: async (params: { id: UniqueID }) => - Result.ok( - activePaymentTermIds.has(params.id.toString()) - ? Maybe.some({ - id: params.id, - companyId, - name: "Plazo", - description: Maybe.none(), - isActive: true, - isSystem: false, - }) - : Maybe.none() - ), - } as never; - - const customerServices: ICustomerPublicServices = { - findCustomerByTIN: async () => Result.fail(new Error("unused")), - createCustomer: async () => Result.fail(new Error("unused")), - getCustomerById: async () => - Result.ok({ - paymentTermId: params?.customerPaymentTermId ?? Maybe.none(), - } as never), - }; - - const companiesServices: ICompanyPublicServices = { - finder: { - findById: async () => - Result.ok( - Maybe.some({ - id: companyId.toString(), - defaultPaymentTermId: params?.companyDefaultPaymentTermId ?? null, - } as never) - ), - }, - } as never; +const buildUseCase = (params?: { sourceFailure?: Error; duplicatorFailure?: Error }) => { + const source = createProformaTestFixture(); + const duplicated = createProformaTestFixture({ description: "Duplicada" }); + let duplicatorCalls = 0; + let receivedParams: unknown; const useCase = new DuplicateProformaByIdUseCase({ - creator: { - create: async (input: { props: { paymentTermId?: Maybe }; id: UniqueID }) => { - receivedPaymentTermId = input.props.paymentTermId; - return Result.ok({ id: input.id } as never); + duplicator: { + duplicate: async (input) => { + duplicatorCalls += 1; + receivedParams = input; + return params?.duplicatorFailure + ? Result.fail(params.duplicatorFailure) + : Result.ok({ proforma: duplicated, paymentTermAdaptation: null }); }, - } as never, + }, finder: { findProformaByIdIncludingArchived: async () => - Result.ok({ - documentSeriesId: Maybe.some(UniqueID.generateNewID().toPrimitive()), - targetInvoiceSeriesCode: Maybe.none(), - customerId, - reference: Maybe.none(), - description: Maybe.none(), - notes: Maybe.none(), - languageCode: { toPrimitive: () => "es" }, - currencyCode: { toPrimitive: () => "EUR" }, - paymentMethodId: Maybe.none(), - paymentTermId: params?.sourcePaymentTermId ?? Maybe.none(), - taxRegimeCode: Maybe.some("01"), - taxConfig: { - taxMode: "single", - defaultIvaCode: Maybe.none(), - usesEquivalenceSurcharge: false, - defaultRecCode: Maybe.none(), - usesRetention: false, - defaultRetentionCode: Maybe.none(), - }, - globalDiscountPercentage: { toPrimitive: () => ({ value: 0, scale: 2 }) }, - items: { - getAll: () => [], - }, - } as never), - findProformaById: async () => Result.ok({ id: sourceId } as never), + params?.sourceFailure ? Result.fail(params.sourceFailure) : Result.ok(source), } as never, fullReadModelAssembler: { - assemble: async () => Result.ok({} as never), + assemble: async () => Result.ok(duplicated), } as never, fullSnapshotBuilder: { - toOutput: () => ({ id: sourceId.toPrimitive() }), + toOutput: (proforma) => ({ + id: proforma.id.toPrimitive(), + description: proforma.description.getOrUndefined() ?? null, + }), } as never, - documentSeriesServices: { - listActiveSeries: async (params: { documentType: string }) => - Result.ok([ - { - id: UniqueID.generateNewID().toPrimitive(), - code: params.documentType === "proforma" ? "PF" : "FV", - is_default: true, - }, - ]), - } as never, - companiesServices, - customerServices, - paymentTermFinder, - transactionManager: buildTransactionManager(), + transactionManager, }); return { - companyId, - sourceId, + source, + duplicated, useCase, - getReceivedPaymentTermId: () => receivedPaymentTermId, + getDuplicatorCalls: () => duplicatorCalls, + getReceivedParams: () => receivedParams, }; }; -test("duplicate conserva el payment term original si sigue activo", async () => { - const paymentTermId = UniqueID.generateNewID(); - const setup = buildUseCase({ - sourcePaymentTermId: Maybe.some(paymentTermId), - activePaymentTermIds: new Set([paymentTermId.toString()]), - }); +test("duplicate usa Duplicator y conserva la respuesta final", async () => { + const setup = buildUseCase(); const result = await setup.useCase.execute({ - companyId: setup.companyId, - params: { proforma_id: setup.sourceId.toPrimitive() }, + companyId: setup.source.companyId, + params: { proforma_id: setup.source.id.toPrimitive() }, }); assert.equal(result.isSuccess, true); - assert.equal(setup.getReceivedPaymentTermId()?.unwrap().equals(paymentTermId), true); + assert.equal(setup.getDuplicatorCalls(), 1); + assert.deepEqual(setup.getReceivedParams(), { + companyId: setup.source.companyId, + source: setup.source, + transaction: undefined, + }); + assert.equal(result.data.id, setup.duplicated.id.toPrimitive()); + assert.equal(result.data.description, "Duplicada"); assert.equal(result.data.payment_term_adaptation, null); }); -test("duplicate usa el payment term activo del customer si el original está inactivo", async () => { - const originalPaymentTermId = UniqueID.generateNewID(); - const customerPaymentTermId = UniqueID.generateNewID(); - const setup = buildUseCase({ - sourcePaymentTermId: Maybe.some(originalPaymentTermId), - customerPaymentTermId: Maybe.some(customerPaymentTermId), - activePaymentTermIds: new Set([customerPaymentTermId.toString()]), - }); +test("duplicate no invoca Duplicator si falla la carga de source", async () => { + const sourceError = new Error("Source not found"); + const setup = buildUseCase({ sourceFailure: sourceError }); const result = await setup.useCase.execute({ - companyId: setup.companyId, - params: { proforma_id: setup.sourceId.toPrimitive() }, + companyId: setup.source.companyId, + params: { proforma_id: setup.source.id.toPrimitive() }, }); - assert.equal(result.isSuccess, true); - assert.equal(setup.getReceivedPaymentTermId()?.unwrap().equals(customerPaymentTermId), true); - assert.deepEqual(result.data.payment_term_adaptation, { - source: "inactive_original", - resolution: "customer", - }); + assert.equal(result.isFailure, true); + assert.equal(result.error, sourceError); + assert.equal(setup.getDuplicatorCalls(), 0); }); -test("duplicate usa el default activo de company si el original y el customer no son válidos", async () => { - const originalPaymentTermId = UniqueID.generateNewID(); - const inactiveCustomerPaymentTermId = UniqueID.generateNewID(); - const companyDefaultPaymentTermId = UniqueID.generateNewID(); - const setup = buildUseCase({ - sourcePaymentTermId: Maybe.some(originalPaymentTermId), - customerPaymentTermId: Maybe.some(inactiveCustomerPaymentTermId), - companyDefaultPaymentTermId: companyDefaultPaymentTermId.toPrimitive(), - activePaymentTermIds: new Set([companyDefaultPaymentTermId.toString()]), - }); +test("duplicate propaga el fallo de Duplicator", async () => { + const duplicateError = new Error("Duplicate failed"); + const setup = buildUseCase({ duplicatorFailure: duplicateError }); const result = await setup.useCase.execute({ - companyId: setup.companyId, - params: { proforma_id: setup.sourceId.toPrimitive() }, + companyId: setup.source.companyId, + params: { proforma_id: setup.source.id.toPrimitive() }, }); - assert.equal(result.isSuccess, true); - assert.equal( - setup.getReceivedPaymentTermId()?.unwrap().equals(companyDefaultPaymentTermId), - true - ); - assert.deepEqual(result.data.payment_term_adaptation, { - source: "inactive_original", - resolution: "company", - }); -}); - -test("duplicate crea sin payment term si no hay fallback válido", async () => { - const originalPaymentTermId = UniqueID.generateNewID(); - const inactiveCustomerPaymentTermId = UniqueID.generateNewID(); - const inactiveCompanyDefaultPaymentTermId = UniqueID.generateNewID(); - const setup = buildUseCase({ - sourcePaymentTermId: Maybe.some(originalPaymentTermId), - customerPaymentTermId: Maybe.some(inactiveCustomerPaymentTermId), - companyDefaultPaymentTermId: inactiveCompanyDefaultPaymentTermId.toPrimitive(), - activePaymentTermIds: new Set(), - }); - - const result = await setup.useCase.execute({ - companyId: setup.companyId, - params: { proforma_id: setup.sourceId.toPrimitive() }, - }); - - assert.equal(result.isSuccess, true); - assert.equal(setup.getReceivedPaymentTermId()?.isNone(), true); - assert.deepEqual(result.data.payment_term_adaptation, { - source: "inactive_original", - resolution: "none", - }); + assert.equal(result.isFailure, true); + assert.equal(result.error, duplicateError); + assert.equal(setup.getDuplicatorCalls(), 1); }); diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts index e925a560..931bfa24 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts @@ -1,27 +1,15 @@ -import type { IPaymentTermPublicFinder } from "@erp/catalogs/api"; -import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ITransactionManager } from "@erp/core/api"; -import type { ICustomerPublicServices } from "@erp/customers/api"; -import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; -import { UniqueID, UtcDate } from "@repo/rdx-ddd"; -import { Maybe, Result } from "@repo/rdx-utils"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; import type { DuplicateProformaByIdParamsRequestDTO } from "../../../../common"; -import { InvoiceSerie, InvoiceStatus, type ProformaRecipient } from "../../../domain"; -import { InvoiceSeriesNotFoundError } from "../../invoice-series"; -import type { ProformaCreateInputProps } from "../models"; import type { - IProformaCreator, + IProformaDuplicator, IProformaFinder, IProformaFullReadModelAssembler, } from "../services"; import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; -type DuplicatePaymentTermAdaptation = { - source: "inactive_original"; - resolution: "customer" | "company" | "none"; -} | null; - type DuplicateProformaByIdUseCaseInput = { companyId: UniqueID; params: DuplicateProformaByIdParamsRequestDTO; @@ -30,23 +18,17 @@ type DuplicateProformaByIdUseCaseInput = { export class DuplicateProformaByIdUseCase { public constructor( private readonly deps: { - creator: IProformaCreator; + duplicator: IProformaDuplicator; finder: IProformaFinder; fullReadModelAssembler: IProformaFullReadModelAssembler; fullSnapshotBuilder: IProformaFullSnapshotBuilder; - documentSeriesServices: DocumentSeriesPublicServicesType; - companiesServices: ICompanyPublicServices; - customerServices: ICustomerPublicServices; - paymentTermFinder: IPaymentTermPublicFinder; transactionManager: ITransactionManager; } ) {} public async execute(params: DuplicateProformaByIdUseCaseInput) { const proformaIdResult = UniqueID.create(params.params.proforma_id); - if (proformaIdResult.isFailure) { - return Result.fail(proformaIdResult.error); - } + if (proformaIdResult.isFailure) return Result.fail(proformaIdResult.error); return this.deps.transactionManager.complete(async (transaction: unknown) => { try { @@ -55,370 +37,29 @@ export class DuplicateProformaByIdUseCase { proformaIdResult.data, transaction ); + if (sourceResult.isFailure) return Result.fail(sourceResult.error); - if (sourceResult.isFailure) { - return Result.fail(sourceResult.error); - } - - const source = sourceResult.data; - const duplicateId = UniqueID.generateNewID(); - const proformaDateResult = UtcDate.createFromISO(new Date().toISOString().slice(0, 10)); - - if (proformaDateResult.isFailure) { - return Result.fail(proformaDateResult.error); - } - - const proformaSeriesCodeResult = await this.resolveDuplicateSeriesCode({ + const duplicatedResult = await this.deps.duplicator.duplicate({ companyId: params.companyId, - sourceDocumentSeriesId: source.documentSeriesId, + source: sourceResult.data, transaction, }); - - if (proformaSeriesCodeResult.isFailure) { - return Result.fail(proformaSeriesCodeResult.error); - } - - const targetInvoiceSeriesCodeResult = await this.resolveValidTargetInvoiceSeriesCode({ - companyId: params.companyId, - targetInvoiceSeriesCode: source.targetInvoiceSeriesCode, - transaction, - }); - - if (targetInvoiceSeriesCodeResult.isFailure) { - return Result.fail(targetInvoiceSeriesCodeResult.error); - } - - const paymentTermResolutionResult = await this.resolveDuplicatePaymentTermId({ - companyId: params.companyId, - sourceCustomerId: source.customerId, - sourcePaymentTermId: source.paymentTermId, - transaction, - }); - - if (paymentTermResolutionResult.isFailure) { - return Result.fail(paymentTermResolutionResult.error); - } - - const props: ProformaCreateInputProps = { - companyId: params.companyId, - status: InvoiceStatus.draft(), - proformaSeriesCode: proformaSeriesCodeResult.data, - targetInvoiceSeriesCode: targetInvoiceSeriesCodeResult.data, - proformaDate: proformaDateResult.data, - operationDate: Maybe.none(), - customerId: source.customerId, - recipient: Maybe.none(), - reference: source.reference, - description: source.description, - notes: source.notes, - languageCode: source.languageCode, - currencyCode: source.currencyCode, - linkedInvoiceId: Maybe.none(), - paymentMethodId: source.paymentMethodId, - paymentTermId: paymentTermResolutionResult.data.paymentTermId, - taxRegimeCode: source.taxRegimeCode, - taxConfig: { - taxMode: source.taxConfig.taxMode, - defaultIvaCode: source.taxConfig.defaultIvaCode, - usesEquivalenceSurcharge: source.taxConfig.usesEquivalenceSurcharge, - defaultRecCode: source.taxConfig.defaultRecCode, - usesRetention: source.taxConfig.usesRetention, - defaultRetentionCode: source.taxConfig.defaultRetentionCode, - }, - globalDiscountPercentage: source.globalDiscountPercentage, - items: source.items.getAll().map((item) => ({ - description: item.description, - quantity: item.quantity, - unitAmount: item.unitAmount, - itemDiscountPercentage: item.itemDiscountPercentage, - taxes: { - ivaCode: item.ivaCode(), - recCode: item.recCode(), - retentionCode: item.retentionCode(), - }, - })), - }; - - const createResult = await this.deps.creator.create({ - companyId: params.companyId, - id: duplicateId, - props, - transaction, - }); - - if (createResult.isFailure) { - return Result.fail(createResult.error); - } - - const duplicatedResult = await this.deps.finder.findProformaById( - params.companyId, - createResult.data.id, - transaction - ); - - if (duplicatedResult.isFailure) { - return Result.fail(duplicatedResult.error); - } + if (duplicatedResult.isFailure) return Result.fail(duplicatedResult.error); const readModelResult = await this.deps.fullReadModelAssembler.assemble({ companyId: params.companyId, - proforma: duplicatedResult.data, + proforma: duplicatedResult.data.proforma, transaction, }); - - if (readModelResult.isFailure) { - return Result.fail(readModelResult.error); - } + if (readModelResult.isFailure) return Result.fail(readModelResult.error); return Result.ok({ ...this.deps.fullSnapshotBuilder.toOutput(readModelResult.data), - payment_term_adaptation: paymentTermResolutionResult.data.adaptation, + payment_term_adaptation: duplicatedResult.data.paymentTermAdaptation, }); } catch (error: unknown) { return Result.fail(error as Error); } }); } - - private async resolveDuplicateSeriesCode(params: { - companyId: UniqueID; - sourceDocumentSeriesId: Maybe; - transaction?: unknown; - }): Promise, Error>> { - const listResult = await this.deps.documentSeriesServices.listActiveSeries({ - companyId: params.companyId.toString(), - documentType: "proforma", - transaction: params.transaction, - }); - - if (listResult.isFailure) { - return Result.fail(listResult.error); - } - - const sourceSeriesId = params.sourceDocumentSeriesId.getOrUndefined(); - const sourceSeries = sourceSeriesId - ? listResult.data.find((series) => series.id === sourceSeriesId) - : undefined; - - if (sourceSeries) { - const seriesCodeResult = InvoiceSerie.create(sourceSeries.code); - if (seriesCodeResult.isFailure) { - return Result.fail(seriesCodeResult.error); - } - - return Result.ok(Maybe.some(seriesCodeResult.data)); - } - - const defaultSeries = listResult.data.find((series) => series.is_default); - if (!defaultSeries) { - return Result.fail( - new InvoiceSeriesNotFoundError({ - cause: "No active proforma document series is available.", - }) - ); - } - - const defaultSeriesCodeResult = InvoiceSerie.create(defaultSeries.code); - if (defaultSeriesCodeResult.isFailure) { - return Result.fail(defaultSeriesCodeResult.error); - } - - return Result.ok(Maybe.some(defaultSeriesCodeResult.data)); - } - - private async resolveValidTargetInvoiceSeriesCode(params: { - companyId: UniqueID; - targetInvoiceSeriesCode: Maybe; - transaction?: unknown; - }): Promise, Error>> { - if (params.targetInvoiceSeriesCode.isNone()) { - return Result.ok(Maybe.none()); - } - - const listResult = await this.deps.documentSeriesServices.listActiveSeries({ - companyId: params.companyId.toString(), - documentType: "issued_invoice", - transaction: params.transaction, - }); - - if (listResult.isFailure) { - return Result.fail(listResult.error); - } - - const targetCode = params.targetInvoiceSeriesCode.unwrap().toPrimitive(); - const isValid = listResult.data.some((series) => series.code === targetCode); - - if (!isValid) { - return Result.ok(Maybe.none()); - } - - return Result.ok(params.targetInvoiceSeriesCode); - } - - private async resolveDuplicatePaymentTermId(params: { - companyId: UniqueID; - sourceCustomerId: UniqueID; - sourcePaymentTermId: Maybe; - transaction?: unknown; - }): Promise< - Result< - { - paymentTermId: Maybe | undefined; - adaptation: DuplicatePaymentTermAdaptation; - }, - Error - > - > { - if (params.sourcePaymentTermId.isNone()) { - return Result.ok({ - paymentTermId: params.sourcePaymentTermId, - adaptation: null, - }); - } - - const activeSourcePaymentTermResult = await this.resolveActivePaymentTermId({ - companyId: params.companyId, - paymentTermId: params.sourcePaymentTermId, - transaction: params.transaction, - }); - - if (activeSourcePaymentTermResult.isFailure) { - return Result.fail(activeSourcePaymentTermResult.error); - } - - if (activeSourcePaymentTermResult.data.isSome()) { - return Result.ok({ - paymentTermId: activeSourcePaymentTermResult.data, - adaptation: null, - }); - } - - const customerResult = await this.deps.customerServices.getCustomerById( - params.sourceCustomerId, - { - companyId: params.companyId, - transaction: params.transaction, - } - ); - - if (customerResult.isFailure) { - return Result.fail(customerResult.error); - } - - const activeCustomerPaymentTermResult = await this.resolveActivePaymentTermId({ - companyId: params.companyId, - paymentTermId: customerResult.data.paymentTermId, - transaction: params.transaction, - }); - - if (activeCustomerPaymentTermResult.isFailure) { - return Result.fail(activeCustomerPaymentTermResult.error); - } - - if (activeCustomerPaymentTermResult.data.isSome()) { - return Result.ok({ - paymentTermId: activeCustomerPaymentTermResult.data, - adaptation: { - source: "inactive_original", - resolution: "customer", - }, - }); - } - - const companyResult = await this.deps.companiesServices.finder.findById({ - companyId: params.companyId, - transaction: params.transaction, - }); - - if (companyResult.isFailure) { - return Result.fail(companyResult.error); - } - - if (companyResult.data.isNone()) { - return Result.fail(new Error("Company not found while duplicating proforma.")); - } - - const companyDefaultPaymentTermIdResult = this.toMaybeUniqueId( - companyResult.data.unwrap().defaultPaymentTermId - ); - - if (companyDefaultPaymentTermIdResult.isFailure) { - return Result.fail( - new Error("Company default payment term is invalid while duplicating proforma.", { - cause: companyDefaultPaymentTermIdResult.error, - }) - ); - } - - const activeCompanyDefaultPaymentTermResult = await this.resolveActivePaymentTermId({ - companyId: params.companyId, - paymentTermId: companyDefaultPaymentTermIdResult.data, - transaction: params.transaction, - }); - - if (activeCompanyDefaultPaymentTermResult.isFailure) { - return Result.fail(activeCompanyDefaultPaymentTermResult.error); - } - - if (activeCompanyDefaultPaymentTermResult.data.isSome()) { - return Result.ok({ - paymentTermId: activeCompanyDefaultPaymentTermResult.data, - adaptation: { - source: "inactive_original", - resolution: "company", - }, - }); - } - - return Result.ok({ - paymentTermId: Maybe.none(), - adaptation: { - source: "inactive_original", - resolution: "none", - }, - }); - } - - private async resolveActivePaymentTermId(params: { - companyId: UniqueID; - paymentTermId: Maybe; - transaction?: unknown; - }): Promise, Error>> { - if (params.paymentTermId.isNone()) { - return Result.ok(Maybe.none()); - } - - const paymentTermResult = await this.deps.paymentTermFinder.findByIdInCompany({ - companyId: params.companyId, - id: params.paymentTermId.unwrap(), - transaction: params.transaction, - }); - - if (paymentTermResult.isFailure) { - return Result.fail(paymentTermResult.error); - } - - if (paymentTermResult.data.isNone()) { - return Result.ok(Maybe.none()); - } - - if (!paymentTermResult.data.unwrap().isActive) { - return Result.ok(Maybe.none()); - } - - return Result.ok(params.paymentTermId); - } - - private toMaybeUniqueId(value: string | null): Result, Error> { - if (value == null) { - return Result.ok(Maybe.none()); - } - - const idResult = UniqueID.create(value); - if (idResult.isFailure) { - return Result.fail(idResult.error); - } - - return Result.ok(Maybe.some(idResult.data)); - } } diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts index 5d47878a..35bd0952 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts @@ -1,25 +1,141 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { DiscountPercentage } from "@erp/core/api"; import type { ITransactionManager } from "@erp/core/api"; -import { CurrencyCode, LanguageCode, UtcDate, UniqueID } from "@repo/rdx-ddd"; +import { DiscountPercentage } from "@erp/core/api"; +import type { IAudit, RecordAuditMutationShortcutInput } from "@repo/rdx-audit"; +import { + City, + Country, + CurrencyCode, + LanguageCode, + Name, + PostalCode, + Province, + Street, + TINNumber, + UniqueID, + UtcDate, +} from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -import type { ProformaToIssuedInvoiceCreateProps } from "../../issued-invoices/services/proforma-to-issued-invoice-props-converter"; -import type { IIssuedInvoicePublicServices } from "../../issued-invoices/services/issued-invoice-public-services.interface"; import { + InvoiceAmount, InvoiceNumber, + InvoicePaymentMethod, + InvoicePaymentTerm, + InvoiceSerie, InvoiceStatus, + InvoiceTaxRegime, + IssuedInvoice, + IssuedInvoiceFiscalClassification, + IssuedInvoiceItems, + IssuedInvoiceRecipient, + IssuedInvoiceTaxes, Proforma, ProformaItems, ProformaTaxConfig, } from "../../../domain"; +import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit"; +import type { IIssuedInvoicePublicServices } from "../../issued-invoices/services/issued-invoice-public-services.interface"; +import type { ProformaToIssuedInvoiceCreateProps } from "../../issued-invoices/services/proforma-to-issued-invoice-props-converter"; import { ProformaIssuer } from "../services/proforma-issuer"; + import { IssueProformaUseCase } from "./issue-proforma.use-case"; type CreateIssuedInvoiceProps = Parameters[1]; +class SuccessAudit implements IAudit { + public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = []; + + async recordMutation(input: RecordAuditMutationShortcutInput) { + this.recordMutationCalls.push(input); + return Result.ok(undefined); + } +} + +const required = (result: Result): T => { + if (result.isFailure) throw result.error; + return result.data; +}; + +const createReloadedIssuedInvoice = (id: UniqueID, companyId: UniqueID): IssuedInvoice => { + const languageCode = required(LanguageCode.create("es")); + const currencyCode = required(CurrencyCode.create("EUR")); + const amount = required(InvoiceAmount.create({ value: 0, currency_code: "EUR" })); + const recipient = required( + IssuedInvoiceRecipient.create({ + name: required(Name.create("Acme")), + tin: required(TINNumber.create("ESB1234567")), + street: required(Street.create("Calle Mayor 1")), + street2: Maybe.none(), + city: required(City.create("Madrid")), + postalCode: required(PostalCode.create("28001")), + province: Maybe.some(required(Province.create("Madrid"))), + country: Maybe.some(required(Country.create("ES"))), + }) + ); + const paymentTerm = required( + InvoicePaymentTerm.create({ + name: "30 días", + description: Maybe.none(), + dueRules: [{ dueDays: "30", percentage: { value: "10000", scale: 2 } }], + }) + ); + const fiscalClassification = required( + IssuedInvoiceFiscalClassification.create({ + taxRegimeCode: "01", + taxRegimeLabel: Maybe.none(), + operationQualificationCode: Maybe.none(), + exemptionReasonCode: Maybe.none(), + }) + ); + return IssuedInvoice.rehydrate( + { + companyId, + status: InvoiceStatus.issued(), + linkedProformaId: UniqueID.generateNewID(), + documentSeriesId: Maybe.none(), + series: required(InvoiceSerie.create("F")), + invoiceNumber: required(InvoiceNumber.create("1")), + invoiceReference: "F-1", + invoiceDate: required(UtcDate.createFromISO("2026-08-17")), + operationDate: Maybe.none(), + customerId: UniqueID.generateNewID(), + recipient, + reference: Maybe.none(), + description: "Factura emitida", + notes: Maybe.none(), + languageCode, + currencyCode, + paymentMethod: required(InvoicePaymentMethod.create({ name: "Transferencia" })), + paymentTerm: Maybe.some(paymentTerm), + taxRegime: required(InvoiceTaxRegime.create({ code: "01", description: "Régimen general" })), + taxes: IssuedInvoiceTaxes.create({ languageCode, currencyCode }), + subtotalAmount: amount, + itemsDiscountAmount: amount, + globalDiscountPercentage: required(DiscountPercentage.create({ value: 0 })), + globalDiscountAmount: amount, + totalDiscountAmount: amount, + taxableAmount: amount, + ivaAmount: amount, + recAmount: amount, + retentionAmount: amount, + taxesAmount: amount, + totalAmount: amount, + fiscalClassification: Maybe.some(fiscalClassification), + verifactu: Maybe.none(), + }, + IssuedInvoiceItems.create({ + items: [], + languageCode, + currencyCode, + globalDiscountPercentage: required(DiscountPercentage.create({ value: 0 })), + }), + id + ); +}; + const buildTransactionManager = (): ITransactionManager => ({ start: async () => undefined, getTransaction: () => undefined, @@ -33,9 +149,7 @@ const buildCreateIssuedInvoiceProps = (): CreateIssuedInvoiceProps => verifactu: Maybe.none(), }) as CreateIssuedInvoiceProps; -const buildIssueableProforma = (params?: { - operationDate?: Maybe; -}) => { +const buildIssueableProforma = (params?: { operationDate?: Maybe }) => { const proformaDateResult = UtcDate.createFromISO("2026-08-16"); const operationDateResult = UtcDate.createFromISO("2026-08-17"); const languageCodeResult = LanguageCode.create("es"); @@ -226,14 +340,18 @@ test("issue falla antes de persistir si la proforma no tiene operation date", as test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async () => { const proforma = buildIssueableProforma(); + const reloadedInvoice = createReloadedIssuedInvoice(UniqueID.generateNewID(), proforma.companyId); + const audit = new SuccessAudit(); let capturedProps: CreateIssuedInvoiceProps | undefined; const useCase = new IssueProformaUseCase({ issuedInvoiceServices: { createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => { capturedProps = props; - return Result.ok({ id: UniqueID.generateNewID() } as never); + return Result.ok(reloadedInvoice); }, + getIssuedInvoiceById: async (id) => + Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)), } as never, finder: { findProformaById: async () => Result.ok(proforma), @@ -270,6 +388,7 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async markAsIssuedByIdInCompany: async () => Result.ok(undefined), } as never, transactionManager: buildTransactionManager(), + audit, }); const result = await useCase.execute({ @@ -277,7 +396,7 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async proformaId: proforma.id.toPrimitive(), }); - assert.equal(result.isSuccess, true); + assert.equal(result.isSuccess, true, result.isFailure ? result.error.message : ""); if (!capturedProps) { assert.fail("Expected createIssuedInvoice to be called"); } @@ -289,14 +408,18 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async () => { const proforma = buildIssueableProforma(); + const reloadedInvoice = createReloadedIssuedInvoice(UniqueID.generateNewID(), proforma.companyId); + const audit = new SuccessAudit(); let capturedProps: CreateIssuedInvoiceProps | undefined; const useCase = new IssueProformaUseCase({ issuedInvoiceServices: { createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => { capturedProps = props; - return Result.ok({ id: UniqueID.generateNewID() } as never); + return Result.ok(reloadedInvoice); }, + getIssuedInvoiceById: async (id) => + Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)), } as never, finder: { findProformaById: async () => Result.ok(proforma), @@ -333,6 +456,7 @@ test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async ( markAsIssuedByIdInCompany: async () => Result.ok(undefined), } as never, transactionManager: buildTransactionManager(), + audit, }); const result = await useCase.execute({ @@ -340,7 +464,7 @@ test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async ( proformaId: proforma.id.toPrimitive(), }); - assert.equal(result.isSuccess, true); + assert.equal(result.isSuccess, true, result.isFailure ? result.error.message : ""); if (!capturedProps) { assert.fail("Expected createIssuedInvoice to be called"); } @@ -405,3 +529,88 @@ test("issue propaga el fallo de persistencia VeriFactu y no marca la proforma co assert.match(result.error.message, /verifactu insert failed/i); assert.equal(markAsIssuedCalls, 0); }); + +test("issue registra issued_invoice.created con el estado final de la factura", async () => { + const proforma = buildIssueableProforma(); + const audit = new SuccessAudit(); + const transaction = {}; + let createdInvoiceId: UniqueID | undefined; + let reloadCalls = 0; + let reloadCompanyId: UniqueID | undefined; + let reloadTransaction: unknown; + let reloadedInvoice: IssuedInvoice | undefined; + const useCase = new IssueProformaUseCase({ + issuedInvoiceServices: { + createIssuedInvoice: async (id) => { + createdInvoiceId = id; + return Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)); + }, + getIssuedInvoiceById: async (id, context) => { + reloadCalls += 1; + reloadCompanyId = context.companyId; + reloadTransaction = context.transaction; + reloadedInvoice = createReloadedIssuedInvoice(id, context.companyId); + return Result.ok(reloadedInvoice); + }, + }, + finder: { findProformaById: async () => Result.ok(proforma) }, + issuer: { + issueProforma: (): Result => + Result.ok(buildCreateIssuedInvoiceProps()), + }, + issueReadModelAssembler: { + assemble: async () => + Result.ok({ + proforma, + paymentMethod: { + id: UniqueID.generateNewID(), + name: "Transferencia", + description: Maybe.none(), + }, + paymentTerm: Maybe.none(), + taxRegime: { code: "01", description: "Régimen general" }, + fiscalClassification: { operationQualificationCode: null, exemptionReasonCode: null }, + usesVerifactu: false, + }), + }, + issueResponseSnapshotBuilder: { toOutput: () => ({ id: "response" }) }, + repository: { markAsIssuedByIdInCompany: async () => Result.ok(undefined) }, + transactionManager: { + start: async () => undefined, + getTransaction: () => transaction, + complete: async (work: (activeTransaction: unknown) => Promise) => work(transaction), + commit: async () => undefined, + rollback: async () => undefined, + }, + audit, + }); + + const result = await useCase.execute({ + companyId: proforma.companyId.toPrimitive(), + proformaId: proforma.id.toPrimitive(), + }); + + assert.equal(result.isSuccess, true); + assert.equal(reloadCalls, 1); + assert.equal(reloadCompanyId?.equals(proforma.companyId), true); + assert.equal(reloadTransaction, transaction); + assert.equal(createdInvoiceId?.equals(reloadedInvoice?.id), true); + assert.equal(audit.recordMutationCalls.length, 1); + const [mutation] = audit.recordMutationCalls; + assert.equal(mutation?.action, "issued_invoice.created"); + assert.deepEqual(mutation?.target, { + type: "issued_invoice", + id: reloadedInvoice?.id.toPrimitive(), + label: reloadedInvoice?.invoiceReference, + }); + assert.deepEqual(mutation?.before, {}); + assert.deepEqual(mutation?.after, toIssuedInvoiceAuditState(reloadedInvoice!)); + assert.equal( + audit.recordMutationCalls.filter(({ action }) => action === "issued_invoice.created").length, + 1 + ); + assert.equal( + audit.recordMutationCalls.filter(({ action }) => action === "proforma.issued").length, + 0 + ); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts index 12b6933c..a6321cf1 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.ts @@ -1,8 +1,10 @@ import type { ITransactionManager } from "@erp/core/api"; +import type { IAudit } from "@repo/rdx-audit"; import { UniqueID } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; +import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit"; import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain"; import type { IssueProformaCommand } from "../commands"; import type { IProformaRepository } from "../repositories"; @@ -26,6 +28,7 @@ export class IssueProformaUseCase { public constructor( private readonly deps: { issuedInvoiceServices: IIssuedInvoicePublicServices; + audit: IAudit; finder: IProformaFinder; issuer: IProformaIssuer; issueReadModelAssembler: IProformaIssueReadModelAssembler; @@ -127,6 +130,31 @@ export class IssueProformaUseCase { return Result.fail(markAsIssuedResult.error); } + const reloadedInvoiceResult = await this.deps.issuedInvoiceServices.getIssuedInvoiceById( + issuedInvoiceId, + { companyId, transaction } + ); + + if (reloadedInvoiceResult.isFailure) { + return Result.fail(reloadedInvoiceResult.error); + } + + const reloadedInvoice = reloadedInvoiceResult.data; + const auditResult = await this.deps.audit.recordMutation({ + action: "issued_invoice.created", + target: { + type: "issued_invoice", + id: reloadedInvoice.id.toPrimitive(), + label: reloadedInvoice.invoiceReference, + }, + before: {}, + after: toIssuedInvoiceAuditState(reloadedInvoice), + }); + + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(this.deps.issueResponseSnapshotBuilder.toOutput(invoiceResult.data)); } catch (error: unknown) { return Result.fail(error as Error); diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma-issue-guards.test.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma-issue-guards.test.ts index 97ed4c05..09850e5c 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma-issue-guards.test.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma-issue-guards.test.ts @@ -11,8 +11,8 @@ import { } from "@repo/rdx-ddd"; import { Maybe } from "@repo/rdx-utils"; -import { InvoiceNumber, InvoiceStatus } from "../../common"; -import { ProformaItems } from "../entities"; +import { InvoiceNumber, InvoiceStatus, ItemDescription } from "../../common"; +import { ProformaItem, ProformaItems } from "../entities"; import { ProformaTaxConfig } from "../value-objects"; import { Proforma } from "./proforma.aggregate"; @@ -24,6 +24,7 @@ const buildApprovedProforma = (params?: { itemsCount?: number; status?: InvoiceStatus; linkedInvoiceId?: Maybe; + items?: ProformaItem[]; }) => { const proformaDateResult = UtcDate.createFromISO("2026-08-16"); const operationDateResult = UtcDate.createFromISO("2026-08-17"); @@ -48,7 +49,8 @@ const buildApprovedProforma = (params?: { assert.equal(discountResult.isSuccess, true); assert.equal(taxConfigResult.isSuccess, true); - const items = Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never); + const items = + params?.items ?? Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never); return Proforma.rehydrate( { @@ -90,6 +92,29 @@ const buildApprovedProforma = (params?: { ); }; +const buildItem = (description?: string): ProformaItem => { + const itemDescription = description ? ItemDescription.create(description) : undefined; + + if (itemDescription) { + assert.equal(itemDescription.isSuccess, true); + } + + const itemResult = ProformaItem.create({ + description: itemDescription ? Maybe.some(itemDescription.data) : Maybe.none(), + quantity: Maybe.none(), + unitAmount: Maybe.none(), + itemDiscountPercentage: Maybe.none(), + taxes: { + iva: Maybe.none(), + rec: Maybe.none(), + retention: Maybe.none(), + }, + }); + + assert.equal(itemResult.isSuccess, true); + return itemResult.data; +}; + test("markAsIssued rechaza proformas sin operationDate", () => { const proforma = buildApprovedProforma({ operationDate: Maybe.none(), @@ -159,3 +184,65 @@ test("getIssuanceBlockers informa already_issued cuando existe linked invoice", ["already_issued"] ); }); + +test("update informa no-op cuando los valores funcionales son iguales", () => { + const proforma = buildApprovedProforma({ itemsCount: 0 }); + + const result = proforma.update({ + description: Maybe.some("Servicio mensual"), + items: [], + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data, false); + assert.equal(proforma.description.unwrap(), "Servicio mensual"); + assert.equal(proforma.items.valued().length, 0); +}); + +test("update informa cambio cuando cambia un campo funcional", () => { + const proforma = buildApprovedProforma({ itemsCount: 0 }); + + const result = proforma.update({ + description: Maybe.some("Servicio actualizado"), + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data, true); + assert.equal(proforma.description.unwrap(), "Servicio actualizado"); +}); + +test("update distingue las transiciones entre null y valor", () => { + const emptyDescription = buildApprovedProforma({ + itemsCount: 0, + description: Maybe.none(), + }); + const sameNull = emptyDescription.update({ description: Maybe.none() }); + const nullToValue = emptyDescription.update({ description: Maybe.some("Servicio") }); + const valueToNull = emptyDescription.update({ description: Maybe.none() }); + + assert.equal(sameNull.data, false); + assert.equal(nullToValue.data, true); + assert.equal(valueToNull.data, true); +}); + +test("update evita reescribir items semanticamente iguales", () => { + const item = buildItem("Linea A"); + const proforma = buildApprovedProforma({ items: [item] }); + + const result = proforma.update({ items: [item.getProps()] }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data, false); + assert.equal(proforma.items.valued().length, 0); +}); + +test("update detecta cambios y reordenaciones de items", () => { + const first = buildItem("Linea A"); + const second = buildItem("Linea B"); + const proforma = buildApprovedProforma({ items: [first, second] }); + + const reordered = proforma.update({ items: [second.getProps(), first.getProps()] }); + + assert.equal(reordered.isSuccess, true); + assert.equal(reordered.data, true); +}); diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index 530cf27c..1176552c 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -1,4 +1,4 @@ -import type { DiscountPercentage } from "@erp/core/api"; +import type { DiscountPercentage, Tax } from "@erp/core/api"; import { AggregateRoot, type CurrencyCode, @@ -41,6 +41,7 @@ import type { IProformaTaxTotals, ProformaCalculationContext } from "../services import { canManuallyTransitionProformaStatus } from "../services"; import { ProformaItemTaxes, + type ProformaItemTaxesProps, type ProformaRecipient, type ProformaTaxConfig, } from "../value-objects"; @@ -214,7 +215,7 @@ export class Proforma extends AggregateRoot implements IP } // Mutabilidad - public update(patchProps: ProformaPatchProps): Result { + public update(patchProps: ProformaPatchProps): Result { const { items, ...otherProps } = patchProps; const definedProps = Object.fromEntries( Object.entries(otherProps).filter(([, value]) => value !== undefined) @@ -232,6 +233,10 @@ export class Proforma extends AggregateRoot implements IP return Result.fail(validationResult.error); } + if (this.hasSameFunctionalState(candidateProps, items)) { + return Result.ok(false); + } + // Aplicar cambios Object.assign(this.props, candidateProps); @@ -244,7 +249,185 @@ export class Proforma extends AggregateRoot implements IP } } - return Result.ok(); + return Result.ok(true); + } + + private hasSameFunctionalState( + candidateProps: ProformaInternalProps, + items: ProformaItemPatchProps[] | undefined + ): boolean { + return this.hasSameProps(candidateProps) && (items === undefined || this.hasSameItems(items)); + } + + private hasSameProps(candidateProps: ProformaInternalProps): boolean { + return ( + this.sameMaybe( + this.documentSeriesId, + candidateProps.documentSeriesId, + (left, right) => left === right + ) && + this.sameMaybe(this.proformaSeriesCode, candidateProps.proformaSeriesCode, (left, right) => + left.equals(right) + ) && + this.sameMaybe(this.proformaNumber, candidateProps.proformaNumber, (left, right) => + left.equals(right) + ) && + this.proformaReference.equals(candidateProps.proformaReference) && + this.sameMaybe( + this.targetInvoiceSeriesCode, + candidateProps.targetInvoiceSeriesCode, + (left, right) => left.equals(right) + ) && + this.proformaDate.equals(candidateProps.proformaDate) && + this.sameMaybe(this.operationDate, candidateProps.operationDate, (left, right) => + left.equals(right) + ) && + this.customerId.equals(candidateProps.customerId) && + this.sameMaybe(this.recipient, candidateProps.recipient, (left, right) => + this.sameRecipient(left, right) + ) && + this.sameMaybe(this.reference, candidateProps.reference, (left, right) => left === right) && + this.sameMaybe( + this.description, + candidateProps.description, + (left, right) => left === right + ) && + this.sameMaybe(this.notes, candidateProps.notes, (left, right) => left.equals(right)) && + this.languageCode.equals(candidateProps.languageCode) && + this.currencyCode.equals(candidateProps.currencyCode) && + this.sameMaybe(this.linkedInvoiceId, candidateProps.linkedInvoiceId, (left, right) => + left.equals(right) + ) && + this.sameMaybe(this.paymentMethodId, candidateProps.paymentMethodId, (left, right) => + left.equals(right) + ) && + this.sameMaybe(this.paymentTermId, candidateProps.paymentTermId, (left, right) => + left.equals(right) + ) && + this.sameMaybe( + this.taxRegimeCode, + candidateProps.taxRegimeCode, + (left, right) => left === right + ) && + this.sameTaxConfig(this.taxConfig, candidateProps.taxConfig) && + this.globalDiscountPercentage.equals(candidateProps.globalDiscountPercentage) + ); + } + + private hasSameItems(items: ProformaItemPatchProps[]): boolean { + const currentItems = this._items.getAll(); + + return ( + currentItems.length === items.length && + currentItems.every((item, index) => this.sameItem(item, items[index])) + ); + } + + private sameItem(item: ProformaItem, candidate: ProformaItemPatchProps): boolean { + return ( + this.sameMaybe(item.description, candidate.description, (left, right) => + left.equals(right) + ) && + this.sameMaybe(item.quantity, candidate.quantity, (left, right) => left.equals(right)) && + this.sameMaybe(item.unitAmount, candidate.unitAmount, (left, right) => left.equals(right)) && + this.sameMaybe(item.itemDiscountPercentage, candidate.itemDiscountPercentage, (left, right) => + left.equals(right) + ) && + this.sameTaxes(item.taxes.getProps(), candidate.taxes) + ); + } + + private sameRecipient(left: ProformaRecipient, right: ProformaRecipient): boolean { + if (left === right) { + return true; + } + + const leftProps = left.getProps(); + const rightProps = right.getProps(); + + return ( + leftProps.name.equals(rightProps.name) && + this.sameMaybe(leftProps.tin, rightProps.tin, (current, candidate) => + current.equals(candidate) + ) && + this.sameMaybe(leftProps.street, rightProps.street, (current, candidate) => + current.equals(candidate) + ) && + this.sameMaybe(leftProps.street2, rightProps.street2, (current, candidate) => + current.equals(candidate) + ) && + this.sameMaybe(leftProps.city, rightProps.city, (current, candidate) => + current.equals(candidate) + ) && + this.sameMaybe(leftProps.postalCode, rightProps.postalCode, (current, candidate) => + current.equals(candidate) + ) && + this.sameMaybe(leftProps.province, rightProps.province, (current, candidate) => + current.equals(candidate) + ) && + this.sameMaybe(leftProps.country, rightProps.country, (current, candidate) => + current.equals(candidate) + ) + ); + } + + private sameTaxConfig(left: ProformaTaxConfig, right: ProformaTaxConfig): boolean { + return ( + left.taxMode === right.taxMode && + this.sameMaybe( + left.defaultIvaCode, + right.defaultIvaCode, + (current, candidate) => current === candidate + ) && + left.usesEquivalenceSurcharge === right.usesEquivalenceSurcharge && + this.sameMaybe( + left.defaultRecCode, + right.defaultRecCode, + (current, candidate) => current === candidate + ) && + left.usesRetention === right.usesRetention && + this.sameMaybe( + left.defaultRetentionCode, + right.defaultRetentionCode, + (current, candidate) => current === candidate + ) + ); + } + + private sameTaxes(left: ProformaItemTaxesProps, right: ProformaItemTaxesProps): boolean { + return ( + this.sameMaybe(left.iva, right.iva, (current, candidate) => + this.sameTax(current, candidate) + ) && + this.sameMaybe(left.rec, right.rec, (current, candidate) => + this.sameTax(current, candidate) + ) && + this.sameMaybe(left.retention, right.retention, (current, candidate) => + this.sameTax(current, candidate) + ) + ); + } + + private sameTax(left: Tax, right: Tax): boolean { + return ( + left.code === right.code && + left.name === right.name && + left.rate.equals(right.rate) && + left.group === right.group && + left.calculationBehavior === right.calculationBehavior + ); + } + + private sameMaybe( + left: Maybe, + right: Maybe, + equals: (current: T, candidate: T) => boolean + ): boolean { + if (left.isNone() || right.isNone()) { + return left.isNone() && right.isNone(); + } + + return equals(left.unwrap(), right.unwrap()); } private initializeItems( diff --git a/modules/customer-invoices/src/api/domain/proformas/testing/proforma-test-fixture.test.ts b/modules/customer-invoices/src/api/domain/proformas/testing/proforma-test-fixture.test.ts new file mode 100644 index 00000000..1dd6bddf --- /dev/null +++ b/modules/customer-invoices/src/api/domain/proformas/testing/proforma-test-fixture.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Proforma } from "../aggregates/proforma.aggregate"; +import { ProformaRecipient } from "../value-objects"; + +import { createProformaTestFixture } from "./proforma-test-fixture"; + +test("createProformaTestFixture construye un recipient real", () => { + const recipient = createProformaTestFixture().recipient.unwrap(); + + assert.equal(recipient instanceof ProformaRecipient, true); + assert.equal(recipient.tin.unwrap().toPrimitive(), "ESB1234567"); +}); + +test("createProformaTestFixture construye una Proforma real", () => { + const proforma = createProformaTestFixture(); + + assert.equal(proforma instanceof Proforma, true); + assert.equal(proforma.id.toPrimitive(), "01900000-0000-7000-8000-000000000004"); + assert.equal(proforma.description.unwrap(), "Descripción"); + assert.equal(proforma.recipient.isSome(), true); + assert.equal(proforma.status.toPrimitive(), "draft"); + assert.equal(proforma.currencyCode.toPrimitive(), "EUR"); + assert.equal(proforma.items.getAll().length, 0); + assert.equal(proforma.taxConfig.taxMode, "single"); +}); diff --git a/modules/customer-invoices/src/api/domain/proformas/testing/proforma-test-fixture.ts b/modules/customer-invoices/src/api/domain/proformas/testing/proforma-test-fixture.ts new file mode 100644 index 00000000..22665478 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/proformas/testing/proforma-test-fixture.ts @@ -0,0 +1,97 @@ +import { DiscountPercentage } from "@erp/core/api"; +import { + City, + Country, + CurrencyCode, + LanguageCode, + Name, + PostalCode, + Province, + Street, + TINNumber, + UniqueID, + UtcDate, +} from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { InvoiceNumber, InvoiceStatus } from "../../common"; +import { Proforma } from "../aggregates/proforma.aggregate"; +import type { IProformaItemCreateProps } from "../entities"; +import { ProformaRecipient, ProformaTaxConfig } from "../value-objects"; + +type ProformaTestFixtureOverrides = { + status?: InvoiceStatus; + archivedAt?: Maybe; + description?: string | null; + customerId?: UniqueID; + recipient?: Maybe; + paymentTermId?: Maybe; + taxConfig?: ProformaTaxConfig; + items?: IProformaItemCreateProps[]; + globalDiscount?: DiscountPercentage; +}; + +const required = (result: { data: T }): T => result.data; + +export const createProformaTestFixture = ( + overrides: ProformaTestFixtureOverrides = {} +): Proforma => { + const recipient = required( + ProformaRecipient.create({ + name: required(Name.create("Cliente de prueba")), + tin: Maybe.some(required(TINNumber.create("ESB1234567"))), + street: Maybe.some(required(Street.create("Calle Uno 1"))), + street2: Maybe.some(required(Street.create("Piso 2"))), + city: Maybe.some(required(City.create("Madrid"))), + postalCode: Maybe.some(required(PostalCode.create("28001"))), + province: Maybe.some(required(Province.create("Madrid"))), + country: Maybe.some(required(Country.create("ES"))), + }) + ); + const taxConfig = required( + ProformaTaxConfig.create({ + taxMode: "single", + defaultIvaCode: Maybe.some("iva_21"), + usesEquivalenceSurcharge: false, + defaultRecCode: Maybe.none(), + usesRetention: false, + defaultRetentionCode: Maybe.none(), + }) + ); + const result = Proforma.create( + { + companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000001")), + status: overrides.status ?? required(InvoiceStatus.create("draft")), + archivedAt: overrides.archivedAt ?? Maybe.none(), + documentSeriesId: Maybe.some("01900000-0000-7000-8000-000000000002"), + proformaSeriesCode: Maybe.none(), + proformaNumber: Maybe.none(), + proformaReference: required(InvoiceNumber.create("PF-0042")), + targetInvoiceSeriesCode: Maybe.none(), + proformaDate: required(UtcDate.createFromISO("2026-08-25")), + operationDate: Maybe.none(), + customerId: + overrides.customerId ?? required(UniqueID.create("01900000-0000-7000-8000-000000000003")), + recipient: overrides.recipient ?? Maybe.some(recipient), + reference: Maybe.some("Referencia"), + description: + overrides.description === null + ? Maybe.none() + : Maybe.some(overrides.description ?? "Descripción"), + notes: Maybe.none(), + languageCode: required(LanguageCode.create("es")), + currencyCode: required(CurrencyCode.create("EUR")), + linkedInvoiceId: Maybe.none(), + paymentMethodId: Maybe.none(), + paymentTermId: overrides.paymentTermId ?? Maybe.none(), + taxRegimeCode: Maybe.some("01"), + taxConfig: overrides.taxConfig ?? taxConfig, + items: overrides.items ?? [], + globalDiscountPercentage: + overrides.globalDiscount ?? required(DiscountPercentage.create({ value: 0 })), + }, + required(UniqueID.create("01900000-0000-7000-8000-000000000004")) + ); + + return required(result); +}; diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts index aa35e5c4..fdaccf0e 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts @@ -86,5 +86,7 @@ export function buildIssuedInvoicePublicServices( return Result.ok(createResult.data); }, + getIssuedInvoiceById: async (id: UniqueID, context: IIssuedInvoiceServicesContext) => + repository.getByIdInCompany(context.companyId, id, context.transaction), }; } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts index 94c49a77..6257e525 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts @@ -16,12 +16,12 @@ import { buildProformaSnapshotBuilders, } from "../../../application"; import type { Proforma } from "../../../domain"; +import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator"; +import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator"; import { buildV2ProformaPersistence } from "./proforma-persistence.di"; import type { ProformasInternalDeps } from "./proformas.di"; import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di"; -import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator"; -import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator"; type ProformaServicesContext = { transaction: Transaction; @@ -83,9 +83,7 @@ export function buildProformaPublicServices( }); const snapshotBuilders = buildProformaSnapshotBuilders(); - const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator( - documentSeriesServices - ); + const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(documentSeriesServices); const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator( documentSeriesServices ); @@ -99,6 +97,7 @@ export function buildProformaPublicServices( targetInvoiceSeriesValidator, companiesServices, customerServices, + audit: params.audit, }); return { diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index b2376602..e69354b5 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -15,6 +15,7 @@ import { type IssueProformaUseCase, type ListProformasUseCase, type PreviewProformaReportUseCase, + ProformaIssuanceReadinessChecker, type ReportProformaPdfUseCase, type UnarchiveProformaByIdUseCase, type UpdateProformaByIdUseCase, @@ -32,10 +33,10 @@ import { buildProformaCatalogResolvers, buildProformaCreator, buildProformaDeleter, + buildProformaDuplicator, buildProformaFinder, buildProformaInputMappers, buildProformaIssueReadModelAssembler, - ProformaIssuanceReadinessChecker, buildProformaIssuer, buildProformaReadModelAssemblers, buildProformaSnapshotBuilders, @@ -133,6 +134,20 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter targetInvoiceSeriesValidator, companiesServices, customerServices, + audit: params.audit, + }); + const duplicator = buildProformaDuplicator({ + repository, + taxResolver: catalogResolvers.taxResolver, + paymentResolver: catalogResolvers.paymentResolver, + numberService: proformaNumberService, + proformaSeriesValidator, + targetInvoiceSeriesValidator, + companiesServices, + customerServices, + documentSeriesServices, + paymentTermFinder: catalogs.paymentTerm.finder, + audit: params.audit, }); const updater = buildProformaUpdater({ @@ -141,16 +156,20 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter paymentResolver: catalogResolvers.paymentResolver, targetInvoiceSeriesValidator, customerServices, + audit: params.audit, }); const deleter = buildProformaDeleter({ repository, + audit: params.audit, }); const archiver = buildProformaArchiver({ repository, + audit: params.audit, }); const statusChanger = buildProformaStatusChanger({ repository, + audit: params.audit, }); const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter(); @@ -212,14 +231,10 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter duplicateProforma: () => buildDuplicateProformaByIdUseCase({ - creator, + duplicator, finder, fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, - documentSeriesServices, - companiesServices, - customerServices, - paymentTermFinder: catalogs.paymentTerm.finder, transactionManager, }), @@ -278,7 +293,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter transactionManager, }), - issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) => + issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) => buildIssueProformaUseCase({ publicServices, finder, @@ -287,6 +302,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter issueResponseSnapshotBuilder: snapshotBuilders.issue, repository, transactionManager, + audit: params.audit, }), changeStatusProforma: () => diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts index 49c7328f..6f03538e 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts @@ -18,21 +18,21 @@ import { type FindOptions, type Includeable, type InferAttributes, - col, - fn, Op, type OrderItem, type Sequelize, type Transaction, type WhereOptions, + col, + fn, } from "sequelize"; import type { IProformaRepository, + ProformaListScope, ProformaStatusCounts, ProformaSummary, } from "../../../../../application"; -import type { ProformaListScope } from "../../../../../application"; import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain"; import { IssuedInvoiceModel, @@ -41,6 +41,7 @@ import { ProformaTaxModel, } from "../../../../common"; import type { SequelizeProformaV2DomainMapper, SequelizeProformaV2SummaryMapper } from "../mappers"; + import { buildProformaQuickSearchWhere } from "./proforma-quick-search"; export class SequelizeProformaRepositoryV2 @@ -86,15 +87,10 @@ export class SequelizeProformaRepositoryV2 if (dtoResult.isFailure) return Result.fail(dtoResult.error); const dto = dtoResult.data; const { id, items, taxes, ...updatePayload } = dto; - const [affectedCount] = await ProformaModel.update(updatePayload, { + await ProformaModel.update(updatePayload, { where: { id }, transaction, }); - if (affectedCount === 0) { - return Result.fail( - new InfrastructureRepositoryError(`Proforma ${id} not found or concurrency issue`) - ); - } await ProformaItemModel.destroy({ where: { proforma_id: id }, transaction }); await ProformaTaxModel.destroy({ where: { proforma_id: id }, transaction }); if (Array.isArray(taxes) && taxes.length > 0) { @@ -654,8 +650,7 @@ export class SequelizeProformaRepositoryV2 } } - counts.all = - counts.draft + counts.sent + counts.approved + counts.rejected + counts.issued; + counts.all = counts.draft + counts.sent + counts.approved + counts.rejected + counts.issued; return Result.ok(counts); } catch (err: unknown) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b36074b..0147188f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -783,6 +783,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