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
Los comentarios deben explicar:
### Los comentarios
Deben explicar:
- por qué existe una decisión;
- qué riesgo evita;
@ -116,7 +117,12 @@ Documentar mediante TSDoc las APIs públicas cuando aporten información sobre:
Después de un refactor, actualizar o eliminar los comentarios que hayan quedado obsoletos.
La documentación del proyecto debe redactarse en castellano.
### La documentación del proyecto
- debe redactarse en castellano.
- Reside en /docs.
- Utilizar ficheros Markdown
- Los nombres de los ficheros deben estar en castellano.
Reglas de estilo para documentación:

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.
- Usa los documentos en subdirectorios temáticos como fuente de verdad operativa.
- Trata los documentos marcados como históricos o legacy como contexto, no como contrato vigente.
- Si hay duda sobre qué documento manda, consulta la [auditoría SSOT](./documentation-ssot-audit.md).
## Estructura documental
@ -15,6 +16,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
#### Arquitectura y runtime
- [Arquitectura general](./architecture/README.md)
- [Requisitos de `companies`](./companies/company-requirements.md)
- [Contexto tenant y `X-Company-Id`](./architecture/tenant-context.md)
- [Tratamiento de errores HTTP en frontend](./architecture/http-error-handling-frontend.md)
- [API de autenticación de `identity`](./architecture/identity-auth-api.md)
@ -31,8 +33,10 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
#### Customer invoices y proformas
- [Índice de `customer-invoices`](./customer-invoices/README.md)
- [Auditoría SSOT documental](./documentation-ssot-audit.md)
- [Plazos de pago](./catalogs/payment-terms.md)
- [Diseño backend del perfil VeriFactu empresarial](./customer-invoices/settings/verifactu-company-profile-backend-design.md)
Documento parcialmente superado: sigue siendo canónico para `CompanyVerifactuProfile`, pero no para reintroducir `IssuedInvoice.verifactuPolicy`.
- [Diseño frontend de Settings para VeriFactu](./customer-invoices/settings/frontend-erp-settings-verifactu-design.md)
- [Contrato de series de proformas](./customer-invoices/proforma-series-contract.md)
- [Contrato de create de proformas](./customer-invoices/proforma-create-contract.md)
@ -90,3 +94,4 @@ Estos documentos se conservan por trazabilidad, auditoría o contexto de decisio
- Los documentos operativos vigentes deben vivir en subdirectorios temáticos y tener una responsabilidad clara.
- Los documentos raíz en mayúsculas o con naming heredado deben considerarse legacy o auxiliares salvo que este índice los declare explícitamente vigentes.
- Si una regla ya tiene fuente canónica, los demás documentos deben resumirla y enlazarla, no copiarla.
- Para VeriFactu e `IssuedInvoice`, el estado vigente es: sin `verifactuPolicy`, cardinalidad actual `1 -> 0..1`, objetivo futuro `1 -> 0..N`.

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.activated` | mutation | Implementada |
| supplier | `supplier.deactivated` | mutation | Implementada |
| proforma | `proforma.created` | mutation | Implementada |
| proforma | `proforma.updated` | mutation | Implementada |
| proforma | `proforma.status_changed` | mutation | Implementada |
| proforma | `proforma.archived` | mutation | Implementada |
| proforma | `proforma.unarchived` | mutation | Implementada |
| proforma | `proforma.deleted` | mutation | Implementada |
| proforma | `proforma.duplicated` | mutation | Implementada |
Payment Methods proyecta `name`, `description`, flags `isActive/isSystem/isDefault`; cambiar el default puede producir dos AuditEntries (`default_unset` y `default_set`) bajo la misma operación y T. Payment Terms añade `dueRules` como array atómico ordenado por el dominio, con porcentajes serializados como strings.
Customers y Suppliers proyectan estado, referencia, identidad comercial/fiscal, dirección, contactos, idioma y moneda. Customers añade método/plazo de pago, régimen fiscal y flags de recargo/retención; incluye PII y excluye deliberadamente `legalRecord`. No hay actions lifecycle de Customer. Suppliers separa status del PATCH mediante lifecycle explícito; los inactivos siguen siendo consultables. Companies proyecta identidad, contacto, slug, plazo por defecto y estado; create es unscoped y update/status son scoped en las rutas actuales.
### Proformas
P1 está **COMPLETA**: `proforma.created` se registra después de persistence y reload, y `proforma.updated` captura `before/after`. Create y update comparten la misma transacción con Audit; un fallo de Audit revierte la Proforma, items/taxes y, en create, `DocumentSeries.next_number`. Un update sin cambio funcional no persiste ni genera AuditEntry. Los casos validados de update incluyen description, customer y recipient derivado, items y limpieza de `paymentTermId`.
P2 está **COMPLETA**: `proforma.status_changed`, `proforma.archived`, `proforma.unarchived` y `proforma.deleted`. Todas reciben `IAudit` explícitamente, usan target `proforma` con ID real y referencia como label, y comparten frontera transaccional funcional + Audit. La regla es: `functional mutation + Audit append belong to the same transaction boundary`; si la mutación falla o es no-op no hay Audit, y si Audit falla se revierte la mutación sin AuditEntries parciales committed.
`proforma.status_changed` solo registra transiciones manuales reales. Mismo status es no-op sin persistence ni Audit; los rechazos tampoco auditan. Los estados actuales son `draft`, `sent`, `approved`, `rejected` e `issued`; las transiciones manuales son `draft -> sent`, `sent -> approved|rejected`, `approved -> rejected|draft` y `rejected -> draft`. `issued` no admite transición manual y no existe el estado `accepted`.
`proforma.archived` proyecta `archivedAt` de `null` a timestamp funcional; `proforma.unarchived` lo proyecta de timestamp a `null`. `proforma.deleted` mantiene delete físico, usa `before = toProformaAuditState(proforma)` y `after = {}`, conservando ID y referencia del target aunque el aggregate desaparezca.
`ProformaAuditState` conserva cabecera funcional, recipient con PII, taxConfig JSON-like e items ordenados como array atómico. Excluye `companyId`, IDs técnicos de líneas, totals derivados y timestamps ORM. Los opcionales se normalizan a `null`; Quantity y Percentage usan `value:scale`, y Money usa `currency:value:scale`.
P3 está **COMPLETA**: `DuplicateProformaByIdUseCase -> ProformaDuplicator -> proforma.duplicated`. `ProformaCreator` y `ProformaDuplicator` son servicios hermanos: create sigue el flujo `CreateProformaUseCase -> ProformaCreator -> proforma.created`, mientras que Duplicator no llama a Creator ni usa flags como `skipAudit`, `auditAction` o `shouldAudit`. Para la copia, el target es la nueva Proforma recargada, con su ID y referencia final como label; se registra exactamente una AuditEntry con `before = {}` y `after = toProformaAuditState(duplicatedProforma)`. No se registra `proforma.created` para la duplicate, la source no recibe Audit adicional, no existe `sourceProformaId` en Audit y `ProformaAuditState` no cambia.
Duplicator devuelve `IProformaDuplicatorResult` con la Proforma final y `paymentTermAdaptation`: `null | { source: "inactive_original"; resolution: "customer" | "company" | "none" }`. La adaptación se calcula junto con la resolución del payment term y se propaga por el Use Case, pero no pertenece al snapshot Audit.
P4 sigue **PENDIENTE** para issue: `proforma.issued` e `issued_invoice.created` no están iniciadas ni implementadas.
Evidencia técnica: P1 cuenta con tests unit/application, tests dedicados de `ProformaAuditState`, MariaDB real, rollback de create/update y de numeración DocumentSeries, y success real mediante `SequelizeAuditStore`. P2 cuenta con application status `3/0`, archive `2/0`, unarchive `1/0` y delete `2/0`; su suite MariaDB lifecycle terminó `7/0`, confirmó rollback de status/archive/unarchive/delete y cero nuevas Audit rows fallidas por action/target. P3 cuenta con `ProformaDuplicator` application `4/0` (success, fallo funcional previo a persistence, fallo Audit y fallback de payment term inactivo a Customer) y `DuplicateProformaByIdUseCase` `3/0` (success, source failure y propagación de fallo del Duplicator). Su MariaDB real valida success HTTP `201`, source committed, duplicate draft con ID y referencia nuevos, una `proforma.duplicated` y cero `proforma.created` para la duplicate. El rollback P3 con `FailingAudit` falla después de persistence y reload (`recordMutation = 1`): la source permanece, la duplicate y sus items/taxes no persisten, AuditEvents y AuditChanges no aumentan, no quedan actions parciales y `DocumentSeries.next_number` vuelve al valor previo. Biome y `git diff --check` fueron correctos en P2/P3.
Una `action` es un string jerárquico validado en minúsculas, por ejemplo `customer.updated`. Tras registrarse se convierte en contrato histórico: no debe renombrarse arbitrariamente ni centralizarse en un enum global inexistente.
## Reader, autorización y HTTP

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
Índice de documentación del área de `customer-invoices`, con separación explícita entre contratos vigentes, soporte operativo e histórico.
Índice de documentación del área de `customer-invoices`, con separación explícita entre SSOT vigente, documentación parcialmente superada y material histórico.
## Documentación vigente
## SSOT de lectura rápida
- Semántica operativa de proformas: `proforma-series-contract.md`, `proforma-create-contract.md`, `proforma-tax-config.md`
- Numeración documental: `../document-series/README.md`
- Perfil VeriFactu empresarial: `settings/verifactu-company-profile-backend-design.md`
- Auditoría SSOT transversal: `../documentation-ssot-audit.md`
Regla crítica vigente:
- `IssuedInvoice` no tiene `verifactuPolicy` como requisito actual.
- La cardinalidad vigente es `IssuedInvoice 1 -> 0..1 VerifactuRecord`.
- La dirección futura deseada es `IssuedInvoice 1 -> 0..N VerifactuRecord`.
## Documentación vigente (`CURRENT`)
### Contratos funcionales y de transporte
- [Plazos de pago](../catalogs/payment-terms.md)
- [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md)
- [Contrato de series de proformas](./proforma-series-contract.md)
- [Contrato de create de proformas](./proforma-create-contract.md)
- [Contrato de readiness de emisión de proformas](./proforma-issuance-readiness-contract.md)
@ -19,6 +31,11 @@
- [Contrato de series de issued invoices](./issued-invoice-series-contract.md)
- [Contrato de snapshot documental](./document-series-snapshot-contract.md)
## Documentación vigente con secciones superadas (`PARTIALLY SUPERSEDED`)
- [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md)
Referencia canónica para `CompanyVerifactuProfile` y settings backend; no usar sus secciones antiguas como requisito para reintroducir `IssuedInvoice.verifactuPolicy`.
### Reports y flujos integrados
- [Reports de proformas](./proformas-reports.md)
@ -28,7 +45,7 @@
- [Índice SQL de `customer-invoices`](./sql/README.md)
## Histórico o transicional
## Histórico o transicional (`HISTORICAL`)
- [Split Proformas / Issued Invoices](./split-proformas-issued-invoices-migration.md)
- [InvoiceSeries legacy](./invoice-series.md)
@ -38,3 +55,4 @@
- Para semántica vigente de negocio, empieza por `proforma-series-contract.md`, `proforma-create-contract.md` y `proforma-tax-config.md`.
- Para SQL manual o runbooks, usa `sql/README.md`.
- Para decisiones antiguas, migraciones o trazabilidad, consulta solo la sección histórica.
- Para saber qué documento manda en cada contexto y qué piezas están superadas, consulta `../documentation-ssot-audit.md`.

View File

@ -2,7 +2,7 @@
## 1. Estado del documento
Estado: **IMPLEMENTADO PARCIALMENTE**
Estado: **PARTIALLY SUPERSEDED**
Este documento define el diseño backend del perfil VeriFactu de empresa dentro del módulo `customer-invoices`.
@ -14,12 +14,28 @@ Decisiones consolidadas:
- El estado de configuración es explícito.
- Se permite `obligated_without_verifactu` como estado real.
- Los catálogos de clasificación y exención quedan modelados por código pero no se cierran todavía como enums.
- `IssuedInvoice` deberá persistir dos snapshots distintos en una fase posterior:
- clasificación fiscal efectiva;
- política VeriFactu efectiva.
- `IssuedInvoice` no incorpora actualmente `verifactuPolicy` como requisito vigente.
- La integración vigente de `IssuedInvoice` con VeriFactu es:
- snapshot fiscal efectivo cuando aplique;
- relación operativa actual `IssuedInvoice 1 -> 0..1 VerifactuRecord`.
- La dirección objetivo futura sigue siendo:
- `IssuedInvoice 1 -> 0..N VerifactuRecord` para histórico operativo.
- `VerifactuRecord` mantiene responsabilidad operativa y no absorbe el perfil empresarial.
- El perfil no expone secretos ni passwords de certificados.
Alcance canónico actual de este documento:
- diseño del agregado `CompanyVerifactuProfile`;
- reglas de configuración empresarial VeriFactu;
- API/settings de perfil empresarial;
- interacción conceptual con emisión solo en lo relativo a clasificación fiscal.
Secciones superadas dentro de este mismo documento:
- cualquier requisito que pida persistir `IssuedInvoice.verifactuPolicy`;
- cualquier DDL o criterio de aceptación que reintroduzca `verifactu_profile_status`, `uses_verifactu` o `verifactu_effective_at` en `issued_invoices`;
- cualquier lectura que presente `IssuedInvoice 1 -> 0..N VerifactuRecord` como comportamiento ya implantado.
## 2. Propósito
Proporcionar una fuente de verdad empresarial para:
@ -30,7 +46,7 @@ Proporcionar una fuente de verdad empresarial para:
- fecha efectiva de activación;
- renovación automática si aplica;
- defaults de clasificación fiscal;
- snapshot histórico al emitir una factura.
- snapshot histórico fiscal al emitir una factura.
El diseño debe integrarse con el flujo actual:
@ -246,17 +262,23 @@ export interface IssuedInvoiceFiscalClassificationSnapshot {
Queda diseñado, pero no implementado todavía en esta fase.
## 12. Snapshot de política VeriFactu
## 12. Nota superada sobre política VeriFactu en `IssuedInvoice`
```ts
export interface IssuedInvoiceVerifactuPolicySnapshot {
profileStatus: CompanyVerifactuProfileStatus;
usesVerifactu: boolean;
effectiveAt: string | null;
}
```
La propuesta anterior de añadir un snapshot `verifactuPolicy` a `IssuedInvoice`
queda superada y no forma parte del SSOT vigente.
Queda diseñado, pero no implementado todavía en esta fase.
Estado actual:
- `IssuedInvoice` no debe añadir ni persistir `verifactuPolicy`;
- no debe reintroducir columnas legacy como `verifactu_profile_status`,
`uses_verifactu` o `verifactu_effective_at`;
- `VerifactuRecord` sigue siendo la pieza operativa para el lifecycle VeriFactu;
- la cardinalidad vigente implementada es `IssuedInvoice 1 -> 0..1 VerifactuRecord`.
Dirección futura:
- evolucionar a `IssuedInvoice 1 -> 0..N VerifactuRecord` cuando el histórico
operativo entre en alcance real.
## 13. Resolución durante la emisión
@ -275,8 +297,7 @@ ProformaIssueReadModelAssembler
proforma-to-issued-invoice-props-converter
│
├── resuelve precedencia
├── construye FiscalClassificationSnapshot
└── construye VerifactuPolicySnapshot
└── construye FiscalClassificationSnapshot
│
▼
IssuedInvoice.create(...)
@ -287,7 +308,7 @@ IssuedInvoice.create(...)
`IssuedInvoice`:
- snapshot documental e histórico;
- clasificación fiscal aplicada;
- política VeriFactu aplicada.
- relación documental con el contexto de emisión.
`VerifactuRecord`:
- estado operativo;
@ -295,9 +316,15 @@ IssuedInvoice.create(...)
- QR;
- UUID;
- tipo de operación.
- histórico operativo futuro.
No debe contener defaults empresariales ni obligación/exención.
Cardinalidad:
- vigente: `IssuedInvoice 1 -> 0..1 VerifactuRecord`;
- objetivo futuro: `IssuedInvoice 1 -> 0..N VerifactuRecord`.
## 15. Certificados
El perfil no contiene:
@ -434,7 +461,14 @@ DDL operativo vigente:
```txt
docs/customer-invoices/sql/add-company-verifactu-profiles.sql
```
SQL histórico/superado relacionado con la antigua propuesta de snapshots
VeriFactu en `issued_invoices`:
```txt
docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql
docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql
```
## 22. Concurrencia
@ -454,12 +488,11 @@ Orden recomendado:
```txt
1. ejecutar DDL de `company_verifactu_profiles`
2. ejecutar DDL de snapshots VeriFactu en `issued_invoices`
3. persistencia y GET seguro
4. PUT/configuración
5. UI Settings
6. uso de defaults en emisión
7. enforcement posterior
2. persistencia y GET seguro
3. PUT/configuración
4. UI Settings
5. uso de defaults en emisión
6. enforcement posterior
```
La ausencia de perfil no debe romper automáticamente la emisión actual durante transición.
@ -545,32 +578,40 @@ versionado
## 28. Integración en `IssuedInvoice`
Implementado en Fase B:
Estado documental vigente:
```txt
CompanyVerifactuProfile
→ ProformaIssueReadModelAssembler
→ resolución backend de clasificación/política
→ ProformaToIssuedInvoiceConverter
→ IssuedInvoice
→ persistencia física en issued_invoices
→ configuración empresarial
→ resolución backend de clasificación fiscal cuando aplique
→ IssuedInvoice / emisión
→ VerifactuRecord operativo
```
`IssuedInvoice` mantiene `taxRegime` como snapshot legacy existente y añade además:
`IssuedInvoice` mantiene `taxRegime` como snapshot legacy existente y, como
evolución vigente de diseño, solo puede incorporar snapshots fiscales
compatibles con el documento emitido.
No forma parte del SSOT actual:
```txt
fiscalClassification
verifactuPolicy
```
Decisiones reales:
Sí forma parte del SSOT actual:
```txt
fiscalClassification
```
Decisiones vigentes:
- `taxRegimeCode` se resuelve con precedencia `document -> customer -> company -> sin valor`.
- `operationQualificationCode` se resuelve hoy desde defaults empresariales o `null`.
- `exemptionReasonCode` se resuelve hoy desde defaults empresariales o `null`.
- `verifactuPolicy.profileStatus` se persiste como señal de snapshot V2.
- facturas antiguas sin esa señal cargan con `fiscalClassification = null` y `verifactuPolicy = null`.
- no se inventan labels de `operationQualification` ni `exemptionReason`; se exponen como `null` hasta disponer de catálogo backend real.
- la cardinalidad implementada entre `IssuedInvoice` y `VerifactuRecord` sigue siendo `1 -> 0..1`.
- la evolución futura deseada es `1 -> 0..N`, pero no debe documentarse como runtime actual.
## 29. Tests implementados
@ -591,7 +632,7 @@ Use cases / controllers:
Fase B:
- precedencia `document -> customer -> company` para `taxRegimeCode`;
- fallback legacy sin perfil;
- validación de `IssuedInvoiceVerifactuPolicy`.
- separación explícita entre perfil empresarial y estado operativo VeriFactu.
## 30. Criterios de aceptación
@ -606,7 +647,9 @@ Fase B:
9. `VerifactuRecord` sigue siendo operativo.
10. No se exponen secretos.
11. Empresas sin perfil se leen como `not_configured`.
12. `IssuedInvoice` persiste snapshots de clasificación/política sin reescribir facturas legacy.
12. `IssuedInvoice` no reintroduce `verifactuPolicy` como requisito vigente.
13. La cardinalidad vigente es `IssuedInvoice 1 -> 0..1 VerifactuRecord`.
14. La dirección futura deseada es `IssuedInvoice 1 -> 0..N VerifactuRecord`.
## 31. Regla mental final
@ -618,10 +661,12 @@ CompanyVerifactuProfile
→ política/defaults empresariales
IssuedInvoice
→ snapshot histórico efectivo
→ snapshot fiscal histórico efectivo cuando aplique
→ relación actual 1 -> 0..1 con VerifactuRecord
VerifactuRecord
→ estado operativo
→ futuro histórico 0..N
AEAT integration
→ lifecycle futuro

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/catalogs/payment-terms.md`
Nota SSOT VeriFactu:
- cardinalidad vigente implementada: `IssuedInvoice 1 -> 0..1 VerifactuRecord`;
- direccion futura deseada: `IssuedInvoice 1 -> 0..N VerifactuRecord`;
- este documento no debe usarse para reintroducir `IssuedInvoice.verifactuPolicy`.
Estado: diseno tecnico / fases 1A-1D + 2A-2B
Estado del backend: persistencia V2 activada; integracion con `document-series` activa para numeracion nueva; sin ejecucion confirmada de migracion DB

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
en SQL historico de migracion o validacion.
Regla SSOT relevante para VeriFactu:
- `issued_invoices` no debe reintroducir `verifactu_profile_status`,
`uses_verifactu` ni `verifactu_effective_at` como columnas vigentes;
- la cardinalidad implementada sigue siendo `IssuedInvoice 1 -> 0..1 VerifactuRecord`;
- `0..N` es objetivo futuro, no estado operativo actual.
## SQL vigentes
- `add-company-verifactu-profiles.sql`
- `add-issued-invoice-verifactu-snapshots.sql`
- `add-proforma-document-series-columns.sql`
- `add-proforma-tax-config-columns.sql`
- `add-issued-invoice-reference.sql`
@ -22,6 +28,8 @@ en SQL historico de migracion o validacion.
## SQL históricos / transicionales
- `add-issued-invoice-verifactu-snapshots.sql`
- `drop-issued-invoice-verifactu-snapshot-columns.sql`
- `dev-split-proformas-issued-invoices.sql`
- `validate-split-proformas-issued-invoices.sql`
@ -32,11 +40,17 @@ Estos archivos no deben tratarse como el camino normal de entornos nuevos salvo
1. Verificar que la BD objetivo es inequívocamente de desarrollo.
2. Hacer backup o confirmar que los datos son descartables.
3. Ejecutar `add-company-verifactu-profiles.sql` si el entorno aún no tiene el perfil VeriFactu empresarial.
4. Ejecutar `add-issued-invoice-verifactu-snapshots.sql` si el entorno aún no tiene los snapshots fiscales/política en `issued_invoices`.
5. Ejecutar el DDL vigente que falte para `proformas`.
6. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado.
7. Ejecutar las validaciones SQL vigentes.
8. Levantar backend V2 y validar flujos funcionales.
4. Ejecutar el DDL vigente que falte para `proformas`.
5. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado.
6. Ejecutar las validaciones SQL vigentes.
7. Levantar backend V2 y validar flujos funcionales.
## SQL expresamente superados para VeriFactu en `issued_invoices`
- `add-issued-invoice-verifactu-snapshots.sql` responde a una propuesta anterior
que mezclaba snapshots fiscales con un `verifactuPolicy` hoy descartado.
- `drop-issued-invoice-verifactu-snapshot-columns.sql` se conserva como runbook
correctivo para entornos legacy donde esas columnas todavía existan.
## Propiedades de los scripts históricos de split

