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:
parent
7033d02a69
commit
e0ba5ef229
10
AGENTS.md
10
AGENTS.md
@ -96,7 +96,8 @@ No introducir capas, adaptadores, factories, genéricos, eventos, contenedores D
|
||||
|
||||
## Comentarios y documentación
|
||||
|
||||
Los comentarios deben explicar:
|
||||
### Los comentarios
|
||||
Deben explicar:
|
||||
|
||||
- por qué existe una decisión;
|
||||
- qué riesgo evita;
|
||||
@ -116,7 +117,12 @@ Documentar mediante TSDoc las APIs públicas cuando aporten información sobre:
|
||||
|
||||
Después de un refactor, actualizar o eliminar los comentarios que hayan quedado obsoletos.
|
||||
|
||||
La documentación del proyecto debe redactarse en castellano.
|
||||
### La documentación del proyecto
|
||||
|
||||
- debe redactarse en castellano.
|
||||
- Reside en /docs.
|
||||
- Utilizar ficheros Markdown
|
||||
- Los nombres de los ficheros deben estar en castellano.
|
||||
|
||||
Reglas de estilo para documentación:
|
||||
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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`.
|
||||
|
||||
@ -1,779 +0,0 @@
|
||||
> **Documento histórico / deprecado**
|
||||
>
|
||||
> Este documento refleja un diseño previo del sistema transversal de auditoría y no constituye la especificación vigente.
|
||||
>
|
||||
> La referencia canónica y consolidada actual es:
|
||||
>
|
||||
> `docs/architecture/audit-system-design.md`
|
||||
>
|
||||
> Este fichero se conserva únicamente como referencia histórica.
|
||||
|
||||
**DOCUMENTO DE DISEÑO**
|
||||
|
||||
Sistema transversal de auditoría
|
||||
|
||||
**Backend ERP · Estado consolidado**
|
||||
|
||||
Versión de diseño: 1.0
|
||||
Fecha: 24 de agosto de 2026
|
||||
Estado: DISEÑO CERRADO PARA IMPLEMENTACIÓN
|
||||
|
||||
**Propósito
|
||||
**Definir una infraestructura de auditoría portable, append-only y fácil de integrar, capaz de registrar eventos y mutaciones con contexto de operación, preservar atomicidad con las transacciones Sequelize del ERP y soportar una futura pantalla administrativa con autorización por scopes o acceso global.
|
||||
|
||||
# 1\. Resumen ejecutivo
|
||||
|
||||
La auditoría se diseña como una capacidad técnica transversal, no como un módulo funcional del ERP. El núcleo portable vive en \`@repo/rdx-audit\`; el host configura una única instancia de \`Audit\` y la distribuye por \`ModuleParams\`, igual que \`database\` y \`logger\`.
|
||||
|
||||
- \`IAudit.record()\` es la operación canónica. \`recordEvent()\` y \`recordMutation()\` son atajos tipados sin lógica alternativa.
|
||||
- \`@repo/rdx-audit\` no depende de Company, tenant, Express, Sequelize, Criteria, roles, permisos ni del \`OperationContext\` concreto del ERP.
|
||||
- El ámbito de un hecho se modela como \`scope: { type, id, label } | null\`. \`scope=null\` significa evento sin ámbito, nunca acceso global.
|
||||
- La autorización de lectura es responsabilidad de la aplicación host y queda separada del scope almacenado en el evento.
|
||||
- Las mutaciones auditadas en producción deben persistirse en la misma transacción Sequelize que la modificación funcional.
|
||||
- \`SequelizeTransactionContext\` se introduce por DI y solo expone la transacción activa a infraestructura transversal; repositorios y casos de uso siguen recibiendo la transacción explícitamente.
|
||||
- El store portable solo conoce \`append(entry)\`. \`SequelizeAuditStore\`, \`FileAuditStore\` o un store externo son adapters intercambiables.
|
||||
- La lectura administrativa usa un \`SequelizeAuditReader\` server-side apoyado en \`@repo/rdx-criteria/server\`, con whitelist propia de filtros y una política de acceso separada.
|
||||
- No se usa Event Sourcing. La auditoría tampoco sustituye a logs, tracing, historial de versiones de documentos ni BusinessVersion.
|
||||
|
||||
| **Decisión** | **Resultado** |
|
||||
| ------------------------------- | -------------------------------------------------------------- |
|
||||
| Arquitectura | Paquete portable + adapters host-specific |
|
||||
| Persistencia productiva inicial | MariaDB/Sequelize, tablas \`audit_events\` y \`audit_changes\` |
|
||||
| Mutaciones | Atómicas con la transacción funcional |
|
||||
| Eventos | Pueden persistirse con o sin transacción activa según contexto |
|
||||
| Lectura | Reader server-side; no forma parte del core portable |
|
||||
| Autorización | Fuera de \`rdx-audit\`; puede ser scoped o unrestricted |
|
||||
| Adopción | Incremental por módulo y operación |
|
||||
|
||||
# 2\. Objetivos y alcance
|
||||
|
||||
Proporcionar un Audit Trail reutilizable para registrar quién o qué realizó una acción, en qué operación, dentro de qué ámbito, sobre qué recurso y qué cambió cuando exista una mutación.
|
||||
|
||||
## 2.1 Objetivos específicos
|
||||
|
||||
- Integración mínima en cualquier módulo mediante \`IAudit\`.
|
||||
- Persistencia append-only e identidad propia de cada \`AuditEntry\`.
|
||||
- Modelo portable y serializable compatible con múltiples stores.
|
||||
- Diff determinista para mutaciones a partir de \`before\` y \`after\` controlados por el módulo propietario.
|
||||
- Contexto automático de source, operación, actor y scope.
|
||||
- Atomicidad fuerte con MariaDB/Sequelize para mutaciones funcionales.
|
||||
- Lectura eficiente para una futura UI administrativa con filtros, detalle y paginación.
|
||||
- Diseño compatible con un administrador limitado a uno o varios scopes y con un administrador global.
|
||||
|
||||
## 2.2 Fuera de alcance
|
||||
|
||||
- Event Sourcing o reconstrucción del estado de negocio a partir del audit log.
|
||||
- BusinessVersion de presupuestos, proformas u otros documentos funcionales.
|
||||
- DocumentVersion para binarios o gestor documental.
|
||||
- Auditar automáticamente todas las lecturas o validaciones fallidas.
|
||||
- Sistema SIEM, alertas o severidades de seguridad.
|
||||
- Retención legal, archivado, particionado o purge en esta primera versión.
|
||||
- Búsqueda full-text sobre \`before_value\` / \`after_value\`.
|
||||
- Histórico granular de arrays o líneas individuales en la primera versión.
|
||||
|
||||
# 3\. Terminología e invariantes
|
||||
|
||||
| **Concepto** | **Semántica** |
|
||||
| ------------- | ---------------------------------------------------------------------------- |
|
||||
| AuditEntry | Unidad histórica append-only generada por Audit. |
|
||||
| Event | Hecho auditable sin diff de estado. |
|
||||
| Mutation | Hecho auditable con uno o más cambios derivados de before/after. |
|
||||
| Source | Sistema que produjo el registro, p.ej. \`application:uecko-erp\`. |
|
||||
| Actor | Quién o qué ejecutó la acción. |
|
||||
| Scope | Ámbito donde ocurrió; puede ser null. |
|
||||
| Target | Recurso o categoría sobre la que ocurrió la acción. |
|
||||
| Operation | Ejecución lógica local que puede producir varios AuditEntry. |
|
||||
| CorrelationId | Identificador que relaciona varias operaciones, incluso entre sistemas. |
|
||||
| Action | Identificador histórico estable de la operación, p.ej. \`customer.updated\`. |
|
||||
|
||||
**Invariante de autorización
|
||||
**\`AuditEntry.scope\` describe dónde ocurrió el hecho. No expresa lo que un usuario puede consultar. \`scope=null\` nunca significa wildcard, superadmin ni acceso global.
|
||||
|
||||
# 4\. Arquitectura
|
||||
|
||||
apps/server
|
||||
│
|
||||
├── OperationContextStore
|
||||
├── SequelizeTransactionContext
|
||||
├── Audit
|
||||
│ ├── IAuditContextProvider
|
||||
│ └── IAuditStore
|
||||
│ └── SequelizeAuditStore
|
||||
│
|
||||
└── initModules(ModuleParams)
|
||||
├── database
|
||||
├── logger
|
||||
├── audit
|
||||
└── transactionContext
|
||||
|
||||
Los módulos no declaran \`dependencies: \["audit"\]\` ni resuelven servicios \`audit:\*\`. \`audit\` es infraestructura transversal suministrada por el runtime.
|
||||
|
||||
## 4.1 Flujo de una mutación
|
||||
|
||||
HTTP / job / integration
|
||||
│
|
||||
▼
|
||||
OperationContext
|
||||
│
|
||||
▼
|
||||
Use Case / Application Service
|
||||
│
|
||||
▼
|
||||
TransactionManager.complete()
|
||||
│
|
||||
├── TransactionContext = T1
|
||||
├── repository.save(..., T1)
|
||||
└── audit.recordMutation(...)
|
||||
│
|
||||
▼
|
||||
AuditEntry
|
||||
│
|
||||
▼
|
||||
SequelizeAuditStore
|
||||
│
|
||||
└── TransactionContext.current() = T1
|
||||
│
|
||||
▼
|
||||
COMMIT o ROLLBACK
|
||||
|
||||
# 5\. Paquete portable \`@repo/rdx-audit\`
|
||||
|
||||
## 5.1 API pública
|
||||
|
||||
export interface IAudit {
|
||||
record(input: RecordAuditInput): Promise<Result<void, AuditError>>;
|
||||
recordEvent(input: RecordAuditEventShortcutInput): Promise<Result<void, AuditError>>;
|
||||
recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result<void, AuditError>>;
|
||||
}
|
||||
<br/>export interface IAuditStore {
|
||||
append(entry: AuditEntry): Promise<Result<void, AuditStoreError>>;
|
||||
}
|
||||
|
||||
\`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<{ \[key: string\]: AuditValue }>;
|
||||
export type AuditArray = readonly AuditValue\[\];
|
||||
export type AuditValue = AuditScalar | AuditObject | AuditArray;
|
||||
export type AuditState = Readonly<Record<string, AuditValue>>;
|
||||
|
||||
- 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<T>(transaction: Transaction, callback: () => T): T;
|
||||
current(): Transaction | null;
|
||||
}
|
||||
|
||||
- Los repositorios y servicios funcionales continúan recibiendo \`Transaction\` explícitamente.
|
||||
- \`SequelizeTransactionManager.complete()\` abre el scope del context alrededor del callback.
|
||||
- Debe conservarse el comportamiento actual: un \`Result.fail\` dentro del callback provoca rollback.
|
||||
- Las transacciones anidadas quedan prohibidas inicialmente; no hay evidencia productiva que requiera savepoints o nesting.
|
||||
- El contexto se inyecta; no se utiliza singleton global.
|
||||
|
||||
**Deuda técnica detectada
|
||||
**El manager actual declara protección frente a anidamiento, pero su \`complete()\` no mantiene \`\_transaction\`; esa incoherencia debe corregirse o cubrirse con la nueva política de nesting, sin mezclar una refactorización mayor con audit.
|
||||
|
||||
# 12\. Stores y consistencia
|
||||
|
||||
## 12.1 Puerto portable
|
||||
|
||||
export interface IAuditStore {
|
||||
append(entry: AuditEntry): Promise<Result<void, AuditStoreError>>;
|
||||
}
|
||||
|
||||
- Solo append. No update, delete ni upsert.
|
||||
- La lectura usa otro adapter; no se mezcla en el write port.
|
||||
- El entry se genera completamente antes del store.
|
||||
- El store debe preservar el orden de \`changes\`.
|
||||
- Un fallo del store no genera a su vez otro AuditEntry.
|
||||
|
||||
## 12.2 Adapters previstos
|
||||
|
||||
| **Adapter** | **Garantía** |
|
||||
| ------------------- | ---------------------------------------------------------------------- |
|
||||
| SequelizeAuditStore | Store productivo principal; puede compartir la transacción funcional. |
|
||||
| FileAuditStore | Útil para desarrollo/best-effort; no es ACID respecto al negocio. |
|
||||
| ExternalAuditStore | Portable, pero no puede garantizar atomicidad con MariaDB por sí solo. |
|
||||
| Outbox + external | Evolución futura si se necesita entrega externa fuerte sin 2PC. |
|
||||
|
||||
**Regla de producción
|
||||
**Una configuración que exija consistencia fuerte no debe aceptar como store primario un FileAuditStore o un external store no transaccional. La validación de esa combinación pertenece al bootstrap del host, no a \`rdx-audit\`.
|
||||
|
||||
# 13\. \`SequelizeAuditStore\` y esquema MariaDB
|
||||
|
||||
La representación relacional utiliza dos tablas. No existe \`audit_change_sets\`.
|
||||
|
||||
audit_events
|
||||
1 ─────── 0..N audit_changes
|
||||
|
||||
## 13.1 \`audit_events\`
|
||||
|
||||
| **Columna** | **Tipo propuesto** | **Nulabilidad** |
|
||||
| -------------- | ------------------ | --------------- |
|
||||
| id | VARCHAR(64) | NOT NULL, PK |
|
||||
| kind | VARCHAR(32) | NOT NULL |
|
||||
| occurred_at | DATETIME(3) | NOT NULL |
|
||||
| source_type | VARCHAR(64) | NOT NULL |
|
||||
| source_id | VARCHAR(128) | NOT NULL |
|
||||
| operation_id | VARCHAR(128) | NOT NULL |
|
||||
| correlation_id | VARCHAR(128) | NOT NULL |
|
||||
| origin | VARCHAR(64) | NOT NULL |
|
||||
| actor_type | VARCHAR(64) | NOT NULL |
|
||||
| actor_id | VARCHAR(128) | NULL |
|
||||
| actor_label | VARCHAR(255) | NULL |
|
||||
| scope_type | VARCHAR(64) | NULL |
|
||||
| scope_id | VARCHAR(128) | NULL |
|
||||
| scope_label | VARCHAR(255) | NULL |
|
||||
| action | VARCHAR(191) | NOT NULL |
|
||||
| target_type | VARCHAR(64) | NOT NULL |
|
||||
| target_id | VARCHAR(128) | NULL |
|
||||
| target_label | VARCHAR(255) | NULL |
|
||||
|
||||
## 13.2 \`audit_changes\`
|
||||
|
||||
| **Columna** | **Tipo propuesto** | **Notas** |
|
||||
| -------------- | ------------------ | ------------------------ |
|
||||
| audit_event_id | VARCHAR(64) | PK/FK -> audit_events.id |
|
||||
| position | INT UNSIGNED | PK; preserva orden |
|
||||
| path | VARCHAR(255) | NOT NULL |
|
||||
| kind | VARCHAR(32) | set/unset/change |
|
||||
| before_value | JSON | NULL |
|
||||
| after_value | JSON | NULL |
|
||||
|
||||
- MariaDB \`JSON\` es adecuado para presentar valores históricos; no se diseña como mecanismo principal de búsqueda.
|
||||
- No existen FKs a Company, User, Customer, Proforma u otros recursos funcionales.
|
||||
- \`audit_changes -> audit_events\` sí tiene integridad referencial interna; \`ON DELETE CASCADE\` puede reservarse para retención administrativa futura.
|
||||
- No se usan timestamps Sequelize automáticos \`createdAt/updatedAt\`.
|
||||
- No se añade \`stored_at\`, soft delete, particionado, metadata genérica ni índices JSON en v1.
|
||||
|
||||
## 13.3 Constraints
|
||||
|
||||
- \`audit_events.kind IN (event, mutation)\`.
|
||||
- \`audit_changes.kind IN (set, unset, change)\`.
|
||||
- Scope: o todos sus campos son null, o al menos \`scope_type\` y \`scope_id\` están presentes.
|
||||
- \`kind=mutation\` implica \`target_id IS NOT NULL\`.
|
||||
- Las invariantes se validan también antes de persistir; las CHECK constraints son defensa adicional.
|
||||
|
||||
## 13.4 Índices iniciales
|
||||
|
||||
PRIMARY KEY (id)
|
||||
INDEX (occurred_at, id)
|
||||
INDEX (scope_type, scope_id, occurred_at, id)
|
||||
INDEX (target_type, target_id, occurred_at, id)
|
||||
INDEX (actor_type, actor_id, occurred_at, id)
|
||||
INDEX (operation_id)
|
||||
INDEX (correlation_id)
|
||||
|
||||
No se indexan inicialmente \`kind\`, \`path\`, JSON values, source ni action. Se añadirán solo si las consultas reales justifican el coste de escritura.
|
||||
|
||||
# 14\. Lectura administrativa
|
||||
|
||||
\`IAuditReader\` no forma parte del core portable porque el modelo actual de Criteria se exporta desde \`@repo/rdx-criteria/server\` y arrastra Sequelize. La lectura se implementa en infraestructura server-side.
|
||||
|
||||
interface IAuditReader {
|
||||
search(
|
||||
criteria: Criteria,
|
||||
access: AuditReadAccess,
|
||||
): Promise<Result<AuditSearchResult, Error>>;
|
||||
<br/>findById(
|
||||
id: string,
|
||||
access: AuditReadAccess,
|
||||
): Promise<Result<AuditEntry | null, Error>>;
|
||||
}
|
||||
|
||||
## 14.1 \`AuditReadAccess\`
|
||||
|
||||
type AuditReadAccess =
|
||||
| Readonly<{ mode: "unrestricted" }>
|
||||
| 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>"
|
||||
}
|
||||
\]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
234
docs/companies/company-requirements.md
Normal file
234
docs/companies/company-requirements.md
Normal 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.
|
||||
@ -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`.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
210
docs/documentation-ssot-audit.md
Normal file
210
docs/documentation-ssot-audit.md
Normal 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.
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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;
|
||||
@ -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>>;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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(),
|
||||
});
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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");
|
||||
});
|
||||
@ -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);
|
||||
};
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: () =>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user