feat: add proforma duplicator service and related tests

- Implemented ProformaDuplicator service for duplicating proforma documents.
- Added tests for duplicating proformas, including scenarios for successful duplication, audit failure propagation, and payment term adaptations.
- Created ProformaStatusChanger service to handle status changes of proformas with corresponding tests.
- Developed ProformaArchiver service for unarchiving proformas and added tests to verify functionality.
- Introduced a test fixture for creating proforma instances for testing purposes.
This commit is contained in:
David Arranz 2026-08-26 13:03:39 +02:00
parent 7033d02a69
commit e0ba5ef229
57 changed files with 5825 additions and 1469 deletions

View File

@ -96,7 +96,8 @@ No introducir capas, adaptadores, factories, genéricos, eventos, contenedores D
## Comentarios y documentación ## Comentarios y documentación
Los comentarios deben explicar: ### Los comentarios
Deben explicar:
- por qué existe una decisión; - por qué existe una decisión;
- qué riesgo evita; - 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. 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: Reglas de estilo para documentación:

View File

@ -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<ReturnType<typeof createHttpTestRuntime>>
) {
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<ReturnType<typeof createHttpTestRuntime>>
) {
const documentSeriesServices = getServiceScoped<DocumentSeriesPublicServicesType>(
"customer-invoices",
["document-series"],
"document-series:general"
);
const customerServices = getServiceScoped<ICustomerPublicServices>(
"customer-invoices",
["customers"],
"customers:general"
);
const companiesServices = getServiceScoped<ICompanyPublicServices>(
"customer-invoices",
["companies"],
"companies:general"
);
const taxDefinitions = getServiceScoped<CatalogsPublicServicesType["taxDefinitions"]>(
"customer-invoices",
["catalogs"],
"catalogs:taxDefinitions"
);
const taxRegimes = getServiceScoped<CatalogsPublicServicesType["taxRegimes"]>(
"customer-invoices",
["catalogs"],
"catalogs:taxRegimes"
);
const paymentMethods = getServiceScoped<CatalogsPublicServicesType["paymentMethods"]>(
"customer-invoices",
["catalogs"],
"catalogs:paymentMethods"
);
const paymentTerms = getServiceScoped<CatalogsPublicServicesType["paymentTerms"]>(
"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<ReturnType<typeof createHttpTestRuntime>>) {
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<typeof target.create>) => {
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
);
});
}

View File