View File

@ -1,6 +1,9 @@
-- DDL vigente para snapshots fiscales y de politica VeriFactu en issued_invoices.
-- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado.
-- No ejecutar automaticamente en produccion.
-- Estado documental: SUPERSEDED / solo auditoria legacy.
-- Este script responde a una propuesta antigua que reintroducia
-- `verifactu_profile_status`, `uses_verifactu` y `verifactu_effective_at`
-- en `issued_invoices`.
-- No es SSOT vigente y no debe ejecutarse como camino normal en entornos nuevos.
-- Ejecutar solo para auditar drift historico o comparar esquemas legacy.
SET @target_schema = DATABASE();

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.
-- No ejecutar automaticamente en produccion.

View File

@ -112,6 +112,9 @@ WHERE ii.id IS NULL;
-- ---------------------------------------------------------------------------
-- Verifactu
-- ---------------------------------------------------------------------------
-- Cardinalidad vigente esperada: IssuedInvoice 1 -> 0..1 VerifactuRecord.
-- Direccion futura deseada: IssuedInvoice 1 -> 0..N VerifactuRecord.
-- Este SQL historico no debe interpretarse como prueba de que 0..N ya esta implantado.
SELECT COUNT(*) AS orphan_verifactu_records
FROM verifactu_records vr

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",
"@repo/i18next": "workspace:*",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-audit": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-ui": "workspace:*",
@ -69,4 +70,4 @@
"sequelize": "^6.37.8",
"zod": "^4.3.6"
}
}
}

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"],
context: IIssuedInvoiceServicesContext
) => 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-creator.di";
export * from "./proforma-deleter.di";
export * from "./proforma-duplicator.di";
export * from "./proforma-finder.di";
export * from "./proforma-input-mappers.di";
export * from "./proforma-issuer.di";