@ -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. - Empieza por este índice si necesitas orientarte.
- Usa los documentos en subdirectorios temáticos como fuente de verdad operativa. - 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. - 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 ## 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 y runtime
- [Arquitectura general](./architecture/README.md) - [Arquitectura general](./architecture/README.md)
- [Requisitos de `companies`](./companies/company-requirements.md)
- [Contexto tenant y `X-Company-Id`](./architecture/tenant-context.md) - [Contexto tenant y `X-Company-Id`](./architecture/tenant-context.md)
- [Tratamiento de errores HTTP en frontend](./architecture/http-error-handling-frontend.md) - [Tratamiento de errores HTTP en frontend](./architecture/http-error-handling-frontend.md)
- [API de autenticación de `identity`](./architecture/identity-auth-api.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 #### Customer invoices y proformas
- [Índice de `customer-invoices`](./customer-invoices/README.md) - [Índice de `customer-invoices`](./customer-invoices/README.md)
- [Auditoría SSOT documental](./documentation-ssot-audit.md)
- [Plazos de pago](./catalogs/payment-terms.md) - [Plazos de pago](./catalogs/payment-terms.md)
- [Diseño backend del perfil VeriFactu empresarial](./customer-invoices/settings/verifactu-company-profile-backend-design.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) - [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 series de proformas](./customer-invoices/proforma-series-contract.md)
- [Contrato de create de proformas](./customer-invoices/proforma-create-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 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. - 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. - 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`.

View File

@ -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&lt;Result<void, AuditError&gt;>;
recordEvent(input: RecordAuditEventShortcutInput): Promise&lt;Result<void, AuditError&gt;>;
recordMutation(input: RecordAuditMutationShortcutInput): Promise&lt;Result<void, AuditError&gt;>;
}
<br/>export interface IAuditStore {
append(entry: AuditEntry): Promise&lt;Result<void, AuditStoreError&gt;>;
}
\`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;
}
<br/>export interface RecordAuditMutationInput {
readonly kind: "mutation";
readonly action: string;
readonly target: AuditMutationTarget;
readonly before: AuditState;
readonly after: AuditState;
readonly atomicPaths?: readonly string\[\];
}
<br/>export type RecordAuditInput =
| RecordAuditEventInput
| RecordAuditMutationInput;
## 5.3 Contratos estructurales
export interface AuditSource {
readonly type: string;
readonly id: string;
}
<br/>export interface AuditOperation {
readonly id: string;
readonly correlationId: string;
readonly origin: string;
}
<br/>export interface AuditActor {
readonly type: string;
readonly id: string | null;
readonly label: string | null;
}
<br/>export interface AuditScope {
readonly type: string;
readonly id: string;
readonly label: string | null;
}
<br/>export interface AuditEventTarget {
readonly type: string;
readonly id: string | null;
readonly label: string | null;
}
<br/>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&lt;{ \[key: string\]: AuditValue }&gt;;
export type AuditArray = readonly AuditValue\[\];
export type AuditValue = AuditScalar | AuditObject | AuditArray;
export type AuditState = Readonly&lt;Record<string, AuditValue&gt;>;
- 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;
}
<br/>export interface AuditEventEntry extends AuditEntryBase {
readonly kind: "event";
readonly target: AuditEventTarget;
readonly changes: readonly \[\];
}
<br/>export interface AuditMutationEntry extends AuditEntryBase {
readonly kind: "mutation";
readonly target: AuditMutationTarget;
readonly changes: readonly AuditChange\[\];
}
<br/>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;
}
<br/>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&lt;T&gt;(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&lt;Result<void, AuditStoreError&gt;>;
}
- 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&lt;Result<AuditSearchResult, Error&gt;>;
<br/>findById(
id: string,
access: AuditReadAccess,
): Promise&lt;Result<AuditEntry | null, Error&gt;>;
}
## 14.1 \`AuditReadAccess\`
type AuditReadAccess =
| Readonly&lt;{ mode: "unrestricted" }&gt;
| Readonly<{
mode: "scoped";
scopes: readonly AuditScopeIdentity\[\];
includeUnscoped: boolean;
}>;
<br/>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,
);
<br/>if (customerResult.isFailure) {
return customerResult;
}
<br/>const customer = customerResult.value;
const before = toCustomerAuditState(customer);
<br/>const updateResult = customer.update(input);
if (updateResult.isFailure) {
return updateResult;
}
<br/>const saveResult = await this.customerRepository.save(
customer,
transaction,
);
<br/>if (saveResult.isFailure) {
return saveResult;
}
<br/>const auditResult = await this.audit.recordMutation({
action: "customer.updated",
target: toCustomerAuditTarget(customer),
before,
after: toCustomerAuditState(customer),
});
<br/>if (auditResult.isFailure) {
return auditResult;
}
<br/>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": "<admin@example.es>"
},
"scope": {
"type": "company",
"id": "C1",
"label": "Empresa A"
},
"action": "customer.updated",
"target": {
"type": "customer",
"id": "CU1",
"label": "ACME"
},
"changes": \[
{
"path": "email",
"kind": "change",
"before": "<old@acme.es>",
"after": "<new@acme.es>"
}
\]
}

View File

@ -141,11 +141,38 @@ Normalmente no se auditan validation failure, not found, rechazo de dominio, cro
| supplier | `supplier.updated` | mutation | Implementada | | supplier | `supplier.updated` | mutation | Implementada |
| supplier | `supplier.activated` | mutation | Implementada | | supplier | `supplier.activated` | mutation | Implementada |
| supplier | `supplier.deactivated` | 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. 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. 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. 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 ## Reader, autorización y HTTP

View File

@ -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 <access_token>
X-Company-Id: <uuid-company>
```
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.

View File

@ -1,13 +1,25 @@
# customer-invoices # 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 ### Contratos funcionales y de transporte
- [Plazos de pago](../catalogs/payment-terms.md) - [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 series de proformas](./proforma-series-contract.md)
- [Contrato de create de proformas](./proforma-create-contract.md) - [Contrato de create de proformas](./proforma-create-contract.md)
- [Contrato de readiness de emisión de proformas](./proforma-issuance-readiness-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 series de issued invoices](./issued-invoice-series-contract.md)
- [Contrato de snapshot documental](./document-series-snapshot-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 y flujos integrados
- [Reports de proformas](./proformas-reports.md) - [Reports de proformas](./proformas-reports.md)
@ -28,7 +45,7 @@
- [Índice SQL de `customer-invoices`](./sql/README.md) - [Í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) - [Split Proformas / Issued Invoices](./split-proformas-issued-invoices-migration.md)
- [InvoiceSeries legacy](./invoice-series.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 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 SQL manual o runbooks, usa `sql/README.md`.
- Para decisiones antiguas, migraciones o trazabilidad, consulta solo la sección histórica. - 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`.

View File

@ -2,7 +2,7 @@
## 1. Estado del documento ## 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`. 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. - El estado de configuración es explícito.
- Se permite `obligated_without_verifactu` como estado real. - 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. - 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: - `IssuedInvoice` no incorpora actualmente `verifactuPolicy` como requisito vigente.
- clasificación fiscal efectiva; - La integración vigente de `IssuedInvoice` con VeriFactu es:
- política VeriFactu efectiva. - 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. - `VerifactuRecord` mantiene responsabilidad operativa y no absorbe el perfil empresarial.
- El perfil no expone secretos ni passwords de certificados. - 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 ## 2. Propósito
Proporcionar una fuente de verdad empresarial para: Proporcionar una fuente de verdad empresarial para:
@ -30,7 +46,7 @@ Proporcionar una fuente de verdad empresarial para:
- fecha efectiva de activación; - fecha efectiva de activación;
- renovación automática si aplica; - renovación automática si aplica;
- defaults de clasificación fiscal; - 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: 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. 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 La propuesta anterior de añadir un snapshot `verifactuPolicy` a `IssuedInvoice`
export interface IssuedInvoiceVerifactuPolicySnapshot { queda superada y no forma parte del SSOT vigente.
profileStatus: CompanyVerifactuProfileStatus;
usesVerifactu: boolean;
effectiveAt: string | null;
}
```
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 ## 13. Resolución durante la emisión
@ -275,8 +297,7 @@ ProformaIssueReadModelAssembler
proforma-to-issued-invoice-props-converter proforma-to-issued-invoice-props-converter
│ │
├── resuelve precedencia ├── resuelve precedencia
├── construye FiscalClassificationSnapshot └── construye FiscalClassificationSnapshot
└── construye VerifactuPolicySnapshot
│ │
▼ ▼
IssuedInvoice.create(...) IssuedInvoice.create(...)
@ -287,7 +308,7 @@ IssuedInvoice.create(...)
`IssuedInvoice`: `IssuedInvoice`:
- snapshot documental e histórico; - snapshot documental e histórico;
- clasificación fiscal aplicada; - clasificación fiscal aplicada;
- política VeriFactu aplicada. - relación documental con el contexto de emisión.
`VerifactuRecord`: `VerifactuRecord`:
- estado operativo; - estado operativo;
@ -295,9 +316,15 @@ IssuedInvoice.create(...)
- QR; - QR;
- UUID; - UUID;
- tipo de operación. - tipo de operación.
- histórico operativo futuro.
No debe contener defaults empresariales ni obligación/exención. 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 ## 15. Certificados
El perfil no contiene: El perfil no contiene:
@ -434,7 +461,14 @@ DDL operativo vigente:
```txt ```txt
docs/customer-invoices/sql/add-company-verifactu-profiles.sql 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/add-issued-invoice-verifactu-snapshots.sql
docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql
``` ```
## 22. Concurrencia ## 22. Concurrencia
@ -454,12 +488,11 @@ Orden recomendado:
```txt ```txt
1. ejecutar DDL de `company_verifactu_profiles` 1. ejecutar DDL de `company_verifactu_profiles`
2. ejecutar DDL de snapshots VeriFactu en `issued_invoices` 2. persistencia y GET seguro
3. persistencia y GET seguro 3. PUT/configuración
4. PUT/configuración 4. UI Settings
5. UI Settings 5. uso de defaults en emisión
6. uso de defaults en emisión 6. enforcement posterior
7. enforcement posterior
``` ```
La ausencia de perfil no debe romper automáticamente la emisión actual durante transición. 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` ## 28. Integración en `IssuedInvoice`
Implementado en Fase B: Estado documental vigente:
```txt ```txt
CompanyVerifactuProfile CompanyVerifactuProfile
→ ProformaIssueReadModelAssembler → configuración empresarial
→ resolución backend de clasificación/política → resolución backend de clasificación fiscal cuando aplique
→ ProformaToIssuedInvoiceConverter → IssuedInvoice / emisión
→ IssuedInvoice → VerifactuRecord operativo
→ persistencia física en issued_invoices
``` ```
`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 ```txt
fiscalClassification
verifactuPolicy verifactuPolicy
``` ```
Decisiones reales: Sí forma parte del SSOT actual:
```txt
fiscalClassification
```
Decisiones vigentes:
- `taxRegimeCode` se resuelve con precedencia `document -> customer -> company -> sin valor`. - `taxRegimeCode` se resuelve con precedencia `document -> customer -> company -> sin valor`.
- `operationQualificationCode` se resuelve hoy desde defaults empresariales o `null`. - `operationQualificationCode` se resuelve hoy desde defaults empresariales o `null`.
- `exemptionReasonCode` 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. - 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 ## 29. Tests implementados
@ -591,7 +632,7 @@ Use cases / controllers:
Fase B: Fase B:
- precedencia `document -> customer -> company` para `taxRegimeCode`; - precedencia `document -> customer -> company` para `taxRegimeCode`;
- fallback legacy sin perfil; - fallback legacy sin perfil;
- validación de `IssuedInvoiceVerifactuPolicy`. - separación explícita entre perfil empresarial y estado operativo VeriFactu.
## 30. Criterios de aceptación ## 30. Criterios de aceptación
@ -606,7 +647,9 @@ Fase B:
9. `VerifactuRecord` sigue siendo operativo. 9. `VerifactuRecord` sigue siendo operativo.
10. No se exponen secretos. 10. No se exponen secretos.
11. Empresas sin perfil se leen como `not_configured`. 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 ## 31. Regla mental final
@ -618,10 +661,12 @@ CompanyVerifactuProfile
→ política/defaults empresariales → política/defaults empresariales
IssuedInvoice IssuedInvoice
→ snapshot histórico efectivo → snapshot fiscal histórico efectivo cuando aplique
→ relación actual 1 -> 0..1 con VerifactuRecord
VerifactuRecord VerifactuRecord
→ estado operativo → estado operativo
→ futuro histórico 0..N
AEAT integration AEAT integration
→ lifecycle futuro → lifecycle futuro

View File

@ -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/customer-invoices/proforma-tax-config.md`
- `docs/catalogs/payment-terms.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: 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 Estado del backend: persistencia V2 activada; integracion con `document-series` activa para numeracion nueva; sin ejecucion confirmada de migracion DB

View File

@ -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 `customer_invoice_taxes` no forman parte del runtime actual. Solo pueden aparecer
en SQL historico de migracion o validacion. 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 ## SQL vigentes
- `add-company-verifactu-profiles.sql` - `add-company-verifactu-profiles.sql`
- `add-issued-invoice-verifactu-snapshots.sql`
- `add-proforma-document-series-columns.sql` - `add-proforma-document-series-columns.sql`
- `add-proforma-tax-config-columns.sql` - `add-proforma-tax-config-columns.sql`
- `add-issued-invoice-reference.sql` - `add-issued-invoice-reference.sql`
@ -22,6 +28,8 @@ en SQL historico de migracion o validacion.
## SQL históricos / transicionales ## SQL históricos / transicionales
- `add-issued-invoice-verifactu-snapshots.sql`
- `drop-issued-invoice-verifactu-snapshot-columns.sql`
- `dev-split-proformas-issued-invoices.sql` - `dev-split-proformas-issued-invoices.sql`
- `validate-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. 1. Verificar que la BD objetivo es inequívocamente de desarrollo.
2. Hacer backup o confirmar que los datos son descartables. 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. 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`. 4. Ejecutar el DDL vigente que falte para `proformas`.
5. Ejecutar el DDL vigente que falte para `proformas`. 5. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado.
6. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado. 6. Ejecutar las validaciones SQL vigentes.
7. Ejecutar las validaciones SQL vigentes. 7. Levantar backend V2 y validar flujos funcionales.
8. 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 ## Propiedades de los scripts históricos de split

View File

@ -1,6 +1,9 @@
-- DDL vigente para snapshots fiscales y de politica VeriFactu en issued_invoices. -- Estado documental: SUPERSEDED / solo auditoria legacy.
-- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado. -- Este script responde a una propuesta antigua que reintroducia
-- No ejecutar automaticamente en produccion. -- `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(); SET @target_schema = DATABASE();

View File

@ -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. -- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado.
-- No ejecutar automaticamente en produccion. -- No ejecutar automaticamente en produccion.

View File

@ -112,6 +112,9 @@ WHERE ii.id IS NULL;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Verifactu -- 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 SELECT COUNT(*) AS orphan_verifactu_records
FROM verifactu_records vr FROM verifactu_records vr

View File

@ -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.

View File

@ -49,6 +49,7 @@
"@lglab/react-qr-code": "^1.4.10", "@lglab/react-qr-code": "^1.4.10",
"@repo/i18next": "workspace:*", "@repo/i18next": "workspace:*",
"@repo/rdx-criteria": "workspace:*", "@repo/rdx-criteria": "workspace:*",
"@repo/rdx-audit": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-logger": "workspace:*", "@repo/rdx-logger": "workspace:*",
"@repo/rdx-ui": "workspace:*", "@repo/rdx-ui": "workspace:*",

View File

@ -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 = <T>(result: Result<T, Error>): 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);
});

View File

@ -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 = <T extends { toPrimitive(): string }>(value: {
match: <R>(some: (item: T) => R, none: () => R) => R;
}): string | null =>
value.match(
(item) => item.toPrimitive(),
() => null
);
const nullableString = (value: {
match: <R>(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: <R>(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: <R>(
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;

View File

@ -16,4 +16,8 @@ export interface IIssuedInvoicePublicServices {
props: IIssuedInvoiceCreatorParams["props"], props: IIssuedInvoiceCreatorParams["props"],
context: IIssuedInvoiceServicesContext context: IIssuedInvoiceServicesContext
) => Promise<Result<IssuedInvoice, Error>>; ) => Promise<Result<IssuedInvoice, Error>>;
getIssuedInvoiceById: (
id: UniqueID,
context: IIssuedInvoiceServicesContext
) => Promise<Result<IssuedInvoice, Error>>;
} }

View File

@ -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 = <T>(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);
});

View File

@ -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 = <T extends { toPrimitive(): string }>(value: {
match: <R>(some: (item: T) => R, none: () => R) => R;
}): string | null =>
value.match(
(item) => item.toPrimitive(),
() => null
);
const toNullableString = (value: {
match: <R>(some: (item: string) => R, none: () => R) => R;
}): string | null =>
value.match(
(item) => item,
() => null
);
const toNullableScaledString = (value: {
match: <R>(some: (item: { value: number; scale: number }) => R, none: () => R) => R;
}): string | null =>
value.match(
(item) => `${item.value}:${item.scale}`,
() => null
);
const toNullableMoneyString = (value: {
match: <R>(
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(),
});

View File

@ -2,6 +2,7 @@ export * from "./proforma-archiver.di";
export * from "./proforma-catalog-resolvers.di"; export * from "./proforma-catalog-resolvers.di";
export * from "./proforma-creator.di"; export * from "./proforma-creator.di";
export * from "./proforma-deleter.di"; export * from "./proforma-deleter.di";
export * from "./proforma-duplicator.di";
export * from "./proforma-finder.di"; export * from "./proforma-finder.di";
export * from "./proforma-input-mappers.di"; export * from "./proforma-input-mappers.di";
export * from "./proforma-issuer.di"; export * from "./proforma-issuer.di";

View File

@ -1,10 +1,14 @@
import type { IAudit } from "@repo/rdx-audit";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import { type IProformaArchiver, ProformaArchiver } from "../services"; import { type IProformaArchiver, ProformaArchiver } from "../services";
export function buildProformaArchiver(deps: { export function buildProformaArchiver(deps: {
repository: IProformaRepository; repository: IProformaRepository;
audit: IAudit;
}): IProformaArchiver { }): IProformaArchiver {
return new ProformaArchiver({ return new ProformaArchiver({
repository: deps.repository, repository: deps.repository,
audit: deps.audit,
}); });
} }

View File

@ -1,5 +1,6 @@
import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ICompanyPublicServices } from "@erp/companies/api";
import type { ICustomerPublicServices } from "@erp/customers/api"; import type { ICustomerPublicServices } from "@erp/customers/api";
import type { IAudit } from "@repo/rdx-audit";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { ProformaPaymentResolver } from "../services"; import type { ProformaPaymentResolver } from "../services";
@ -12,7 +13,7 @@ import {
type ProformaTaxResolver, type ProformaTaxResolver,
} from "../services"; } from "../services";
export const buildProformaCreator = (params: { export const buildProformaCreator = (deps: {
taxResolver: ProformaTaxResolver; taxResolver: ProformaTaxResolver;
paymentResolver: ProformaPaymentResolver; paymentResolver: ProformaPaymentResolver;
numberService: IProformaNumberGenerator; numberService: IProformaNumberGenerator;
@ -21,6 +22,7 @@ export const buildProformaCreator = (params: {
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
companiesServices: ICompanyPublicServices; companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices; customerServices: ICustomerPublicServices;
audit: IAudit;
}): IProformaCreator => { }): IProformaCreator => {
return new ProformaCreator(params); return new ProformaCreator(deps);
}; };

View File

@ -1,8 +1,14 @@
import type { IAudit } from "@repo/rdx-audit";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import { type IProformaDeleter, ProformaDeleter } from "../services"; 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({ return new ProformaDeleter({
repository: deps.repository, repository: deps.repository,
audit: deps.audit,
}); });
} }

View File

@ -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);

View File

@ -1,8 +1,11 @@
import type { IAudit } from "@repo/rdx-audit";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import { type IProformaStatusChanger, ProformaStatusChanger } from "../services"; import { type IProformaStatusChanger, ProformaStatusChanger } from "../services";
export const buildProformaStatusChanger = (params: { export const buildProformaStatusChanger = (params: {
repository: IProformaRepository; repository: IProformaRepository;
audit: IAudit;
}): IProformaStatusChanger => { }): IProformaStatusChanger => {
return new ProformaStatusChanger(params); return new ProformaStatusChanger(params);
}; };

View File

@ -1,5 +1,6 @@
// modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts // modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts
import type { ICustomerPublicServices } from "@erp/customers/api"; import type { ICustomerPublicServices } from "@erp/customers/api";
import type { IAudit } from "@repo/rdx-audit";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { ProformaPaymentResolver } from "../services"; import type { ProformaPaymentResolver } from "../services";
@ -16,6 +17,7 @@ export const buildProformaUpdater = (params: {
paymentResolver: ProformaPaymentResolver; paymentResolver: ProformaPaymentResolver;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
customerServices: ICustomerPublicServices; customerServices: ICustomerPublicServices;
audit: IAudit;
}): IProformaUpdater => { }): IProformaUpdater => {
return new ProformaUpdater(params); return new ProformaUpdater(params);
}; };

View File

@ -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 { 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 { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers"; import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
@ -11,6 +9,7 @@ import type {
IProformaArchiver, IProformaArchiver,
IProformaCreator, IProformaCreator,
IProformaDeleter, IProformaDeleter,
IProformaDuplicator,
IProformaFinder, IProformaFinder,
IProformaFullReadModelAssembler, IProformaFullReadModelAssembler,
IProformaIssuanceReadinessChecker, IProformaIssuanceReadinessChecker,
@ -142,25 +141,17 @@ export function buildCreateProformaUseCase(deps: {
} }
export function buildDuplicateProformaByIdUseCase(deps: { export function buildDuplicateProformaByIdUseCase(deps: {
creator: IProformaCreator; duplicator: IProformaDuplicator;
finder: IProformaFinder; finder: IProformaFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler; fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder; fullSnapshotBuilder: IProformaFullSnapshotBuilder;
documentSeriesServices: import("@erp/document-series/api").DocumentSeriesPublicServicesType;
companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices;
paymentTermFinder: IPaymentTermPublicFinder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {
return new DuplicateProformaByIdUseCase({ return new DuplicateProformaByIdUseCase({
creator: deps.creator, duplicator: deps.duplicator,
finder: deps.finder, finder: deps.finder,
fullReadModelAssembler: deps.fullReadModelAssembler, fullReadModelAssembler: deps.fullReadModelAssembler,
fullSnapshotBuilder: deps.fullSnapshotBuilder, fullSnapshotBuilder: deps.fullSnapshotBuilder,
documentSeriesServices: deps.documentSeriesServices,
companiesServices: deps.companiesServices,
customerServices: deps.customerServices,
paymentTermFinder: deps.paymentTermFinder,
transactionManager: deps.transactionManager, transactionManager: deps.transactionManager,
}); });
} }
@ -175,6 +166,7 @@ export function buildIssueProformaUseCase(deps: {
issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder; issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder;
repository: IProformaRepository; repository: IProformaRepository;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
audit: IAudit;
}) { }) {
const { const {
finder, finder,
@ -183,6 +175,7 @@ export function buildIssueProformaUseCase(deps: {
issueResponseSnapshotBuilder, issueResponseSnapshotBuilder,
repository, repository,
transactionManager, transactionManager,
audit,
publicServices: { issuedInvoiceServices }, publicServices: { issuedInvoiceServices },
} = deps; } = deps;
@ -194,6 +187,7 @@ export function buildIssueProformaUseCase(deps: {
issueResponseSnapshotBuilder, issueResponseSnapshotBuilder,
repository, repository,
transactionManager, transactionManager,
audit,
}); });
} }

View File

@ -1,14 +1,15 @@
export * from "./proforma-archiver";
export * from "./assemblers"; export * from "./assemblers";
export * from "./catalog-resolver"; export * from "./catalog-resolver";
export * from "./company-report-profile-finder"; export * from "./company-report-profile-finder";
export * from "./proforma-archiver";
export * from "./proforma-creator"; export * from "./proforma-creator";
export * from "./proforma-deleter";
export * from "./proforma-document-generator.interface"; export * from "./proforma-document-generator.interface";
export * from "./proforma-document-properties-factory"; export * from "./proforma-document-properties-factory";
export * from "./proforma-deleter"; export * from "./proforma-duplicator";
export * from "./proforma-finder"; export * from "./proforma-finder";
export * from "./proforma-issuer";
export * from "./proforma-issuance-readiness-checker"; export * from "./proforma-issuance-readiness-checker";
export * from "./proforma-issuer";
export * from "./proforma-number-generator.interface"; export * from "./proforma-number-generator.interface";
export * from "./proforma-public-services.interface"; export * from "./proforma-public-services.interface";
export * from "./proforma-series-validator.interface"; export * from "./proforma-series-validator.interface";

View File

@ -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 = <T>(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);
});

View File

@ -1,14 +1,21 @@
import type { IAudit } from "@repo/rdx-audit";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { ProformaCannotBeArchivedError, ProformaCannotBeUnarchivedError } from "../../../domain";
import { import {
ProformaCannotBeArchivedError, PROFORMA_AUDIT_ACTIONS,
ProformaCannotBeUnarchivedError, toProformaAuditState,
} from "../../../domain"; toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
export interface IProformaArchiver { export interface IProformaArchiver {
archive(params: { companyId: UniqueID; id: UniqueID; transaction: unknown }): Promise<Result<void, Error>>; archive(params: {
companyId: UniqueID;
id: UniqueID;
transaction: unknown;
}): Promise<Result<void, Error>>;
unarchive(params: { unarchive(params: {
companyId: UniqueID; companyId: UniqueID;
id: UniqueID; id: UniqueID;
@ -17,7 +24,7 @@ export interface IProformaArchiver {
} }
export class ProformaArchiver implements 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: { public async archive(params: {
companyId: UniqueID; companyId: UniqueID;
@ -35,6 +42,7 @@ export class ProformaArchiver implements IProformaArchiver {
} }
const proforma = proformaResult.data; const proforma = proformaResult.data;
const before = toProformaAuditState(proforma);
const canArchiveResult = proforma.ensureCanBeArchived(); const canArchiveResult = proforma.ensureCanBeArchived();
if (canArchiveResult.isFailure) { 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(); return Result.ok();
} }
@ -76,6 +105,7 @@ export class ProformaArchiver implements IProformaArchiver {
} }
const proforma = proformaResult.data; const proforma = proformaResult.data;
const before = toProformaAuditState(proforma);
const canUnarchiveResult = proforma.ensureCanBeUnarchived(); const canUnarchiveResult = proforma.ensureCanBeUnarchived();
if (canUnarchiveResult.isFailure) { if (canUnarchiveResult.isFailure) {
@ -94,13 +124,31 @@ export class ProformaArchiver implements IProformaArchiver {
if (!persistenceResult.data) { if (!persistenceResult.data) {
return Result.fail( return Result.fail(
new ProformaCannotBeUnarchivedError( new ProformaCannotBeUnarchivedError(params.id.toString(), "Proforma is not archived.")
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(); return Result.ok();
} }
} }

View File

@ -2,25 +2,57 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { DiscountPercentage } from "@erp/core/api"; 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 { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { InvoiceStatus, type Proforma } from "../../../domain";
import type { ProformaCreateInputProps } from "../models";
import { ProformaCreator } from "./proforma-creator"; 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 buildCreateProps = () => {
const proformaDateResult = UtcDate.createFromISO("2026-08-16"); const proformaDateResult = UtcDate.createFromISO("2026-08-16");
const languageCodeResult = LanguageCode.create("es"); const languageCodeResult = LanguageCode.create("es");
const currencyCodeResult = CurrencyCode.create("EUR"); const currencyCodeResult = CurrencyCode.create("EUR");
const globalDiscountPercentageResult = DiscountPercentage.create({ value: 0 }); const globalDiscountPercentageResult = DiscountPercentage.create({ value: 0 });
const statusResult = InvoiceStatus.create("draft");
assert.equal(proformaDateResult.isSuccess, true); assert.equal(proformaDateResult.isSuccess, true);
assert.equal(languageCodeResult.isSuccess, true); assert.equal(languageCodeResult.isSuccess, true);
assert.equal(currencyCodeResult.isSuccess, true); assert.equal(currencyCodeResult.isSuccess, true);
assert.equal(globalDiscountPercentageResult.isSuccess, true); assert.equal(globalDiscountPercentageResult.isSuccess, true);
assert.equal(statusResult.isSuccess, true);
return { return {
companyId: UniqueID.generateNewID(), companyId: UniqueID.generateNewID(),
status: {} as never, status: statusResult.data,
proformaSeriesCode: Maybe.none(), proformaSeriesCode: Maybe.none(),
targetInvoiceSeriesCode: Maybe.none(), targetInvoiceSeriesCode: Maybe.none(),
proformaDate: proformaDateResult.data, proformaDate: proformaDateResult.data,
@ -45,7 +77,7 @@ const buildCreateProps = () => {
}, },
globalDiscountPercentage: globalDiscountPercentageResult.data, globalDiscountPercentage: globalDiscountPercentageResult.data,
items: [], items: [],
}; } satisfies ProformaCreateInputProps;
}; };
test("create hereda payment term del customer cuando no llega explicito", async () => { 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 props = buildCreateProps();
const proformaId = UniqueID.generateNewID(); const proformaId = UniqueID.generateNewID();
let capturedPaymentTermId = Maybe.none<UniqueID>(); let capturedPaymentTermId = Maybe.none<UniqueID>();
let persistedProforma: Proforma | null = null;
let companyLookups = 0; let companyLookups = 0;
const audit = new FakeAudit();
const creator = new ProformaCreator({ const creator = new ProformaCreator({
repository: { repository: {
create: async (proforma) => { create: async (proforma: Proforma) => {
persistedProforma = proforma;
capturedPaymentTermId = proforma.paymentTermId; capturedPaymentTermId = proforma.paymentTermId;
return Result.ok(undefined); 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, } as never,
audit,
numberService: { numberService: {
getNextForCompany: async () => getNextForCompany: async () =>
Result.ok({ 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.isSome(), true);
assert.equal(capturedPaymentTermId.unwrap().equals(customerPaymentTermId), true); assert.equal(capturedPaymentTermId.unwrap().equals(customerPaymentTermId), true);
assert.equal(companyLookups, 0); 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 () => { 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 props = buildCreateProps();
const proformaId = UniqueID.generateNewID(); const proformaId = UniqueID.generateNewID();
let capturedPaymentTermId = Maybe.none<UniqueID>(); let capturedPaymentTermId = Maybe.none<UniqueID>();
let persistedProforma: Proforma | null = null;
const creator = new ProformaCreator({ const creator = new ProformaCreator({
repository: { repository: {
create: async (proforma) => { create: async (proforma: Proforma) => {
persistedProforma = proforma;
capturedPaymentTermId = proforma.paymentTermId; capturedPaymentTermId = proforma.paymentTermId;
return Result.ok(undefined); 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, } as never,
audit: { recordMutation: async () => Result.ok(undefined) } as never,
numberService: { numberService: {
getNextForCompany: async () => getNextForCompany: async () =>
Result.ok({ Result.ok({
@ -206,15 +260,21 @@ test("create con payment_term_id null explicito conserva el clear", async () =>
}; };
const proformaId = UniqueID.generateNewID(); const proformaId = UniqueID.generateNewID();
let capturedPaymentTermId = Maybe.some(UniqueID.generateNewID()); let capturedPaymentTermId = Maybe.some(UniqueID.generateNewID());
let persistedProforma: Proforma | null = null;
const creator = new ProformaCreator({ const creator = new ProformaCreator({
repository: { repository: {
create: async (proforma) => { create: async (proforma: Proforma) => {
persistedProforma = proforma;
capturedPaymentTermId = proforma.paymentTermId; capturedPaymentTermId = proforma.paymentTermId;
return Result.ok(undefined); 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, } as never,
audit: { recordMutation: async () => Result.ok(undefined) } as never,
numberService: { numberService: {
getNextForCompany: async () => getNextForCompany: async () =>
Result.ok({ 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(result.isSuccess, true);
assert.equal(capturedPaymentTermId.isNone(), 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);
});

View File

@ -1,5 +1,6 @@
import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ICompanyPublicServices } from "@erp/companies/api";
import type { ICustomerPublicServices } from "@erp/customers/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 { UniqueID, type UniqueID as UniqueIDType } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
@ -11,6 +12,11 @@ import {
Proforma, Proforma,
ProformaTaxConfig, ProformaTaxConfig,
} from "../../../domain"; } from "../../../domain";
import {
PROFORMA_AUDIT_ACTIONS,
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models"; import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
@ -42,6 +48,7 @@ export class ProformaCreator implements IProformaCreator {
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
companiesServices: ICompanyPublicServices; companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices; customerServices: ICustomerPublicServices;
audit: IAudit;
} }
) {} ) {}
@ -144,6 +151,17 @@ export class ProformaCreator implements IProformaCreator {
return Result.fail(verifyResult.error); 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); return Result.ok(verifyResult.data);
} }

View File

@ -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);
});

View File

@ -1,7 +1,13 @@
import type { IAudit } from "@repo/rdx-audit";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { ProformaCannotBeDeletedError } from "../../../domain"; import { ProformaCannotBeDeletedError } from "../../../domain";
import {
PROFORMA_AUDIT_ACTIONS,
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
export interface IProformaDeleter { export interface IProformaDeleter {
@ -16,6 +22,7 @@ export class ProformaDeleter implements IProformaDeleter {
public constructor( public constructor(
private readonly deps: { private readonly deps: {
repository: IProformaRepository; repository: IProformaRepository;
audit: IAudit;
} }
) {} ) {}
@ -35,6 +42,7 @@ export class ProformaDeleter implements IProformaDeleter {
} }
const proforma = proformaResult.data; const proforma = proformaResult.data;
const before = toProformaAuditState(proforma);
const canDeleteResult = proforma.ensureCanBeDeleted(); const canDeleteResult = proforma.ensureCanBeDeleted();
if (canDeleteResult.isFailure) { if (canDeleteResult.isFailure) {
@ -54,7 +62,10 @@ export class ProformaDeleter implements IProformaDeleter {
if (hasIssuedInvoiceResult.data) { if (hasIssuedInvoiceResult.data) {
return Result.fail( 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); 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(); return Result.ok();
} }
} }

View File

@ -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 = <T>(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);
});

View File

@ -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<Result<IProformaDuplicatorResult, Error>>;
}
type ResolvedDuplicatePaymentTerm = {
paymentTermId: Maybe<UniqueID>;
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<Result<IProformaDuplicatorResult, Error>> {
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<Result<IProformaDuplicatorResult, Error>> {
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<InvoiceSerie>;
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
transaction?: unknown;
}): Promise<Result<ProformaNumberAssignment, Error>> {
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<string>;
transaction?: unknown;
}): Promise<Result<Maybe<InvoiceSerie>, 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<InvoiceSerie>;
transaction?: unknown;
}): Promise<Result<Maybe<InvoiceSerie>, 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<UniqueID>;
transaction?: unknown;
}): Promise<Result<ResolvedDuplicatePaymentTerm, Error>> {
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<UniqueID>;
transaction?: unknown;
}): Promise<Result<Maybe<UniqueID>, 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<Result<Parameters<typeof Proforma.create>[0]["items"], Error>> {
const items: Parameters<typeof Proforma.create>[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);
}
}

View File

@ -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 = <T>(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);
});

View File

@ -1,7 +1,13 @@
import type { IAudit } from "@repo/rdx-audit";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { InvoiceStatus, type Proforma } from "../../../domain"; import { InvoiceStatus, type Proforma } from "../../../domain";
import {
PROFORMA_AUDIT_ACTIONS,
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
export interface IProformaStatusChanger { export interface IProformaStatusChanger {
@ -17,6 +23,7 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
public constructor( public constructor(
private readonly deps: { private readonly deps: {
repository: IProformaRepository; repository: IProformaRepository;
audit: IAudit;
} }
) {} ) {}
@ -43,6 +50,7 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
} }
const proforma = proformaResult.data; const proforma = proformaResult.data;
const before = toProformaAuditState(proforma);
const changeResult = proforma.changeStatus(statusResult.data); const changeResult = proforma.changeStatus(statusResult.data);
@ -60,6 +68,17 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
return Result.fail(updateResult.error); 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); return Result.ok(proforma);
} }
} }

View File

@ -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);
});

View File

@ -1,13 +1,61 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { Result } from "@repo/rdx-utils"; import type {
import { Maybe } from "@repo/rdx-utils"; IAudit,
import { UniqueID, UtcDate } from "@repo/rdx-ddd"; 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"; 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 = <T>(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"); const proformaDateResult = UtcDate.createFromISO("2026-08-16");
assert.equal(proformaDateResult.isSuccess, true); assert.equal(proformaDateResult.isSuccess, true);
@ -20,7 +68,7 @@ const buildProformaDouble = (paymentTermId?: UniqueID) => {
isApproved: () => false, isApproved: () => false,
}, },
proformaDate: proformaDateResult.data, proformaDate: proformaDateResult.data,
paymentTermId: paymentTermId ? Maybe.some(paymentTermId) : Maybe.none(), paymentTermId: params?.paymentTermId ? Maybe.some(params.paymentTermId) : Maybe.none(),
taxConfig: { taxConfig: {
taxMode: "single", taxMode: "single",
defaultIvaCode: Maybe.none(), defaultIvaCode: Maybe.none(),
@ -31,16 +79,312 @@ const buildProformaDouble = (paymentTermId?: UniqueID) => {
}, },
update: (patch: unknown) => { update: (patch: unknown) => {
receivedPatch = patch; receivedPatch = patch;
return Result.ok(undefined); return Result.ok(params?.didChange ?? true);
}, },
} as never, } as never,
getReceivedPatch: () => receivedPatch, 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 () => { test("update conserva un payment term inactivo ya asignado si no cambia", async () => {
const existingPaymentTermId = UniqueID.generateNewID(); const existingPaymentTermId = UniqueID.generateNewID();
const { proforma } = buildProformaDouble(existingPaymentTermId); const { proforma } = buildProformaDouble({ paymentTermId: existingPaymentTermId });
let validationCalls = 0; let validationCalls = 0;
const updater = new ProformaUpdater({ 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 () => { test("update permite limpiar el payment term con null explicito", async () => {
const existingPaymentTermId = UniqueID.generateNewID(); const existingPaymentTermId = UniqueID.generateNewID();
const { proforma } = buildProformaDouble(existingPaymentTermId); const { proforma } = buildProformaDouble({ paymentTermId: existingPaymentTermId });
let validationCalls = 0; let validationCalls = 0;
const updater = new ProformaUpdater({ 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 () => { 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({ const updater = new ProformaUpdater({
repository: { repository: {
@ -203,6 +547,90 @@ test("update refresca el snapshot del destinatario al cambiar el customer", asyn
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true);
assert.equal(getReceivedPatch() !== undefined, 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<unknown> }).recipient.isSome(), true); assert.equal((getReceivedPatch() as { recipient: Maybe<unknown> }).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);
});

View File

@ -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 { DomainValidationError, type UniqueID, type UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import type { ICustomerPublicServices } from "@erp/customers/api";
import { import {
type Proforma, type Proforma,
type ProformaItemPatchProps, type ProformaItemPatchProps,
type ProformaPatchProps, type ProformaPatchProps,
ProformaTaxConfig,
ProformaRecipient, ProformaRecipient,
ProformaTaxConfig,
} from "../../../domain"; } from "../../../domain";
import {
PROFORMA_AUDIT_ACTIONS,
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { ProformaPatchInputProps } from "../models"; import type { ProformaPatchInputProps } from "../models";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
@ -33,6 +39,7 @@ export class ProformaUpdater implements IProformaUpdater {
paymentResolver: ProformaPaymentResolver; paymentResolver: ProformaPaymentResolver;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
customerServices: ICustomerPublicServices; customerServices: ICustomerPublicServices;
audit: IAudit;
} }
) {} ) {}
@ -52,6 +59,7 @@ export class ProformaUpdater implements IProformaUpdater {
} }
const proforma = existingResult.data; const proforma = existingResult.data;
const before = toProformaAuditState(proforma);
if (proforma.status.isIssued()) { if (proforma.status.isIssued()) {
return Result.fail( return Result.fail(
@ -97,6 +105,10 @@ export class ProformaUpdater implements IProformaUpdater {
return Result.fail(updateResult.error); return Result.fail(updateResult.error);
} }
if (!updateResult.data) {
return Result.ok(proforma);
}
// Persistir cambios // Persistir cambios
const saveResult = await this.deps.repository.update(proforma, transaction); const saveResult = await this.deps.repository.update(proforma, transaction);
@ -104,6 +116,17 @@ export class ProformaUpdater implements IProformaUpdater {
return Result.fail(saveResult.error); 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 // Volvemos a recuperar lo guardado para asegurarnos de que los cambios se reflejen correctamente
const verifyResult = await this.deps.repository.getByIdInCompany(companyId, id, transaction); const verifyResult = await this.deps.repository.getByIdInCompany(companyId, id, transaction);
@ -148,7 +171,9 @@ export class ProformaUpdater implements IProformaUpdater {
} }
if (patch.paymentTermId !== undefined) { 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() && patch.paymentTermId.isSome() &&
params.currentPaymentTermId.isSome() && params.currentPaymentTermId.isSome() &&
patch.paymentTermId.unwrap().equals(params.currentPaymentTermId.unwrap()) patch.paymentTermId.unwrap().equals(params.currentPaymentTermId.unwrap())

View File

@ -1,224 +1,106 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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 { ITransactionManager } from "@erp/core/api";
import type { ICustomerPublicServices } from "@erp/customers/api"; import { Result } from "@repo/rdx-utils";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture";
import { DuplicateProformaByIdUseCase } from "./duplicate-proforma-by-id.use-case"; import { DuplicateProformaByIdUseCase } from "./duplicate-proforma-by-id.use-case";
const buildTransactionManager = (): ITransactionManager => ({ const transactionManager: ITransactionManager = {
start: async () => undefined, start: async () => undefined,
getTransaction: () => undefined, getTransaction: () => undefined,
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined), complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined),
commit: async () => undefined, commit: async () => undefined,
rollback: async () => undefined, rollback: async () => undefined,
}); };
const buildUseCase = (params?: { const buildUseCase = (params?: { sourceFailure?: Error; duplicatorFailure?: Error }) => {
sourcePaymentTermId?: Maybe<UniqueID>; const source = createProformaTestFixture();
customerPaymentTermId?: Maybe<UniqueID>; const duplicated = createProformaTestFixture({ description: "Duplicada" });
companyDefaultPaymentTermId?: string | null; let duplicatorCalls = 0;
activePaymentTermIds?: Set<string>; let receivedParams: unknown;
}) => {
const companyId = UniqueID.generateNewID();
const sourceId = UniqueID.generateNewID();
const customerId = UniqueID.generateNewID();
const activePaymentTermIds = params?.activePaymentTermIds ?? new Set<string>();
let receivedPaymentTermId: Maybe<UniqueID> | 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 useCase = new DuplicateProformaByIdUseCase({ const useCase = new DuplicateProformaByIdUseCase({
creator: { duplicator: {
create: async (input: { props: { paymentTermId?: Maybe<UniqueID> }; id: UniqueID }) => { duplicate: async (input) => {
receivedPaymentTermId = input.props.paymentTermId; duplicatorCalls += 1;
return Result.ok({ id: input.id } as never); receivedParams = input;
return params?.duplicatorFailure
? Result.fail(params.duplicatorFailure)
: Result.ok({ proforma: duplicated, paymentTermAdaptation: null });
},
}, },
} as never,
finder: { finder: {
findProformaByIdIncludingArchived: async () => findProformaByIdIncludingArchived: async () =>
Result.ok({ params?.sourceFailure ? Result.fail(params.sourceFailure) : Result.ok(source),
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),
} as never, } as never,
fullReadModelAssembler: { fullReadModelAssembler: {
assemble: async () => Result.ok({} as never), assemble: async () => Result.ok(duplicated),
} as never, } as never,
fullSnapshotBuilder: { fullSnapshotBuilder: {
toOutput: () => ({ id: sourceId.toPrimitive() }), toOutput: (proforma) => ({
id: proforma.id.toPrimitive(),
description: proforma.description.getOrUndefined() ?? null,
}),
} as never, } as never,
documentSeriesServices: { transactionManager,
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(),
}); });
return { return {
companyId, source,
sourceId, duplicated,
useCase, useCase,
getReceivedPaymentTermId: () => receivedPaymentTermId, getDuplicatorCalls: () => duplicatorCalls,
getReceivedParams: () => receivedParams,
}; };
}; };
test("duplicate conserva el payment term original si sigue activo", async () => { test("duplicate usa Duplicator y conserva la respuesta final", async () => {
const paymentTermId = UniqueID.generateNewID(); const setup = buildUseCase();
const setup = buildUseCase({
sourcePaymentTermId: Maybe.some(paymentTermId),
activePaymentTermIds: new Set([paymentTermId.toString()]),
});
const result = await setup.useCase.execute({ const result = await setup.useCase.execute({
companyId: setup.companyId, companyId: setup.source.companyId,
params: { proforma_id: setup.sourceId.toPrimitive() }, params: { proforma_id: setup.source.id.toPrimitive() },
}); });
assert.equal(result.isSuccess, true); 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); assert.equal(result.data.payment_term_adaptation, null);
}); });
test("duplicate usa el payment term activo del customer si el original está inactivo", async () => { test("duplicate no invoca Duplicator si falla la carga de source", async () => {
const originalPaymentTermId = UniqueID.generateNewID(); const sourceError = new Error("Source not found");
const customerPaymentTermId = UniqueID.generateNewID(); const setup = buildUseCase({ sourceFailure: sourceError });
const setup = buildUseCase({
sourcePaymentTermId: Maybe.some(originalPaymentTermId),
customerPaymentTermId: Maybe.some(customerPaymentTermId),
activePaymentTermIds: new Set([customerPaymentTermId.toString()]),
});
const result = await setup.useCase.execute({ const result = await setup.useCase.execute({
companyId: setup.companyId, companyId: setup.source.companyId,
params: { proforma_id: setup.sourceId.toPrimitive() }, params: { proforma_id: setup.source.id.toPrimitive() },
}); });
assert.equal(result.isSuccess, true); assert.equal(result.isFailure, true);
assert.equal(setup.getReceivedPaymentTermId()?.unwrap().equals(customerPaymentTermId), true); assert.equal(result.error, sourceError);
assert.deepEqual(result.data.payment_term_adaptation, { assert.equal(setup.getDuplicatorCalls(), 0);
source: "inactive_original",
resolution: "customer",
});
}); });
test("duplicate usa el default activo de company si el original y el customer no son válidos", async () => { test("duplicate propaga el fallo de Duplicator", async () => {
const originalPaymentTermId = UniqueID.generateNewID(); const duplicateError = new Error("Duplicate failed");
const inactiveCustomerPaymentTermId = UniqueID.generateNewID(); const setup = buildUseCase({ duplicatorFailure: duplicateError });
const companyDefaultPaymentTermId = UniqueID.generateNewID();
const setup = buildUseCase({
sourcePaymentTermId: Maybe.some(originalPaymentTermId),
customerPaymentTermId: Maybe.some(inactiveCustomerPaymentTermId),
companyDefaultPaymentTermId: companyDefaultPaymentTermId.toPrimitive(),
activePaymentTermIds: new Set([companyDefaultPaymentTermId.toString()]),
});
const result = await setup.useCase.execute({ const result = await setup.useCase.execute({
companyId: setup.companyId, companyId: setup.source.companyId,
params: { proforma_id: setup.sourceId.toPrimitive() }, params: { proforma_id: setup.source.id.toPrimitive() },
}); });
assert.equal(result.isSuccess, true); assert.equal(result.isFailure, true);
assert.equal( assert.equal(result.error, duplicateError);
setup.getReceivedPaymentTermId()?.unwrap().equals(companyDefaultPaymentTermId), assert.equal(setup.getDuplicatorCalls(), 1);
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",
});
}); });

View File

@ -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 { ITransactionManager } from "@erp/core/api";
import type { ICustomerPublicServices } from "@erp/customers/api"; import { UniqueID } from "@repo/rdx-ddd";
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; import { Result } from "@repo/rdx-utils";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { DuplicateProformaByIdParamsRequestDTO } from "../../../../common"; import type { DuplicateProformaByIdParamsRequestDTO } from "../../../../common";
import { InvoiceSerie, InvoiceStatus, type ProformaRecipient } from "../../../domain";
import { InvoiceSeriesNotFoundError } from "../../invoice-series";
import type { ProformaCreateInputProps } from "../models";
import type { import type {
IProformaCreator, IProformaDuplicator,
IProformaFinder, IProformaFinder,
IProformaFullReadModelAssembler, IProformaFullReadModelAssembler,
} from "../services"; } from "../services";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
type DuplicatePaymentTermAdaptation = {
source: "inactive_original";
resolution: "customer" | "company" | "none";
} | null;
type DuplicateProformaByIdUseCaseInput = { type DuplicateProformaByIdUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;
params: DuplicateProformaByIdParamsRequestDTO; params: DuplicateProformaByIdParamsRequestDTO;
@ -30,23 +18,17 @@ type DuplicateProformaByIdUseCaseInput = {
export class DuplicateProformaByIdUseCase { export class DuplicateProformaByIdUseCase {
public constructor( public constructor(
private readonly deps: { private readonly deps: {
creator: IProformaCreator; duplicator: IProformaDuplicator;
finder: IProformaFinder; finder: IProformaFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler; fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder; fullSnapshotBuilder: IProformaFullSnapshotBuilder;
documentSeriesServices: DocumentSeriesPublicServicesType;
companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices;
paymentTermFinder: IPaymentTermPublicFinder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
} }
) {} ) {}
public async execute(params: DuplicateProformaByIdUseCaseInput) { public async execute(params: DuplicateProformaByIdUseCaseInput) {
const proformaIdResult = UniqueID.create(params.params.proforma_id); const proformaIdResult = UniqueID.create(params.params.proforma_id);
if (proformaIdResult.isFailure) { if (proformaIdResult.isFailure) return Result.fail(proformaIdResult.error);
return Result.fail(proformaIdResult.error);
}
return this.deps.transactionManager.complete(async (transaction: unknown) => { return this.deps.transactionManager.complete(async (transaction: unknown) => {
try { try {
@ -55,370 +37,29 @@ export class DuplicateProformaByIdUseCase {
proformaIdResult.data, proformaIdResult.data,
transaction transaction
); );
if (sourceResult.isFailure) return Result.fail(sourceResult.error);
if (sourceResult.isFailure) { const duplicatedResult = await this.deps.duplicator.duplicate({
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({
companyId: params.companyId, companyId: params.companyId,
sourceDocumentSeriesId: source.documentSeriesId, source: sourceResult.data,
transaction, transaction,
}); });
if (duplicatedResult.isFailure) return Result.fail(duplicatedResult.error);
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<ProformaRecipient>(),
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);
}
const readModelResult = await this.deps.fullReadModelAssembler.assemble({ const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId: params.companyId, companyId: params.companyId,
proforma: duplicatedResult.data, proforma: duplicatedResult.data.proforma,
transaction, transaction,
}); });
if (readModelResult.isFailure) return Result.fail(readModelResult.error);
if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
return Result.ok({ return Result.ok({
...this.deps.fullSnapshotBuilder.toOutput(readModelResult.data), ...this.deps.fullSnapshotBuilder.toOutput(readModelResult.data),
payment_term_adaptation: paymentTermResolutionResult.data.adaptation, payment_term_adaptation: duplicatedResult.data.paymentTermAdaptation,
}); });
} catch (error: unknown) { } catch (error: unknown) {
return Result.fail(error as Error); return Result.fail(error as Error);
} }
}); });
} }
private async resolveDuplicateSeriesCode(params: {
companyId: UniqueID;
sourceDocumentSeriesId: Maybe<string>;
transaction?: unknown;
}): Promise<Result<Maybe<InvoiceSerie>, 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<InvoiceSerie>;
transaction?: unknown;
}): Promise<Result<Maybe<InvoiceSerie>, 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<UniqueID>;
transaction?: unknown;
}): Promise<
Result<
{
paymentTermId: Maybe<UniqueID> | 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<UniqueID>;
transaction?: unknown;
}): Promise<Result<Maybe<UniqueID>, 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<Maybe<UniqueID>, 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));
}
} }

View File

@ -1,25 +1,141 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import type { ITransactionManager } 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 { 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 { import {
InvoiceAmount,
InvoiceNumber, InvoiceNumber,
InvoicePaymentMethod,
InvoicePaymentTerm,
InvoiceSerie,
InvoiceStatus, InvoiceStatus,
InvoiceTaxRegime,
IssuedInvoice,
IssuedInvoiceFiscalClassification,
IssuedInvoiceItems,
IssuedInvoiceRecipient,
IssuedInvoiceTaxes,
Proforma, Proforma,
ProformaItems, ProformaItems,
ProformaTaxConfig, ProformaTaxConfig,
} from "../../../domain"; } 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 { ProformaIssuer } from "../services/proforma-issuer";
import { IssueProformaUseCase } from "./issue-proforma.use-case"; import { IssueProformaUseCase } from "./issue-proforma.use-case";
type CreateIssuedInvoiceProps = Parameters<IIssuedInvoicePublicServices["createIssuedInvoice"]>[1]; type CreateIssuedInvoiceProps = Parameters<IIssuedInvoicePublicServices["createIssuedInvoice"]>[1];
class SuccessAudit implements IAudit {
public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = [];
async recordMutation(input: RecordAuditMutationShortcutInput) {
this.recordMutationCalls.push(input);
return Result.ok(undefined);
}
}
const required = <T>(result: Result<T, Error>): 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 => ({ const buildTransactionManager = (): ITransactionManager => ({
start: async () => undefined, start: async () => undefined,
getTransaction: () => undefined, getTransaction: () => undefined,
@ -33,9 +149,7 @@ const buildCreateIssuedInvoiceProps = (): CreateIssuedInvoiceProps =>
verifactu: Maybe.none(), verifactu: Maybe.none(),
}) as CreateIssuedInvoiceProps; }) as CreateIssuedInvoiceProps;
const buildIssueableProforma = (params?: { const buildIssueableProforma = (params?: { operationDate?: Maybe<UtcDate> }) => {
operationDate?: Maybe<UtcDate>;
}) => {
const proformaDateResult = UtcDate.createFromISO("2026-08-16"); const proformaDateResult = UtcDate.createFromISO("2026-08-16");
const operationDateResult = UtcDate.createFromISO("2026-08-17"); const operationDateResult = UtcDate.createFromISO("2026-08-17");
const languageCodeResult = LanguageCode.create("es"); 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 () => { test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async () => {
const proforma = buildIssueableProforma(); const proforma = buildIssueableProforma();
const reloadedInvoice = createReloadedIssuedInvoice(UniqueID.generateNewID(), proforma.companyId);
const audit = new SuccessAudit();
let capturedProps: CreateIssuedInvoiceProps | undefined; let capturedProps: CreateIssuedInvoiceProps | undefined;
const useCase = new IssueProformaUseCase({ const useCase = new IssueProformaUseCase({
issuedInvoiceServices: { issuedInvoiceServices: {
createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => { createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => {
capturedProps = props; 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, } as never,
finder: { finder: {
findProformaById: async () => Result.ok(proforma), 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), markAsIssuedByIdInCompany: async () => Result.ok(undefined),
} as never, } as never,
transactionManager: buildTransactionManager(), transactionManager: buildTransactionManager(),
audit,
}); });
const result = await useCase.execute({ const result = await useCase.execute({
@ -277,7 +396,7 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async
proformaId: proforma.id.toPrimitive(), proformaId: proforma.id.toPrimitive(),
}); });
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true, result.isFailure ? result.error.message : "");
if (!capturedProps) { if (!capturedProps) {
assert.fail("Expected createIssuedInvoice to be called"); 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 () => { test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async () => {
const proforma = buildIssueableProforma(); const proforma = buildIssueableProforma();
const reloadedInvoice = createReloadedIssuedInvoice(UniqueID.generateNewID(), proforma.companyId);
const audit = new SuccessAudit();
let capturedProps: CreateIssuedInvoiceProps | undefined; let capturedProps: CreateIssuedInvoiceProps | undefined;
const useCase = new IssueProformaUseCase({ const useCase = new IssueProformaUseCase({
issuedInvoiceServices: { issuedInvoiceServices: {
createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => { createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => {
capturedProps = props; 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, } as never,
finder: { finder: {
findProformaById: async () => Result.ok(proforma), 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), markAsIssuedByIdInCompany: async () => Result.ok(undefined),
} as never, } as never,
transactionManager: buildTransactionManager(), transactionManager: buildTransactionManager(),
audit,
}); });
const result = await useCase.execute({ 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(), proformaId: proforma.id.toPrimitive(),
}); });
assert.equal(result.isSuccess, true); assert.equal(result.isSuccess, true, result.isFailure ? result.error.message : "");
if (!capturedProps) { if (!capturedProps) {
assert.fail("Expected createIssuedInvoice to be called"); 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.match(result.error.message, /verifactu insert failed/i);
assert.equal(markAsIssuedCalls, 0); 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<ProformaToIssuedInvoiceCreateProps, Error> =>
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 <T>(work: (activeTransaction: unknown) => Promise<T>) => 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
);
});

View File

@ -1,8 +1,10 @@
import type { ITransactionManager } from "@erp/core/api"; import type { ITransactionManager } from "@erp/core/api";
import type { IAudit } from "@repo/rdx-audit";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit";
import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain"; import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain";
import type { IssueProformaCommand } from "../commands"; import type { IssueProformaCommand } from "../commands";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
@ -26,6 +28,7 @@ export class IssueProformaUseCase {
public constructor( public constructor(
private readonly deps: { private readonly deps: {
issuedInvoiceServices: IIssuedInvoicePublicServices; issuedInvoiceServices: IIssuedInvoicePublicServices;
audit: IAudit;
finder: IProformaFinder; finder: IProformaFinder;
issuer: IProformaIssuer; issuer: IProformaIssuer;
issueReadModelAssembler: IProformaIssueReadModelAssembler; issueReadModelAssembler: IProformaIssueReadModelAssembler;
@ -127,6 +130,31 @@ export class IssueProformaUseCase {
return Result.fail(markAsIssuedResult.error); 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)); return Result.ok(this.deps.issueResponseSnapshotBuilder.toOutput(invoiceResult.data));
} catch (error: unknown) { } catch (error: unknown) {
return Result.fail(error as Error); return Result.fail(error as Error);

View File

@ -11,8 +11,8 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils"; import { Maybe } from "@repo/rdx-utils";
import { InvoiceNumber, InvoiceStatus } from "../../common"; import { InvoiceNumber, InvoiceStatus, ItemDescription } from "../../common";
import { ProformaItems } from "../entities"; import { ProformaItem, ProformaItems } from "../entities";
import { ProformaTaxConfig } from "../value-objects"; import { ProformaTaxConfig } from "../value-objects";
import { Proforma } from "./proforma.aggregate"; import { Proforma } from "./proforma.aggregate";
@ -24,6 +24,7 @@ const buildApprovedProforma = (params?: {
itemsCount?: number; itemsCount?: number;
status?: InvoiceStatus; status?: InvoiceStatus;
linkedInvoiceId?: Maybe<UniqueID>; linkedInvoiceId?: Maybe<UniqueID>;
items?: ProformaItem[];
}) => { }) => {
const proformaDateResult = UtcDate.createFromISO("2026-08-16"); const proformaDateResult = UtcDate.createFromISO("2026-08-16");
const operationDateResult = UtcDate.createFromISO("2026-08-17"); const operationDateResult = UtcDate.createFromISO("2026-08-17");
@ -48,7 +49,8 @@ const buildApprovedProforma = (params?: {
assert.equal(discountResult.isSuccess, true); assert.equal(discountResult.isSuccess, true);
assert.equal(taxConfigResult.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( 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", () => { test("markAsIssued rechaza proformas sin operationDate", () => {
const proforma = buildApprovedProforma({ const proforma = buildApprovedProforma({
operationDate: Maybe.none(), operationDate: Maybe.none(),
@ -159,3 +184,65 @@ test("getIssuanceBlockers informa already_issued cuando existe linked invoice",
["already_issued"] ["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);
});

View File

@ -1,4 +1,4 @@
import type { DiscountPercentage } from "@erp/core/api"; import type { DiscountPercentage, Tax } from "@erp/core/api";
import { import {
AggregateRoot, AggregateRoot,
type CurrencyCode, type CurrencyCode,
@ -41,6 +41,7 @@ import type { IProformaTaxTotals, ProformaCalculationContext } from "../services
import { canManuallyTransitionProformaStatus } from "../services"; import { canManuallyTransitionProformaStatus } from "../services";
import { import {
ProformaItemTaxes, ProformaItemTaxes,
type ProformaItemTaxesProps,
type ProformaRecipient, type ProformaRecipient,
type ProformaTaxConfig, type ProformaTaxConfig,
} from "../value-objects"; } from "../value-objects";
@ -214,7 +215,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
} }
// Mutabilidad // Mutabilidad
public update(patchProps: ProformaPatchProps): Result<Proforma, Error> { public update(patchProps: ProformaPatchProps): Result<boolean, Error> {
const { items, ...otherProps } = patchProps; const { items, ...otherProps } = patchProps;
const definedProps = Object.fromEntries( const definedProps = Object.fromEntries(
Object.entries(otherProps).filter(([, value]) => value !== undefined) Object.entries(otherProps).filter(([, value]) => value !== undefined)
@ -232,6 +233,10 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return Result.fail(validationResult.error); return Result.fail(validationResult.error);
} }
if (this.hasSameFunctionalState(candidateProps, items)) {
return Result.ok(false);
}
// Aplicar cambios // Aplicar cambios
Object.assign(this.props, candidateProps); Object.assign(this.props, candidateProps);
@ -244,7 +249,185 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> 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<T>(
left: Maybe<T>,
right: Maybe<T>,
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( private initializeItems(

View File

@ -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");
});

View File

@ -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<UtcDate>;
description?: string | null;
customerId?: UniqueID;
recipient?: Maybe<ProformaRecipient>;
paymentTermId?: Maybe<UniqueID>;
taxConfig?: ProformaTaxConfig;
items?: IProformaItemCreateProps[];
globalDiscount?: DiscountPercentage;
};
const required = <T>(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);
};

View File

@ -86,5 +86,7 @@ export function buildIssuedInvoicePublicServices(
return Result.ok(createResult.data); return Result.ok(createResult.data);
}, },
getIssuedInvoiceById: async (id: UniqueID, context: IIssuedInvoiceServicesContext) =>
repository.getByIdInCompany(context.companyId, id, context.transaction),
}; };
} }

View File

@ -16,12 +16,12 @@ import {
buildProformaSnapshotBuilders, buildProformaSnapshotBuilders,
} from "../../../application"; } from "../../../application";
import type { Proforma } from "../../../domain"; 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 { buildV2ProformaPersistence } from "./proforma-persistence.di";
import type { ProformasInternalDeps } from "./proformas.di"; import type { ProformasInternalDeps } from "./proformas.di";
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.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 = { type ProformaServicesContext = {
transaction: Transaction; transaction: Transaction;
@ -83,9 +83,7 @@ export function buildProformaPublicServices(
}); });
const snapshotBuilders = buildProformaSnapshotBuilders(); const snapshotBuilders = buildProformaSnapshotBuilders();
const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator( const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(documentSeriesServices);
documentSeriesServices
);
const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator( const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator(
documentSeriesServices documentSeriesServices
); );
@ -99,6 +97,7 @@ export function buildProformaPublicServices(
targetInvoiceSeriesValidator, targetInvoiceSeriesValidator,
companiesServices, companiesServices,
customerServices, customerServices,
audit: params.audit,
}); });
return { return {

View File

@ -15,6 +15,7 @@ import {
type IssueProformaUseCase, type IssueProformaUseCase,
type ListProformasUseCase, type ListProformasUseCase,
type PreviewProformaReportUseCase, type PreviewProformaReportUseCase,
ProformaIssuanceReadinessChecker,
type ReportProformaPdfUseCase, type ReportProformaPdfUseCase,
type UnarchiveProformaByIdUseCase, type UnarchiveProformaByIdUseCase,
type UpdateProformaByIdUseCase, type UpdateProformaByIdUseCase,
@ -32,10 +33,10 @@ import {
buildProformaCatalogResolvers, buildProformaCatalogResolvers,
buildProformaCreator, buildProformaCreator,
buildProformaDeleter, buildProformaDeleter,
buildProformaDuplicator,
buildProformaFinder, buildProformaFinder,
buildProformaInputMappers, buildProformaInputMappers,
buildProformaIssueReadModelAssembler, buildProformaIssueReadModelAssembler,
ProformaIssuanceReadinessChecker,
buildProformaIssuer, buildProformaIssuer,
buildProformaReadModelAssemblers, buildProformaReadModelAssemblers,
buildProformaSnapshotBuilders, buildProformaSnapshotBuilders,
@ -133,6 +134,20 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
targetInvoiceSeriesValidator, targetInvoiceSeriesValidator,
companiesServices, companiesServices,
customerServices, 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({ const updater = buildProformaUpdater({
@ -141,16 +156,20 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
paymentResolver: catalogResolvers.paymentResolver, paymentResolver: catalogResolvers.paymentResolver,
targetInvoiceSeriesValidator, targetInvoiceSeriesValidator,
customerServices, customerServices,
audit: params.audit,
}); });
const deleter = buildProformaDeleter({ const deleter = buildProformaDeleter({
repository, repository,
audit: params.audit,
}); });
const archiver = buildProformaArchiver({ const archiver = buildProformaArchiver({
repository, repository,
audit: params.audit,
}); });
const statusChanger = buildProformaStatusChanger({ const statusChanger = buildProformaStatusChanger({
repository, repository,
audit: params.audit,
}); });
const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter(); const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter();
@ -212,14 +231,10 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
duplicateProforma: () => duplicateProforma: () =>
buildDuplicateProformaByIdUseCase({ buildDuplicateProformaByIdUseCase({
creator, duplicator,
finder, finder,
fullReadModelAssembler: readModelAssemblers.full, fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full, fullSnapshotBuilder: snapshotBuilders.full,
documentSeriesServices,
companiesServices,
customerServices,
paymentTermFinder: catalogs.paymentTerm.finder,
transactionManager, transactionManager,
}), }),
@ -287,6 +302,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
issueResponseSnapshotBuilder: snapshotBuilders.issue, issueResponseSnapshotBuilder: snapshotBuilders.issue,
repository, repository,
transactionManager, transactionManager,
audit: params.audit,
}), }),
changeStatusProforma: () => changeStatusProforma: () =>

View File

@ -18,21 +18,21 @@ import {
type FindOptions, type FindOptions,
type Includeable, type Includeable,
type InferAttributes, type InferAttributes,
col,
fn,
Op, Op,
type OrderItem, type OrderItem,
type Sequelize, type Sequelize,
type Transaction, type Transaction,
type WhereOptions, type WhereOptions,
col,
fn,
} from "sequelize"; } from "sequelize";
import type { import type {
IProformaRepository, IProformaRepository,
ProformaListScope,
ProformaStatusCounts, ProformaStatusCounts,
ProformaSummary, ProformaSummary,
} from "../../../../../application"; } from "../../../../../application";
import type { ProformaListScope } from "../../../../../application";
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain"; import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
import { import {
IssuedInvoiceModel, IssuedInvoiceModel,
@ -41,6 +41,7 @@ import {
ProformaTaxModel, ProformaTaxModel,
} from "../../../../common"; } from "../../../../common";
import type { SequelizeProformaV2DomainMapper, SequelizeProformaV2SummaryMapper } from "../mappers"; import type { SequelizeProformaV2DomainMapper, SequelizeProformaV2SummaryMapper } from "../mappers";
import { buildProformaQuickSearchWhere } from "./proforma-quick-search"; import { buildProformaQuickSearchWhere } from "./proforma-quick-search";
export class SequelizeProformaRepositoryV2 export class SequelizeProformaRepositoryV2
@ -86,15 +87,10 @@ export class SequelizeProformaRepositoryV2
if (dtoResult.isFailure) return Result.fail(dtoResult.error); if (dtoResult.isFailure) return Result.fail(dtoResult.error);
const dto = dtoResult.data; const dto = dtoResult.data;
const { id, items, taxes, ...updatePayload } = dto; const { id, items, taxes, ...updatePayload } = dto;
const [affectedCount] = await ProformaModel.update(updatePayload, { await ProformaModel.update(updatePayload, {
where: { id }, where: { id },
transaction, 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 ProformaItemModel.destroy({ where: { proforma_id: id }, transaction });
await ProformaTaxModel.destroy({ where: { proforma_id: id }, transaction }); await ProformaTaxModel.destroy({ where: { proforma_id: id }, transaction });
if (Array.isArray(taxes) && taxes.length > 0) { if (Array.isArray(taxes) && taxes.length > 0) {
@ -654,8 +650,7 @@ export class SequelizeProformaRepositoryV2
} }
} }
counts.all = counts.all = counts.draft + counts.sent + counts.approved + counts.rejected + counts.issued;
counts.draft + counts.sent + counts.approved + counts.rejected + counts.issued;
return Result.ok(counts); return Result.ok(counts);
} catch (err: unknown) { } catch (err: unknown) {

View File

@ -783,6 +783,9 @@ importers:
'@repo/i18next': '@repo/i18next':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/i18n version: link:../../packages/i18n
'@repo/rdx-audit':
specifier: workspace:*
version: link:../../packages/rdx-audit
'@repo/rdx-criteria': '@repo/rdx-criteria':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-criteria version: link:../../packages/rdx-criteria