View File

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

View File

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

View File

@ -1,8 +1,14 @@
import type { IAudit } from "@repo/rdx-audit";
import type { IProformaRepository } from "../repositories";
import { type IProformaDeleter, ProformaDeleter } from "../services";
export function buildProformaDeleter(deps: { repository: IProformaRepository }): IProformaDeleter {
export function buildProformaDeleter(deps: {
repository: IProformaRepository;
audit: IAudit;
}): IProformaDeleter {
return new ProformaDeleter({
repository: deps.repository,
audit: deps.audit,
});
}

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 IProformaStatusChanger, ProformaStatusChanger } from "../services";
export const buildProformaStatusChanger = (params: {
repository: IProformaRepository;
audit: IAudit;
}): IProformaStatusChanger => {
return new ProformaStatusChanger(params);
};

View File

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

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

View File

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

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 { Result } from "@repo/rdx-utils";
import { ProformaCannotBeArchivedError, ProformaCannotBeUnarchivedError } from "../../../domain";
import {
ProformaCannotBeArchivedError,
ProformaCannotBeUnarchivedError,
} from "../../../domain";
PROFORMA_AUDIT_ACTIONS,
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { IProformaRepository } from "../repositories";
export interface IProformaArchiver {
archive(params: { companyId: UniqueID; id: UniqueID; transaction: unknown }): Promise<Result<void, Error>>;
archive(params: {
companyId: UniqueID;
id: UniqueID;
transaction: unknown;
}): Promise<Result<void, Error>>;
unarchive(params: {
companyId: UniqueID;
id: UniqueID;
@ -17,7 +24,7 @@ export interface IProformaArchiver {
}
export class ProformaArchiver implements IProformaArchiver {
public constructor(private readonly deps: { repository: IProformaRepository }) {}
public constructor(private readonly deps: { repository: IProformaRepository; audit: IAudit }) {}
public async archive(params: {
companyId: UniqueID;
@ -35,6 +42,7 @@ export class ProformaArchiver implements IProformaArchiver {
}
const proforma = proformaResult.data;
const before = toProformaAuditState(proforma);
const canArchiveResult = proforma.ensureCanBeArchived();
if (canArchiveResult.isFailure) {
@ -57,6 +65,27 @@ export class ProformaArchiver implements IProformaArchiver {
);
}
const persistedResult = await this.deps.repository.getByIdIncludingArchivedInCompany(
params.companyId,
params.id,
params.transaction
);
if (persistedResult.isFailure) {
return Result.fail(persistedResult.error);
}
const auditResult = await this.deps.audit.recordMutation({
action: PROFORMA_AUDIT_ACTIONS.ARCHIVED,
target: toProformaAuditTarget(persistedResult.data),
before,
after: toProformaAuditState(persistedResult.data),
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
return Result.ok();
}
@ -76,6 +105,7 @@ export class ProformaArchiver implements IProformaArchiver {
}
const proforma = proformaResult.data;
const before = toProformaAuditState(proforma);
const canUnarchiveResult = proforma.ensureCanBeUnarchived();
if (canUnarchiveResult.isFailure) {
@ -94,13 +124,31 @@ export class ProformaArchiver implements IProformaArchiver {
if (!persistenceResult.data) {
return Result.fail(
new ProformaCannotBeUnarchivedError(
params.id.toString(),
"Proforma is not archived."
)
new ProformaCannotBeUnarchivedError(params.id.toString(), "Proforma is not archived.")
);
}
const persistedResult = await this.deps.repository.getByIdIncludingArchivedInCompany(
params.companyId,
params.id,
params.transaction
);
if (persistedResult.isFailure) {
return Result.fail(persistedResult.error);
}
const auditResult = await this.deps.audit.recordMutation({
action: PROFORMA_AUDIT_ACTIONS.UNARCHIVED,
target: toProformaAuditTarget(persistedResult.data),
before,
after: toProformaAuditState(persistedResult.data),
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
return Result.ok();
}
}

View File

@ -2,25 +2,57 @@ import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import type {
IAudit,
RecordAuditEventShortcutInput,
RecordAuditInput,
RecordAuditMutationShortcutInput,
} from "@repo/rdx-audit";
import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InvoiceStatus, type Proforma } from "../../../domain";
import type { ProformaCreateInputProps } from "../models";
import { ProformaCreator } from "./proforma-creator";
class FakeAudit implements IAudit {
public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = [];
private failure: Error | null = null;
public failWith(error: Error): void {
this.failure = error;
}
async record(_input: RecordAuditInput) {
return Result.ok(undefined);
}
async recordEvent(_input: RecordAuditEventShortcutInput) {
return Result.ok(undefined);
}
async recordMutation(input: RecordAuditMutationShortcutInput) {
this.recordMutationCalls.push(input);
if (this.failure) return Result.fail(this.failure);
return Result.ok(undefined);
}
}
const buildCreateProps = () => {
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
const languageCodeResult = LanguageCode.create("es");
const currencyCodeResult = CurrencyCode.create("EUR");
const globalDiscountPercentageResult = DiscountPercentage.create({ value: 0 });
const statusResult = InvoiceStatus.create("draft");
assert.equal(proformaDateResult.isSuccess, true);
assert.equal(languageCodeResult.isSuccess, true);
assert.equal(currencyCodeResult.isSuccess, true);
assert.equal(globalDiscountPercentageResult.isSuccess, true);
assert.equal(statusResult.isSuccess, true);
return {
companyId: UniqueID.generateNewID(),
status: {} as never,
status: statusResult.data,
proformaSeriesCode: Maybe.none(),
targetInvoiceSeriesCode: Maybe.none(),
proformaDate: proformaDateResult.data,
@ -45,7 +77,7 @@ const buildCreateProps = () => {
},
globalDiscountPercentage: globalDiscountPercentageResult.data,
items: [],
};
} satisfies ProformaCreateInputProps;
};
test("create hereda payment term del customer cuando no llega explicito", async () => {
@ -53,16 +85,23 @@ test("create hereda payment term del customer cuando no llega explicito", async
const props = buildCreateProps();
const proformaId = UniqueID.generateNewID();
let capturedPaymentTermId = Maybe.none<UniqueID>();
let persistedProforma: Proforma | null = null;
let companyLookups = 0;
const audit = new FakeAudit();
const creator = new ProformaCreator({
repository: {
create: async (proforma) => {
create: async (proforma: Proforma) => {
persistedProforma = proforma;
capturedPaymentTermId = proforma.paymentTermId;
return Result.ok(undefined);
},
getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never),
getByIdInCompany: async () => {
if (!persistedProforma) throw new Error("Proforma was not persisted before reload");
return Result.ok(persistedProforma);
},
} as never,
audit,
numberService: {
getNextForCompany: async () =>
Result.ok({
@ -117,6 +156,15 @@ test("create hereda payment term del customer cuando no llega explicito", async
assert.equal(capturedPaymentTermId.isSome(), true);
assert.equal(capturedPaymentTermId.unwrap().equals(customerPaymentTermId), true);
assert.equal(companyLookups, 0);
assert.equal(audit.recordMutationCalls.length, 1);
const [auditCall] = audit.recordMutationCalls;
assert.equal(auditCall.action, "proforma.created");
assert.equal(auditCall.target.type, "proforma");
assert.equal(auditCall.target.id, proformaId.toPrimitive());
assert.equal(auditCall.target.label, "1");
assert.deepEqual(auditCall.before, {});
assert.equal(auditCall.after.status, "draft");
assert.equal(auditCall.after.proformaReference, "1");
});
test("create usa fallback a company default cuando customer no tiene payment term", async () => {
@ -124,15 +172,21 @@ test("create usa fallback a company default cuando customer no tiene payment ter
const props = buildCreateProps();
const proformaId = UniqueID.generateNewID();
let capturedPaymentTermId = Maybe.none<UniqueID>();
let persistedProforma: Proforma | null = null;
const creator = new ProformaCreator({
repository: {
create: async (proforma) => {
create: async (proforma: Proforma) => {
persistedProforma = proforma;
capturedPaymentTermId = proforma.paymentTermId;
return Result.ok(undefined);
},
getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never),
getByIdInCompany: async () => {
if (!persistedProforma) throw new Error("Proforma was not persisted before reload");
return Result.ok(persistedProforma);
},
} as never,
audit: { recordMutation: async () => Result.ok(undefined) } as never,
numberService: {
getNextForCompany: async () =>
Result.ok({
@ -206,15 +260,21 @@ test("create con payment_term_id null explicito conserva el clear", async () =>
};
const proformaId = UniqueID.generateNewID();
let capturedPaymentTermId = Maybe.some(UniqueID.generateNewID());
let persistedProforma: Proforma | null = null;
const creator = new ProformaCreator({
repository: {
create: async (proforma) => {
create: async (proforma: Proforma) => {
persistedProforma = proforma;
capturedPaymentTermId = proforma.paymentTermId;
return Result.ok(undefined);
},
getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never),
getByIdInCompany: async () => {
if (!persistedProforma) throw new Error("Proforma was not persisted before reload");
return Result.ok(persistedProforma);
},
} as never,
audit: { recordMutation: async () => Result.ok(undefined) } as never,
numberService: {
getNextForCompany: async () =>
Result.ok({
@ -265,3 +325,94 @@ test("create con payment_term_id null explicito conserva el clear", async () =>
assert.equal(result.isSuccess, true);
assert.equal(capturedPaymentTermId.isNone(), true);
});
test("create rechaza cuando no puede cargar el customer sin registrar Audit", async () => {
const audit = new FakeAudit();
let createCalls = 0;
const creator = new ProformaCreator({
repository: {
create: async () => {
createCalls += 1;
return Result.ok(undefined);
},
} as never,
customerServices: {
getCustomerById: async () => Result.fail(new Error("Customer not found")),
} as never,
audit,
} as never);
const props = buildCreateProps();
const result = await creator.create({
companyId: props.companyId,
id: UniqueID.generateNewID(),
props,
transaction: undefined,
});
assert.equal(result.isFailure, true);
assert.equal(createCalls, 0);
assert.equal(audit.recordMutationCalls.length, 0);
});
test("create propaga el fallo de Audit", async () => {
const audit = new FakeAudit();
const auditError = new Error("Audit failed");
const customerPaymentTermId = UniqueID.generateNewID();
audit.failWith(auditError);
let persistedProforma: Proforma | null = null;
const creator = new ProformaCreator({
repository: {
create: async (proforma: Proforma) => {
persistedProforma = proforma;
return Result.ok(undefined);
},
getByIdInCompany: async () => {
if (!persistedProforma) throw new Error("Proforma was not persisted before reload");
return Result.ok(persistedProforma);
},
} as never,
audit,
customerServices: {
getCustomerById: async () =>
Result.ok({
paymentTermId: Maybe.some(customerPaymentTermId),
taxRegimeCode: Maybe.none(),
usesEquivalenceSurcharge: false,
usesRetention: false,
}),
} as never,
numberService: {
getNextForCompany: async () =>
Result.ok({
documentSeriesId: UniqueID.generateNewID().toPrimitive(),
documentSeriesCode: "PF",
number: "1",
reference: "1",
}),
},
taxResolver: {
ensureTaxRegimeByCode: async () => Result.ok(undefined),
ensureItemTaxes: async () => Result.fail(new Error("unused")),
findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")),
},
paymentResolver: {
ensurePaymentMethodById: async () => Result.ok(undefined),
ensurePaymentTermById: async () => Result.ok(undefined),
},
proformaSeriesValidator: { ensureValidProformaSeries: async () => Result.ok(undefined) },
targetInvoiceSeriesValidator: {
ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined),
},
companiesServices: { finder: { findById: async () => Result.fail(new Error("unused")) } },
} as never);
const props = buildCreateProps();
const result = await creator.create({
companyId: props.companyId,
id: UniqueID.generateNewID(),
props,
transaction: undefined,
});
assert.equal(result.isFailure, true);
assert.equal(audit.recordMutationCalls.length, 1);
});

View File

@ -1,5 +1,6 @@
import type { ICompanyPublicServices } from "@erp/companies/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { IAudit } from "@repo/rdx-audit";
import { UniqueID, type UniqueID as UniqueIDType } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
@ -11,6 +12,11 @@ import {
Proforma,
ProformaTaxConfig,
} from "../../../domain";
import {
PROFORMA_AUDIT_ACTIONS,
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models";
import type { IProformaRepository } from "../repositories";
@ -42,6 +48,7 @@ export class ProformaCreator implements IProformaCreator {
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices;
audit: IAudit;
}
) {}
@ -144,6 +151,17 @@ export class ProformaCreator implements IProformaCreator {
return Result.fail(verifyResult.error);
}
const auditResult = await this.deps.audit.recordMutation({
action: PROFORMA_AUDIT_ACTIONS.CREATED,
target: toProformaAuditTarget(verifyResult.data),
before: {},
after: toProformaAuditState(verifyResult.data),
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
return Result.ok(verifyResult.data);
}

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 { Result } from "@repo/rdx-utils";
import { ProformaCannotBeDeletedError } from "../../../domain";
import {
PROFORMA_AUDIT_ACTIONS,
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { IProformaRepository } from "../repositories";
export interface IProformaDeleter {
@ -16,6 +22,7 @@ export class ProformaDeleter implements IProformaDeleter {
public constructor(
private readonly deps: {
repository: IProformaRepository;
audit: IAudit;
}
) {}
@ -35,6 +42,7 @@ export class ProformaDeleter implements IProformaDeleter {
}
const proforma = proformaResult.data;
const before = toProformaAuditState(proforma);
const canDeleteResult = proforma.ensureCanBeDeleted();
if (canDeleteResult.isFailure) {
@ -54,7 +62,10 @@ export class ProformaDeleter implements IProformaDeleter {
if (hasIssuedInvoiceResult.data) {
return Result.fail(
new ProformaCannotBeDeletedError(params.id.toString(), "Issued proformas cannot be deleted.")
new ProformaCannotBeDeletedError(
params.id.toString(),
"Issued proformas cannot be deleted."
)
);
}
@ -68,6 +79,17 @@ export class ProformaDeleter implements IProformaDeleter {
return Result.fail(deleteResult.error);
}
const auditResult = await this.deps.audit.recordMutation({
action: PROFORMA_AUDIT_ACTIONS.DELETED,
target: toProformaAuditTarget(proforma),
before,
after: {},
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
return Result.ok();
}
}

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

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 test from "node:test";
import { Result } from "@repo/rdx-utils";
import { Maybe } from "@repo/rdx-utils";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import type {
IAudit,
RecordAuditEventShortcutInput,
RecordAuditInput,
RecordAuditMutationShortcutInput,
} from "@repo/rdx-audit";
import {
City,
Country,
Name,
PostalCode,
Province,
Street,
TINNumber,
UniqueID,
UtcDate,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { ItemAmount, ItemDescription, ItemQuantity, ProformaItemTaxes } from "../../../domain";
import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture";
import { ProformaUpdater } from "./proforma-updater";
const buildProformaDouble = (paymentTermId?: UniqueID) => {
class FakeAudit implements IAudit {
public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = [];
private error: Error | undefined;
failWith(error: Error): void {
this.error = error;
}
async record(_input: RecordAuditInput) {
return Result.ok(undefined);
}
async recordEvent(_input: RecordAuditEventShortcutInput) {
return Result.ok(undefined);
}
async recordMutation(input: RecordAuditMutationShortcutInput) {
this.recordMutationCalls.push(input);
if (this.error) {
return Result.fail(this.error);
}
return Result.ok(undefined);
}
}
const unwrap = <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");
assert.equal(proformaDateResult.isSuccess, true);
@ -20,7 +68,7 @@ const buildProformaDouble = (paymentTermId?: UniqueID) => {
isApproved: () => false,
},
proformaDate: proformaDateResult.data,
paymentTermId: paymentTermId ? Maybe.some(paymentTermId) : Maybe.none(),
paymentTermId: params?.paymentTermId ? Maybe.some(params.paymentTermId) : Maybe.none(),
taxConfig: {
taxMode: "single",
defaultIvaCode: Maybe.none(),
@ -31,16 +79,312 @@ const buildProformaDouble = (paymentTermId?: UniqueID) => {
},
update: (patch: unknown) => {
receivedPatch = patch;
return Result.ok(undefined);
return Result.ok(params?.didChange ?? true);
},
} as never,
getReceivedPatch: () => receivedPatch,
};
};
test("update cambia la descripción en una Proforma real", async () => {
const proforma = createProformaTestFixture({ description: "Before" });
let updateCalls = 0;
const audit = new FakeAudit();
const updater = new ProformaUpdater({
repository: {
getByIdInCompany: async () => Result.ok(proforma),
update: async () => {
updateCalls += 1;
return Result.ok(undefined);
},
} as never,
audit,
taxResolver: {} as never,
paymentResolver: {} as never,
targetInvoiceSeriesValidator: {} as never,
customerServices: {} as never,
});
const result = await updater.update({
companyId: proforma.companyId,
id: proforma.id,
patchProps: { description: Maybe.some("After") },
transaction: undefined,
});
assert.equal(result.isSuccess, true);
assert.equal(updateCalls, 1);
assert.equal(result.data.description.unwrap(), "After");
assert.equal(audit.recordMutationCalls.length, 1);
const [auditCall] = audit.recordMutationCalls;
assert.equal(auditCall.action, "proforma.updated");
assert.equal(auditCall.target.type, "proforma");
assert.equal(auditCall.target.id, proforma.id.toPrimitive());
assert.equal(auditCall.target.label, proforma.proformaReference.toPrimitive());
assert.equal(auditCall.before.description, "Before");
assert.equal(auditCall.after.description, "After");
});
test("update registra customer y recipient en una sola mutación Audit", async () => {
const customerAId = UniqueID.generateNewID();
const customerBId = UniqueID.generateNewID();
const proforma = createProformaTestFixture({ customerId: customerAId });
let updateCalls = 0;
const audit = new FakeAudit();
const updater = new ProformaUpdater({
repository: {
getByIdInCompany: async () => Result.ok(proforma),
update: async () => {
updateCalls += 1;
return Result.ok(undefined);
},
} as never,
audit,
taxResolver: {} as never,
paymentResolver: {} as never,
targetInvoiceSeriesValidator: {} as never,
customerServices: {
getCustomerById: async () =>
Result.ok({
id: customerBId,
name: unwrap(Name.create("Cliente B")),
tin: Maybe.some(unwrap(TINNumber.create("ESB1234567"))),
address: {
street: Maybe.some(unwrap(Street.create("Calle Uno 1"))),
street2: Maybe.some(unwrap(Street.create("Piso 2"))),
city: Maybe.some(unwrap(City.create("Madrid"))),
postalCode: Maybe.some(unwrap(PostalCode.create("28001"))),
province: Maybe.some(unwrap(Province.create("Madrid"))),
country: Maybe.some(unwrap(Country.create("ES"))),
},
}),
} as never,
});
const result = await updater.update({
companyId: proforma.companyId,
id: proforma.id,
patchProps: { customerId: customerBId },
transaction: undefined,
});
assert.equal(result.isSuccess, true);
assert.equal(updateCalls, 1);
assert.equal(result.data.customerId.equals(customerBId), true);
assert.equal(result.data.recipient.unwrap().name.toPrimitive(), "Cliente B");
assert.equal(audit.recordMutationCalls.length, 1);
const [auditCall] = audit.recordMutationCalls;
assert.equal(auditCall.action, "proforma.updated");
assert.equal(auditCall.before.customerId, customerAId.toPrimitive());
assert.equal(auditCall.after.customerId, customerBId.toPrimitive());
assert.notDeepEqual(auditCall.before.recipient, auditCall.after.recipient);
assert.equal(auditCall.before.recipient?.name, "Cliente de prueba");
assert.equal(auditCall.after.recipient?.name, "Cliente B");
});
test("update registra los items antes y después de un cambio funcional", async () => {
const initialItem = {
description: Maybe.some(unwrap(ItemDescription.create("Línea A"))),
quantity: Maybe.some(unwrap(ItemQuantity.create({ value: 1 }))),
unitAmount: Maybe.some(unwrap(ItemAmount.create({ value: 1000, currency_code: "EUR" }))),
itemDiscountPercentage: Maybe.none(),
taxes: ProformaItemTaxes.empty().getProps(),
};
const proforma = createProformaTestFixture({ items: [initialItem] });
let updateCalls = 0;
const audit = new FakeAudit();
const updater = new ProformaUpdater({
repository: {
getByIdInCompany: async () => Result.ok(proforma),
update: async () => {
updateCalls += 1;
return Result.ok(undefined);
},
} as never,
audit,
taxResolver: {
ensureItemTaxes: async () => Result.ok(ProformaItemTaxes.empty().getProps()),
} as never,
paymentResolver: {} as never,
targetInvoiceSeriesValidator: {} as never,
customerServices: {} as never,
});
const result = await updater.update({
companyId: proforma.companyId,
id: proforma.id,
patchProps: {
items: [
{
...initialItem,
description: Maybe.some(unwrap(ItemDescription.create("Línea B"))),
},
],
},
transaction: undefined,
});
assert.equal(result.isSuccess, true);
assert.equal(updateCalls, 1);
assert.equal(audit.recordMutationCalls.length, 1);
const [auditCall] = audit.recordMutationCalls;
assert.equal(auditCall.action, "proforma.updated");
assert.equal(auditCall.before.items.length, auditCall.after.items.length);
assert.notDeepEqual(auditCall.before.items, auditCall.after.items);
assert.equal(auditCall.before.items[0]?.description, "Línea A");
assert.equal(auditCall.after.items[0]?.description, "Línea B");
});
test("update limpia el payment term y lo registra en Audit", async () => {
const existingPaymentTermId = unwrap(UniqueID.create("01900000-0000-7000-8000-000000000005"));
const proforma = createProformaTestFixture({
paymentTermId: Maybe.some(existingPaymentTermId),
});
let updateCalls = 0;
let paymentTermValidationCalls = 0;
const audit = new FakeAudit();
const updater = new ProformaUpdater({
repository: {
getByIdInCompany: async () => Result.ok(proforma),
update: async () => {
updateCalls += 1;
return Result.ok(undefined);
},
} as never,
audit,
taxResolver: {} as never,
paymentResolver: {
ensurePaymentTermById: async () => {
paymentTermValidationCalls += 1;
return Result.fail(new Error("A cleared payment term must not be validated"));
},
} as never,
targetInvoiceSeriesValidator: {} as never,
customerServices: {} as never,
});
const result = await updater.update({
companyId: proforma.companyId,
id: proforma.id,
patchProps: { paymentTermId: Maybe.none() },
transaction: undefined,
});
assert.equal(result.isSuccess, true);
assert.equal(paymentTermValidationCalls, 0);
assert.equal(updateCalls, 1);
assert.equal(result.data.paymentTermId.isNone(), true);
assert.equal(audit.recordMutationCalls.length, 1);
const [auditCall] = audit.recordMutationCalls;
assert.equal(auditCall.action, "proforma.updated");
assert.equal(auditCall.before.paymentTermId, existingPaymentTermId.toPrimitive());
assert.equal(auditCall.after.paymentTermId, null);
});
test("update no persiste ni registra Audit cuando no puede cargar la Proforma", async () => {
let updateCalls = 0;
const audit = new FakeAudit();
const updater = new ProformaUpdater({
repository: {
getByIdInCompany: async () => Result.fail(new Error("Proforma not found")),
update: async () => {
updateCalls += 1;
return Result.ok(undefined);
},
} as never,
audit,
taxResolver: {} as never,
paymentResolver: {} as never,
targetInvoiceSeriesValidator: {} as never,
customerServices: {} as never,
});
const result = await updater.update({
companyId: UniqueID.generateNewID(),
id: UniqueID.generateNewID(),
patchProps: {},
transaction: undefined,
});
assert.equal(result.isFailure, true);
assert.equal(updateCalls, 0);
assert.equal(audit.recordMutationCalls.length, 0);
});
test("update sin cambio funcional no persiste ni registra Audit", async () => {
const proforma = createProformaTestFixture({ description: "Same" });
let updateCalls = 0;
const audit = new FakeAudit();
const updater = new ProformaUpdater({
repository: {
getByIdInCompany: async () => Result.ok(proforma),
update: async () => {
updateCalls += 1;
return Result.ok(undefined);
},
} as never,
audit,
taxResolver: {} as never,
paymentResolver: {} as never,
targetInvoiceSeriesValidator: {} as never,
customerServices: {} as never,
});
const result = await updater.update({
companyId: proforma.companyId,
id: proforma.id,
patchProps: { description: Maybe.some("Same") },
transaction: undefined,
});
assert.equal(result.isSuccess, true);
assert.equal(updateCalls, 0);
assert.equal(audit.recordMutationCalls.length, 0);
assert.equal(result.data.description.unwrap(), "Same");
});
test("update propaga el fallo de Audit tras un cambio funcional", async () => {
const proforma = createProformaTestFixture({ description: "Before" });
let updateCalls = 0;
const audit = new FakeAudit();
audit.failWith(new Error("Audit failed"));
const updater = new ProformaUpdater({
repository: {
getByIdInCompany: async () => Result.ok(proforma),
update: async () => {
updateCalls += 1;
return Result.ok(undefined);
},
} as never,
audit,
taxResolver: {} as never,
paymentResolver: {} as never,
targetInvoiceSeriesValidator: {} as never,
customerServices: {} as never,
});
const result = await updater.update({
companyId: proforma.companyId,
id: proforma.id,
patchProps: { description: Maybe.some("After") },
transaction: undefined,
});
assert.equal(updateCalls, 1);
assert.equal(audit.recordMutationCalls.length, 1);
assert.equal(result.isFailure, true);
});
test("update conserva un payment term inactivo ya asignado si no cambia", async () => {
const existingPaymentTermId = UniqueID.generateNewID();
const { proforma } = buildProformaDouble(existingPaymentTermId);
const { proforma } = buildProformaDouble({ paymentTermId: existingPaymentTermId });
let validationCalls = 0;
const updater = new ProformaUpdater({
@ -81,7 +425,7 @@ test("update conserva un payment term inactivo ya asignado si no cambia", async
test("update permite limpiar el payment term con null explicito", async () => {
const existingPaymentTermId = UniqueID.generateNewID();
const { proforma } = buildProformaDouble(existingPaymentTermId);
const { proforma } = buildProformaDouble({ paymentTermId: existingPaymentTermId });
let validationCalls = 0;
const updater = new ProformaUpdater({
@ -121,7 +465,7 @@ test("update permite limpiar el payment term con null explicito", async () => {
});
test("update rechaza asignar un nuevo payment term inactivo", async () => {
const { proforma } = buildProformaDouble(UniqueID.generateNewID());
const { proforma } = buildProformaDouble({ paymentTermId: UniqueID.generateNewID() });
const updater = new ProformaUpdater({
repository: {
@ -203,6 +547,90 @@ test("update refresca el snapshot del destinatario al cambiar el customer", asyn
assert.equal(result.isSuccess, true);
assert.equal(getReceivedPatch() !== undefined, true);
assert.equal((getReceivedPatch() as { customerId: UniqueID }).customerId.equals(customerId), true);
assert.equal(
(getReceivedPatch() as { customerId: UniqueID }).customerId.equals(customerId),
true
);
assert.equal((getReceivedPatch() as { recipient: Maybe<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 { Maybe, Result } from "@repo/rdx-utils";
import type { ICustomerPublicServices } from "@erp/customers/api";
import {
type Proforma,
type ProformaItemPatchProps,
type ProformaPatchProps,
ProformaTaxConfig,
ProformaRecipient,
ProformaTaxConfig,
} from "../../../domain";
import {
PROFORMA_AUDIT_ACTIONS,
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { ProformaPatchInputProps } from "../models";
import type { IProformaRepository } from "../repositories";
@ -33,6 +39,7 @@ export class ProformaUpdater implements IProformaUpdater {
paymentResolver: ProformaPaymentResolver;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
customerServices: ICustomerPublicServices;
audit: IAudit;
}
) {}
@ -52,6 +59,7 @@ export class ProformaUpdater implements IProformaUpdater {
}
const proforma = existingResult.data;
const before = toProformaAuditState(proforma);
if (proforma.status.isIssued()) {
return Result.fail(
@ -97,6 +105,10 @@ export class ProformaUpdater implements IProformaUpdater {
return Result.fail(updateResult.error);
}
if (!updateResult.data) {
return Result.ok(proforma);
}
// Persistir cambios
const saveResult = await this.deps.repository.update(proforma, transaction);
@ -104,6 +116,17 @@ export class ProformaUpdater implements IProformaUpdater {
return Result.fail(saveResult.error);
}
const auditResult = await this.deps.audit.recordMutation({
action: PROFORMA_AUDIT_ACTIONS.UPDATED,
target: toProformaAuditTarget(proforma),
before,
after: toProformaAuditState(proforma),
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
// Volvemos a recuperar lo guardado para asegurarnos de que los cambios se reflejen correctamente
const verifyResult = await this.deps.repository.getByIdInCompany(companyId, id, transaction);
@ -148,7 +171,9 @@ export class ProformaUpdater implements IProformaUpdater {
}
if (patch.paymentTermId !== undefined) {
if (
if (patch.paymentTermId.isNone()) {
// Una limpieza explícita no referencia ningún término que validar.
} else if (
patch.paymentTermId.isSome() &&
params.currentPaymentTermId.isSome() &&
patch.paymentTermId.unwrap().equals(params.currentPaymentTermId.unwrap())

View File

@ -1,224 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { IPaymentTermPublicFinder } from "@erp/catalogs/api";
import type { ICompanyPublicServices } from "@erp/companies/api";
import type { ITransactionManager } from "@erp/core/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture";
import { DuplicateProformaByIdUseCase } from "./duplicate-proforma-by-id.use-case";
const buildTransactionManager = (): ITransactionManager => ({
const transactionManager: ITransactionManager = {
start: async () => undefined,
getTransaction: () => undefined,
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined),
commit: async () => undefined,
rollback: async () => undefined,
});
};
const buildUseCase = (params?: {
sourcePaymentTermId?: Maybe<UniqueID>;
customerPaymentTermId?: Maybe<UniqueID>;
companyDefaultPaymentTermId?: string | null;
activePaymentTermIds?: Set<string>;
}) => {
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 buildUseCase = (params?: { sourceFailure?: Error; duplicatorFailure?: Error }) => {
const source = createProformaTestFixture();
const duplicated = createProformaTestFixture({ description: "Duplicada" });
let duplicatorCalls = 0;
let receivedParams: unknown;
const useCase = new DuplicateProformaByIdUseCase({
creator: {
create: async (input: { props: { paymentTermId?: Maybe<UniqueID> }; id: UniqueID }) => {
receivedPaymentTermId = input.props.paymentTermId;
return Result.ok({ id: input.id } as never);
duplicator: {
duplicate: async (input) => {
duplicatorCalls += 1;
receivedParams = input;
return params?.duplicatorFailure
? Result.fail(params.duplicatorFailure)
: Result.ok({ proforma: duplicated, paymentTermAdaptation: null });
},
} as never,
},
finder: {
findProformaByIdIncludingArchived: async () =>
Result.ok({
documentSeriesId: Maybe.some(UniqueID.generateNewID().toPrimitive()),
targetInvoiceSeriesCode: Maybe.none(),
customerId,
reference: Maybe.none(),
description: Maybe.none(),
notes: Maybe.none(),
languageCode: { toPrimitive: () => "es" },
currencyCode: { toPrimitive: () => "EUR" },
paymentMethodId: Maybe.none(),
paymentTermId: params?.sourcePaymentTermId ?? Maybe.none(),
taxRegimeCode: Maybe.some("01"),
taxConfig: {
taxMode: "single",
defaultIvaCode: Maybe.none(),
usesEquivalenceSurcharge: false,
defaultRecCode: Maybe.none(),
usesRetention: false,
defaultRetentionCode: Maybe.none(),
},
globalDiscountPercentage: { toPrimitive: () => ({ value: 0, scale: 2 }) },
items: {
getAll: () => [],
},
} as never),
findProformaById: async () => Result.ok({ id: sourceId } as never),
params?.sourceFailure ? Result.fail(params.sourceFailure) : Result.ok(source),
} as never,
fullReadModelAssembler: {
assemble: async () => Result.ok({} as never),
assemble: async () => Result.ok(duplicated),
} as never,
fullSnapshotBuilder: {
toOutput: () => ({ id: sourceId.toPrimitive() }),
toOutput: (proforma) => ({
id: proforma.id.toPrimitive(),
description: proforma.description.getOrUndefined() ?? null,
}),
} as never,
documentSeriesServices: {
listActiveSeries: async (params: { documentType: string }) =>
Result.ok([
{
id: UniqueID.generateNewID().toPrimitive(),
code: params.documentType === "proforma" ? "PF" : "FV",
is_default: true,
},
]),
} as never,
companiesServices,
customerServices,
paymentTermFinder,
transactionManager: buildTransactionManager(),
transactionManager,
});
return {
companyId,
sourceId,
source,
duplicated,
useCase,
getReceivedPaymentTermId: () => receivedPaymentTermId,
getDuplicatorCalls: () => duplicatorCalls,
getReceivedParams: () => receivedParams,
};
};
test("duplicate conserva el payment term original si sigue activo", async () => {
const paymentTermId = UniqueID.generateNewID();
const setup = buildUseCase({
sourcePaymentTermId: Maybe.some(paymentTermId),
activePaymentTermIds: new Set([paymentTermId.toString()]),
});
test("duplicate usa Duplicator y conserva la respuesta final", async () => {
const setup = buildUseCase();
const result = await setup.useCase.execute({
companyId: setup.companyId,
params: { proforma_id: setup.sourceId.toPrimitive() },
companyId: setup.source.companyId,
params: { proforma_id: setup.source.id.toPrimitive() },
});
assert.equal(result.isSuccess, true);
assert.equal(setup.getReceivedPaymentTermId()?.unwrap().equals(paymentTermId), true);
assert.equal(setup.getDuplicatorCalls(), 1);
assert.deepEqual(setup.getReceivedParams(), {
companyId: setup.source.companyId,
source: setup.source,
transaction: undefined,
});
assert.equal(result.data.id, setup.duplicated.id.toPrimitive());
assert.equal(result.data.description, "Duplicada");
assert.equal(result.data.payment_term_adaptation, null);
});
test("duplicate usa el payment term activo del customer si el original está inactivo", async () => {
const originalPaymentTermId = UniqueID.generateNewID();
const customerPaymentTermId = UniqueID.generateNewID();
const setup = buildUseCase({
sourcePaymentTermId: Maybe.some(originalPaymentTermId),
customerPaymentTermId: Maybe.some(customerPaymentTermId),
activePaymentTermIds: new Set([customerPaymentTermId.toString()]),
});
test("duplicate no invoca Duplicator si falla la carga de source", async () => {
const sourceError = new Error("Source not found");
const setup = buildUseCase({ sourceFailure: sourceError });
const result = await setup.useCase.execute({
companyId: setup.companyId,
params: { proforma_id: setup.sourceId.toPrimitive() },
companyId: setup.source.companyId,
params: { proforma_id: setup.source.id.toPrimitive() },
});
assert.equal(result.isSuccess, true);
assert.equal(setup.getReceivedPaymentTermId()?.unwrap().equals(customerPaymentTermId), true);
assert.deepEqual(result.data.payment_term_adaptation, {
source: "inactive_original",
resolution: "customer",
});
assert.equal(result.isFailure, true);
assert.equal(result.error, sourceError);
assert.equal(setup.getDuplicatorCalls(), 0);
});
test("duplicate usa el default activo de company si el original y el customer no son válidos", async () => {
const originalPaymentTermId = UniqueID.generateNewID();
const inactiveCustomerPaymentTermId = UniqueID.generateNewID();
const companyDefaultPaymentTermId = UniqueID.generateNewID();
const setup = buildUseCase({
sourcePaymentTermId: Maybe.some(originalPaymentTermId),
customerPaymentTermId: Maybe.some(inactiveCustomerPaymentTermId),
companyDefaultPaymentTermId: companyDefaultPaymentTermId.toPrimitive(),
activePaymentTermIds: new Set([companyDefaultPaymentTermId.toString()]),
});
test("duplicate propaga el fallo de Duplicator", async () => {
const duplicateError = new Error("Duplicate failed");
const setup = buildUseCase({ duplicatorFailure: duplicateError });
const result = await setup.useCase.execute({
companyId: setup.companyId,
params: { proforma_id: setup.sourceId.toPrimitive() },
companyId: setup.source.companyId,
params: { proforma_id: setup.source.id.toPrimitive() },
});
assert.equal(result.isSuccess, true);
assert.equal(
setup.getReceivedPaymentTermId()?.unwrap().equals(companyDefaultPaymentTermId),
true
);
assert.deepEqual(result.data.payment_term_adaptation, {
source: "inactive_original",
resolution: "company",
});
});
test("duplicate crea sin payment term si no hay fallback válido", async () => {
const originalPaymentTermId = UniqueID.generateNewID();
const inactiveCustomerPaymentTermId = UniqueID.generateNewID();
const inactiveCompanyDefaultPaymentTermId = UniqueID.generateNewID();
const setup = buildUseCase({
sourcePaymentTermId: Maybe.some(originalPaymentTermId),
customerPaymentTermId: Maybe.some(inactiveCustomerPaymentTermId),
companyDefaultPaymentTermId: inactiveCompanyDefaultPaymentTermId.toPrimitive(),
activePaymentTermIds: new Set(),
});
const result = await setup.useCase.execute({
companyId: setup.companyId,
params: { proforma_id: setup.sourceId.toPrimitive() },
});
assert.equal(result.isSuccess, true);
assert.equal(setup.getReceivedPaymentTermId()?.isNone(), true);
assert.deepEqual(result.data.payment_term_adaptation, {
source: "inactive_original",
resolution: "none",
});
assert.equal(result.isFailure, true);
assert.equal(result.error, duplicateError);
assert.equal(setup.getDuplicatorCalls(), 1);
});

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 { ICustomerPublicServices } from "@erp/customers/api";
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { DuplicateProformaByIdParamsRequestDTO } from "../../../../common";
import { InvoiceSerie, InvoiceStatus, type ProformaRecipient } from "../../../domain";
import { InvoiceSeriesNotFoundError } from "../../invoice-series";
import type { ProformaCreateInputProps } from "../models";
import type {
IProformaCreator,
IProformaDuplicator,
IProformaFinder,
IProformaFullReadModelAssembler,
} from "../services";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
type DuplicatePaymentTermAdaptation = {
source: "inactive_original";
resolution: "customer" | "company" | "none";
} | null;
type DuplicateProformaByIdUseCaseInput = {
companyId: UniqueID;
params: DuplicateProformaByIdParamsRequestDTO;
@ -30,23 +18,17 @@ type DuplicateProformaByIdUseCaseInput = {
export class DuplicateProformaByIdUseCase {
public constructor(
private readonly deps: {
creator: IProformaCreator;
duplicator: IProformaDuplicator;
finder: IProformaFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
documentSeriesServices: DocumentSeriesPublicServicesType;
companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices;
paymentTermFinder: IPaymentTermPublicFinder;
transactionManager: ITransactionManager;
}
) {}
public async execute(params: DuplicateProformaByIdUseCaseInput) {
const proformaIdResult = UniqueID.create(params.params.proforma_id);
if (proformaIdResult.isFailure) {
return Result.fail(proformaIdResult.error);
}
if (proformaIdResult.isFailure) return Result.fail(proformaIdResult.error);
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
@ -55,370 +37,29 @@ export class DuplicateProformaByIdUseCase {
proformaIdResult.data,
transaction
);
if (sourceResult.isFailure) return Result.fail(sourceResult.error);
if (sourceResult.isFailure) {
return Result.fail(sourceResult.error);
}
const source = sourceResult.data;
const duplicateId = UniqueID.generateNewID();
const proformaDateResult = UtcDate.createFromISO(new Date().toISOString().slice(0, 10));
if (proformaDateResult.isFailure) {
return Result.fail(proformaDateResult.error);
}
const proformaSeriesCodeResult = await this.resolveDuplicateSeriesCode({
const duplicatedResult = await this.deps.duplicator.duplicate({
companyId: params.companyId,
sourceDocumentSeriesId: source.documentSeriesId,
source: sourceResult.data,
transaction,
});
if (proformaSeriesCodeResult.isFailure) {
return Result.fail(proformaSeriesCodeResult.error);
}
const targetInvoiceSeriesCodeResult = await this.resolveValidTargetInvoiceSeriesCode({
companyId: params.companyId,
targetInvoiceSeriesCode: source.targetInvoiceSeriesCode,
transaction,
});
if (targetInvoiceSeriesCodeResult.isFailure) {
return Result.fail(targetInvoiceSeriesCodeResult.error);
}
const paymentTermResolutionResult = await this.resolveDuplicatePaymentTermId({
companyId: params.companyId,
sourceCustomerId: source.customerId,
sourcePaymentTermId: source.paymentTermId,
transaction,
});
if (paymentTermResolutionResult.isFailure) {
return Result.fail(paymentTermResolutionResult.error);
}
const props: ProformaCreateInputProps = {
companyId: params.companyId,
status: InvoiceStatus.draft(),
proformaSeriesCode: proformaSeriesCodeResult.data,
targetInvoiceSeriesCode: targetInvoiceSeriesCodeResult.data,
proformaDate: proformaDateResult.data,
operationDate: Maybe.none(),
customerId: source.customerId,
recipient: Maybe.none<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);
}
if (duplicatedResult.isFailure) return Result.fail(duplicatedResult.error);
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId: params.companyId,
proforma: duplicatedResult.data,
proforma: duplicatedResult.data.proforma,
transaction,
});
if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
if (readModelResult.isFailure) return Result.fail(readModelResult.error);
return Result.ok({
...this.deps.fullSnapshotBuilder.toOutput(readModelResult.data),
payment_term_adaptation: paymentTermResolutionResult.data.adaptation,
payment_term_adaptation: duplicatedResult.data.paymentTermAdaptation,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
private async resolveDuplicateSeriesCode(params: {
companyId: UniqueID;
sourceDocumentSeriesId: Maybe<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 test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import type { ITransactionManager } from "@erp/core/api";
import { CurrencyCode, LanguageCode, UtcDate, UniqueID } from "@repo/rdx-ddd";
import { DiscountPercentage } from "@erp/core/api";
import type { IAudit, RecordAuditMutationShortcutInput } from "@repo/rdx-audit";
import {
City,
Country,
CurrencyCode,
LanguageCode,
Name,
PostalCode,
Province,
Street,
TINNumber,
UniqueID,
UtcDate,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { ProformaToIssuedInvoiceCreateProps } from "../../issued-invoices/services/proforma-to-issued-invoice-props-converter";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices/services/issued-invoice-public-services.interface";
import {
InvoiceAmount,
InvoiceNumber,
InvoicePaymentMethod,
InvoicePaymentTerm,
InvoiceSerie,
InvoiceStatus,
InvoiceTaxRegime,
IssuedInvoice,
IssuedInvoiceFiscalClassification,
IssuedInvoiceItems,
IssuedInvoiceRecipient,
IssuedInvoiceTaxes,
Proforma,
ProformaItems,
ProformaTaxConfig,
} from "../../../domain";
import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices/services/issued-invoice-public-services.interface";
import type { ProformaToIssuedInvoiceCreateProps } from "../../issued-invoices/services/proforma-to-issued-invoice-props-converter";
import { ProformaIssuer } from "../services/proforma-issuer";
import { IssueProformaUseCase } from "./issue-proforma.use-case";
type CreateIssuedInvoiceProps = Parameters<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 => ({
start: async () => undefined,
getTransaction: () => undefined,
@ -33,9 +149,7 @@ const buildCreateIssuedInvoiceProps = (): CreateIssuedInvoiceProps =>
verifactu: Maybe.none(),
}) as CreateIssuedInvoiceProps;
const buildIssueableProforma = (params?: {
operationDate?: Maybe<UtcDate>;
}) => {
const buildIssueableProforma = (params?: { operationDate?: Maybe<UtcDate> }) => {
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
const operationDateResult = UtcDate.createFromISO("2026-08-17");
const languageCodeResult = LanguageCode.create("es");
@ -226,14 +340,18 @@ test("issue falla antes de persistir si la proforma no tiene operation date", as
test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async () => {
const proforma = buildIssueableProforma();
const reloadedInvoice = createReloadedIssuedInvoice(UniqueID.generateNewID(), proforma.companyId);
const audit = new SuccessAudit();
let capturedProps: CreateIssuedInvoiceProps | undefined;
const useCase = new IssueProformaUseCase({
issuedInvoiceServices: {
createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => {
capturedProps = props;
return Result.ok({ id: UniqueID.generateNewID() } as never);
return Result.ok(reloadedInvoice);
},
getIssuedInvoiceById: async (id) =>
Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)),
} as never,
finder: {
findProformaById: async () => Result.ok(proforma),
@ -270,6 +388,7 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async
markAsIssuedByIdInCompany: async () => Result.ok(undefined),
} as never,
transactionManager: buildTransactionManager(),
audit,
});
const result = await useCase.execute({
@ -277,7 +396,7 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async
proformaId: proforma.id.toPrimitive(),
});
assert.equal(result.isSuccess, true);
assert.equal(result.isSuccess, true, result.isFailure ? result.error.message : "");
if (!capturedProps) {
assert.fail("Expected createIssuedInvoice to be called");
}
@ -289,14 +408,18 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async
test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async () => {
const proforma = buildIssueableProforma();
const reloadedInvoice = createReloadedIssuedInvoice(UniqueID.generateNewID(), proforma.companyId);
const audit = new SuccessAudit();
let capturedProps: CreateIssuedInvoiceProps | undefined;
const useCase = new IssueProformaUseCase({
issuedInvoiceServices: {
createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => {
capturedProps = props;
return Result.ok({ id: UniqueID.generateNewID() } as never);
return Result.ok(reloadedInvoice);
},
getIssuedInvoiceById: async (id) =>
Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)),
} as never,
finder: {
findProformaById: async () => Result.ok(proforma),
@ -333,6 +456,7 @@ test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async (
markAsIssuedByIdInCompany: async () => Result.ok(undefined),
} as never,
transactionManager: buildTransactionManager(),
audit,
});
const result = await useCase.execute({
@ -340,7 +464,7 @@ test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async (
proformaId: proforma.id.toPrimitive(),
});
assert.equal(result.isSuccess, true);
assert.equal(result.isSuccess, true, result.isFailure ? result.error.message : "");
if (!capturedProps) {
assert.fail("Expected createIssuedInvoice to be called");
}
@ -405,3 +529,88 @@ test("issue propaga el fallo de persistencia VeriFactu y no marca la proforma co
assert.match(result.error.message, /verifactu insert failed/i);
assert.equal(markAsIssuedCalls, 0);
});
test("issue registra issued_invoice.created con el estado final de la factura", async () => {
const proforma = buildIssueableProforma();
const audit = new SuccessAudit();
const transaction = {};
let createdInvoiceId: UniqueID | undefined;
let reloadCalls = 0;
let reloadCompanyId: UniqueID | undefined;
let reloadTransaction: unknown;
let reloadedInvoice: IssuedInvoice | undefined;
const useCase = new IssueProformaUseCase({
issuedInvoiceServices: {
createIssuedInvoice: async (id) => {
createdInvoiceId = id;
return Result.ok(createReloadedIssuedInvoice(id, proforma.companyId));
},
getIssuedInvoiceById: async (id, context) => {
reloadCalls += 1;
reloadCompanyId = context.companyId;
reloadTransaction = context.transaction;
reloadedInvoice = createReloadedIssuedInvoice(id, context.companyId);
return Result.ok(reloadedInvoice);
},
},
finder: { findProformaById: async () => Result.ok(proforma) },
issuer: {
issueProforma: (): Result<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 { IAudit } from "@repo/rdx-audit";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit";
import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain";
import type { IssueProformaCommand } from "../commands";
import type { IProformaRepository } from "../repositories";
@ -26,6 +28,7 @@ export class IssueProformaUseCase {
public constructor(
private readonly deps: {
issuedInvoiceServices: IIssuedInvoicePublicServices;
audit: IAudit;
finder: IProformaFinder;
issuer: IProformaIssuer;
issueReadModelAssembler: IProformaIssueReadModelAssembler;
@ -127,6 +130,31 @@ export class IssueProformaUseCase {
return Result.fail(markAsIssuedResult.error);
}
const reloadedInvoiceResult = await this.deps.issuedInvoiceServices.getIssuedInvoiceById(
issuedInvoiceId,
{ companyId, transaction }
);
if (reloadedInvoiceResult.isFailure) {
return Result.fail(reloadedInvoiceResult.error);
}
const reloadedInvoice = reloadedInvoiceResult.data;
const auditResult = await this.deps.audit.recordMutation({
action: "issued_invoice.created",
target: {
type: "issued_invoice",
id: reloadedInvoice.id.toPrimitive(),
label: reloadedInvoice.invoiceReference,
},
before: {},
after: toIssuedInvoiceAuditState(reloadedInvoice),
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
return Result.ok(this.deps.issueResponseSnapshotBuilder.toOutput(invoiceResult.data));
} catch (error: unknown) {
return Result.fail(error as Error);

View File

@ -11,8 +11,8 @@ import {
} from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { InvoiceNumber, InvoiceStatus } from "../../common";
import { ProformaItems } from "../entities";
import { InvoiceNumber, InvoiceStatus, ItemDescription } from "../../common";
import { ProformaItem, ProformaItems } from "../entities";
import { ProformaTaxConfig } from "../value-objects";
import { Proforma } from "./proforma.aggregate";
@ -24,6 +24,7 @@ const buildApprovedProforma = (params?: {
itemsCount?: number;
status?: InvoiceStatus;
linkedInvoiceId?: Maybe<UniqueID>;
items?: ProformaItem[];
}) => {
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
const operationDateResult = UtcDate.createFromISO("2026-08-17");
@ -48,7 +49,8 @@ const buildApprovedProforma = (params?: {
assert.equal(discountResult.isSuccess, true);
assert.equal(taxConfigResult.isSuccess, true);
const items = Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never);
const items =
params?.items ?? Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never);
return Proforma.rehydrate(
{
@ -90,6 +92,29 @@ const buildApprovedProforma = (params?: {
);
};
const buildItem = (description?: string): ProformaItem => {
const itemDescription = description ? ItemDescription.create(description) : undefined;
if (itemDescription) {
assert.equal(itemDescription.isSuccess, true);
}
const itemResult = ProformaItem.create({
description: itemDescription ? Maybe.some(itemDescription.data) : Maybe.none(),
quantity: Maybe.none(),
unitAmount: Maybe.none(),
itemDiscountPercentage: Maybe.none(),
taxes: {
iva: Maybe.none(),
rec: Maybe.none(),
retention: Maybe.none(),
},
});
assert.equal(itemResult.isSuccess, true);
return itemResult.data;
};
test("markAsIssued rechaza proformas sin operationDate", () => {
const proforma = buildApprovedProforma({
operationDate: Maybe.none(),
@ -159,3 +184,65 @@ test("getIssuanceBlockers informa already_issued cuando existe linked invoice",
["already_issued"]
);
});
test("update informa no-op cuando los valores funcionales son iguales", () => {
const proforma = buildApprovedProforma({ itemsCount: 0 });
const result = proforma.update({
description: Maybe.some("Servicio mensual"),
items: [],
});
assert.equal(result.isSuccess, true);
assert.equal(result.data, false);
assert.equal(proforma.description.unwrap(), "Servicio mensual");
assert.equal(proforma.items.valued().length, 0);
});
test("update informa cambio cuando cambia un campo funcional", () => {
const proforma = buildApprovedProforma({ itemsCount: 0 });
const result = proforma.update({
description: Maybe.some("Servicio actualizado"),
});
assert.equal(result.isSuccess, true);
assert.equal(result.data, true);
assert.equal(proforma.description.unwrap(), "Servicio actualizado");
});
test("update distingue las transiciones entre null y valor", () => {
const emptyDescription = buildApprovedProforma({
itemsCount: 0,
description: Maybe.none(),
});
const sameNull = emptyDescription.update({ description: Maybe.none() });
const nullToValue = emptyDescription.update({ description: Maybe.some("Servicio") });
const valueToNull = emptyDescription.update({ description: Maybe.none() });
assert.equal(sameNull.data, false);
assert.equal(nullToValue.data, true);
assert.equal(valueToNull.data, true);
});
test("update evita reescribir items semanticamente iguales", () => {
const item = buildItem("Linea A");
const proforma = buildApprovedProforma({ items: [item] });
const result = proforma.update({ items: [item.getProps()] });
assert.equal(result.isSuccess, true);
assert.equal(result.data, false);
assert.equal(proforma.items.valued().length, 0);
});
test("update detecta cambios y reordenaciones de items", () => {
const first = buildItem("Linea A");
const second = buildItem("Linea B");
const proforma = buildApprovedProforma({ items: [first, second] });
const reordered = proforma.update({ items: [second.getProps(), first.getProps()] });
assert.equal(reordered.isSuccess, true);
assert.equal(reordered.data, true);
});

View File

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

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);
},
getIssuedInvoiceById: async (id: UniqueID, context: IIssuedInvoiceServicesContext) =>
repository.getByIdInCompany(context.companyId, id, context.transaction),
};
}

View File

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

View File

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

View File

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

View File

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