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
|
## Comentarios y documentación
|
||||||
|
|
||||||
Los comentarios deben explicar:
|
### Los comentarios
|
||||||
|
Deben explicar:
|
||||||
|
|
||||||
- por qué existe una decisión;
|
- por qué existe una decisión;
|
||||||
- qué riesgo evita;
|
- qué riesgo evita;
|
||||||
@ -116,7 +117,12 @@ Documentar mediante TSDoc las APIs públicas cuando aporten información sobre:
|
|||||||
|
|
||||||
Después de un refactor, actualizar o eliminar los comentarios que hayan quedado obsoletos.
|
Después de un refactor, actualizar o eliminar los comentarios que hayan quedado obsoletos.
|
||||||
|
|
||||||
La documentación del proyecto debe redactarse en castellano.
|
### La documentación del proyecto
|
||||||
|
|
||||||
|
- debe redactarse en castellano.
|
||||||
|
- Reside en /docs.
|
||||||
|
- Utilizar ficheros Markdown
|
||||||
|
- Los nombres de los ficheros deben estar en castellano.
|
||||||
|
|
||||||
Reglas de estilo para documentación:
|
Reglas de estilo para documentación:
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
- Empieza por este índice si necesitas orientarte.
|
||||||
- Usa los documentos en subdirectorios temáticos como fuente de verdad operativa.
|
- Usa los documentos en subdirectorios temáticos como fuente de verdad operativa.
|
||||||
- Trata los documentos marcados como históricos o legacy como contexto, no como contrato vigente.
|
- Trata los documentos marcados como históricos o legacy como contexto, no como contrato vigente.
|
||||||
|
- Si hay duda sobre qué documento manda, consulta la [auditoría SSOT](./documentation-ssot-audit.md).
|
||||||
|
|
||||||
## Estructura documental
|
## Estructura documental
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
|
|||||||
#### Arquitectura y runtime
|
#### Arquitectura y runtime
|
||||||
|
|
||||||
- [Arquitectura general](./architecture/README.md)
|
- [Arquitectura general](./architecture/README.md)
|
||||||
|
- [Requisitos de `companies`](./companies/company-requirements.md)
|
||||||
- [Contexto tenant y `X-Company-Id`](./architecture/tenant-context.md)
|
- [Contexto tenant y `X-Company-Id`](./architecture/tenant-context.md)
|
||||||
- [Tratamiento de errores HTTP en frontend](./architecture/http-error-handling-frontend.md)
|
- [Tratamiento de errores HTTP en frontend](./architecture/http-error-handling-frontend.md)
|
||||||
- [API de autenticación de `identity`](./architecture/identity-auth-api.md)
|
- [API de autenticación de `identity`](./architecture/identity-auth-api.md)
|
||||||
@ -31,8 +33,10 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
|
|||||||
#### Customer invoices y proformas
|
#### Customer invoices y proformas
|
||||||
|
|
||||||
- [Índice de `customer-invoices`](./customer-invoices/README.md)
|
- [Índice de `customer-invoices`](./customer-invoices/README.md)
|
||||||
|
- [Auditoría SSOT documental](./documentation-ssot-audit.md)
|
||||||
- [Plazos de pago](./catalogs/payment-terms.md)
|
- [Plazos de pago](./catalogs/payment-terms.md)
|
||||||
- [Diseño backend del perfil VeriFactu empresarial](./customer-invoices/settings/verifactu-company-profile-backend-design.md)
|
- [Diseño backend del perfil VeriFactu empresarial](./customer-invoices/settings/verifactu-company-profile-backend-design.md)
|
||||||
|
Documento parcialmente superado: sigue siendo canónico para `CompanyVerifactuProfile`, pero no para reintroducir `IssuedInvoice.verifactuPolicy`.
|
||||||
- [Diseño frontend de Settings para VeriFactu](./customer-invoices/settings/frontend-erp-settings-verifactu-design.md)
|
- [Diseño frontend de Settings para VeriFactu](./customer-invoices/settings/frontend-erp-settings-verifactu-design.md)
|
||||||
- [Contrato de series de proformas](./customer-invoices/proforma-series-contract.md)
|
- [Contrato de series de proformas](./customer-invoices/proforma-series-contract.md)
|
||||||
- [Contrato de create de proformas](./customer-invoices/proforma-create-contract.md)
|
- [Contrato de create de proformas](./customer-invoices/proforma-create-contract.md)
|
||||||
@ -90,3 +94,4 @@ Estos documentos se conservan por trazabilidad, auditoría o contexto de decisio
|
|||||||
- Los documentos operativos vigentes deben vivir en subdirectorios temáticos y tener una responsabilidad clara.
|
- Los documentos operativos vigentes deben vivir en subdirectorios temáticos y tener una responsabilidad clara.
|
||||||
- Los documentos raíz en mayúsculas o con naming heredado deben considerarse legacy o auxiliares salvo que este índice los declare explícitamente vigentes.
|
- Los documentos raíz en mayúsculas o con naming heredado deben considerarse legacy o auxiliares salvo que este índice los declare explícitamente vigentes.
|
||||||
- Si una regla ya tiene fuente canónica, los demás documentos deben resumirla y enlazarla, no copiarla.
|
- Si una regla ya tiene fuente canónica, los demás documentos deben resumirla y enlazarla, no copiarla.
|
||||||
|
- Para VeriFactu e `IssuedInvoice`, el estado vigente es: sin `verifactuPolicy`, cardinalidad actual `1 -> 0..1`, objetivo futuro `1 -> 0..N`.
|
||||||
|
|||||||
@ -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.updated` | mutation | Implementada |
|
||||||
| supplier | `supplier.activated` | mutation | Implementada |
|
| supplier | `supplier.activated` | mutation | Implementada |
|
||||||
| supplier | `supplier.deactivated` | mutation | Implementada |
|
| supplier | `supplier.deactivated` | mutation | Implementada |
|
||||||
|
| proforma | `proforma.created` | mutation | Implementada |
|
||||||
|
| proforma | `proforma.updated` | mutation | Implementada |
|
||||||
|
| proforma | `proforma.status_changed` | mutation | Implementada |
|
||||||
|
| proforma | `proforma.archived` | mutation | Implementada |
|
||||||
|
| proforma | `proforma.unarchived` | mutation | Implementada |
|
||||||
|
| proforma | `proforma.deleted` | mutation | Implementada |
|
||||||
|
| proforma | `proforma.duplicated` | mutation | Implementada |
|
||||||
|
|
||||||
Payment Methods proyecta `name`, `description`, flags `isActive/isSystem/isDefault`; cambiar el default puede producir dos AuditEntries (`default_unset` y `default_set`) bajo la misma operación y T. Payment Terms añade `dueRules` como array atómico ordenado por el dominio, con porcentajes serializados como strings.
|
Payment Methods proyecta `name`, `description`, flags `isActive/isSystem/isDefault`; cambiar el default puede producir dos AuditEntries (`default_unset` y `default_set`) bajo la misma operación y T. Payment Terms añade `dueRules` como array atómico ordenado por el dominio, con porcentajes serializados como strings.
|
||||||
|
|
||||||
Customers y Suppliers proyectan estado, referencia, identidad comercial/fiscal, dirección, contactos, idioma y moneda. Customers añade método/plazo de pago, régimen fiscal y flags de recargo/retención; incluye PII y excluye deliberadamente `legalRecord`. No hay actions lifecycle de Customer. Suppliers separa status del PATCH mediante lifecycle explícito; los inactivos siguen siendo consultables. Companies proyecta identidad, contacto, slug, plazo por defecto y estado; create es unscoped y update/status son scoped en las rutas actuales.
|
Customers y Suppliers proyectan estado, referencia, identidad comercial/fiscal, dirección, contactos, idioma y moneda. Customers añade método/plazo de pago, régimen fiscal y flags de recargo/retención; incluye PII y excluye deliberadamente `legalRecord`. No hay actions lifecycle de Customer. Suppliers separa status del PATCH mediante lifecycle explícito; los inactivos siguen siendo consultables. Companies proyecta identidad, contacto, slug, plazo por defecto y estado; create es unscoped y update/status son scoped en las rutas actuales.
|
||||||
|
|
||||||
|
### Proformas
|
||||||
|
|
||||||
|
P1 está **COMPLETA**: `proforma.created` se registra después de persistence y reload, y `proforma.updated` captura `before/after`. Create y update comparten la misma transacción con Audit; un fallo de Audit revierte la Proforma, items/taxes y, en create, `DocumentSeries.next_number`. Un update sin cambio funcional no persiste ni genera AuditEntry. Los casos validados de update incluyen description, customer y recipient derivado, items y limpieza de `paymentTermId`.
|
||||||
|
|
||||||
|
P2 está **COMPLETA**: `proforma.status_changed`, `proforma.archived`, `proforma.unarchived` y `proforma.deleted`. Todas reciben `IAudit` explícitamente, usan target `proforma` con ID real y referencia como label, y comparten frontera transaccional funcional + Audit. La regla es: `functional mutation + Audit append belong to the same transaction boundary`; si la mutación falla o es no-op no hay Audit, y si Audit falla se revierte la mutación sin AuditEntries parciales committed.
|
||||||
|
|
||||||
|
`proforma.status_changed` solo registra transiciones manuales reales. Mismo status es no-op sin persistence ni Audit; los rechazos tampoco auditan. Los estados actuales son `draft`, `sent`, `approved`, `rejected` e `issued`; las transiciones manuales son `draft -> sent`, `sent -> approved|rejected`, `approved -> rejected|draft` y `rejected -> draft`. `issued` no admite transición manual y no existe el estado `accepted`.
|
||||||
|
|
||||||
|
`proforma.archived` proyecta `archivedAt` de `null` a timestamp funcional; `proforma.unarchived` lo proyecta de timestamp a `null`. `proforma.deleted` mantiene delete físico, usa `before = toProformaAuditState(proforma)` y `after = {}`, conservando ID y referencia del target aunque el aggregate desaparezca.
|
||||||
|
|
||||||
|
`ProformaAuditState` conserva cabecera funcional, recipient con PII, taxConfig JSON-like e items ordenados como array atómico. Excluye `companyId`, IDs técnicos de líneas, totals derivados y timestamps ORM. Los opcionales se normalizan a `null`; Quantity y Percentage usan `value:scale`, y Money usa `currency:value:scale`.
|
||||||
|
|
||||||
|
P3 está **COMPLETA**: `DuplicateProformaByIdUseCase -> ProformaDuplicator -> proforma.duplicated`. `ProformaCreator` y `ProformaDuplicator` son servicios hermanos: create sigue el flujo `CreateProformaUseCase -> ProformaCreator -> proforma.created`, mientras que Duplicator no llama a Creator ni usa flags como `skipAudit`, `auditAction` o `shouldAudit`. Para la copia, el target es la nueva Proforma recargada, con su ID y referencia final como label; se registra exactamente una AuditEntry con `before = {}` y `after = toProformaAuditState(duplicatedProforma)`. No se registra `proforma.created` para la duplicate, la source no recibe Audit adicional, no existe `sourceProformaId` en Audit y `ProformaAuditState` no cambia.
|
||||||
|
|
||||||
|
Duplicator devuelve `IProformaDuplicatorResult` con la Proforma final y `paymentTermAdaptation`: `null | { source: "inactive_original"; resolution: "customer" | "company" | "none" }`. La adaptación se calcula junto con la resolución del payment term y se propaga por el Use Case, pero no pertenece al snapshot Audit.
|
||||||
|
|
||||||
|
P4 sigue **PENDIENTE** para issue: `proforma.issued` e `issued_invoice.created` no están iniciadas ni implementadas.
|
||||||
|
|
||||||
|
Evidencia técnica: P1 cuenta con tests unit/application, tests dedicados de `ProformaAuditState`, MariaDB real, rollback de create/update y de numeración DocumentSeries, y success real mediante `SequelizeAuditStore`. P2 cuenta con application status `3/0`, archive `2/0`, unarchive `1/0` y delete `2/0`; su suite MariaDB lifecycle terminó `7/0`, confirmó rollback de status/archive/unarchive/delete y cero nuevas Audit rows fallidas por action/target. P3 cuenta con `ProformaDuplicator` application `4/0` (success, fallo funcional previo a persistence, fallo Audit y fallback de payment term inactivo a Customer) y `DuplicateProformaByIdUseCase` `3/0` (success, source failure y propagación de fallo del Duplicator). Su MariaDB real valida success HTTP `201`, source committed, duplicate draft con ID y referencia nuevos, una `proforma.duplicated` y cero `proforma.created` para la duplicate. El rollback P3 con `FailingAudit` falla después de persistence y reload (`recordMutation = 1`): la source permanece, la duplicate y sus items/taxes no persisten, AuditEvents y AuditChanges no aumentan, no quedan actions parciales y `DocumentSeries.next_number` vuelve al valor previo. Biome y `git diff --check` fueron correctos en P2/P3.
|
||||||
|
|
||||||
Una `action` es un string jerárquico validado en minúsculas, por ejemplo `customer.updated`. Tras registrarse se convierte en contrato histórico: no debe renombrarse arbitrariamente ni centralizarse en un enum global inexistente.
|
Una `action` es un string jerárquico validado en minúsculas, por ejemplo `customer.updated`. Tras registrarse se convierte en contrato histórico: no debe renombrarse arbitrariamente ni centralizarse en un enum global inexistente.
|
||||||
|
|
||||||
## Reader, autorización y HTTP
|
## Reader, autorización y HTTP
|
||||||
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
|
# customer-invoices
|
||||||
|
|
||||||
Índice de documentación del área de `customer-invoices`, con separación explícita entre contratos vigentes, soporte operativo e histórico.
|
Índice de documentación del área de `customer-invoices`, con separación explícita entre SSOT vigente, documentación parcialmente superada y material histórico.
|
||||||
|
|
||||||
## Documentación vigente
|
## SSOT de lectura rápida
|
||||||
|
|
||||||
|
- Semántica operativa de proformas: `proforma-series-contract.md`, `proforma-create-contract.md`, `proforma-tax-config.md`
|
||||||
|
- Numeración documental: `../document-series/README.md`
|
||||||
|
- Perfil VeriFactu empresarial: `settings/verifactu-company-profile-backend-design.md`
|
||||||
|
- Auditoría SSOT transversal: `../documentation-ssot-audit.md`
|
||||||
|
|
||||||
|
Regla crítica vigente:
|
||||||
|
|
||||||
|
- `IssuedInvoice` no tiene `verifactuPolicy` como requisito actual.
|
||||||
|
- La cardinalidad vigente es `IssuedInvoice 1 -> 0..1 VerifactuRecord`.
|
||||||
|
- La dirección futura deseada es `IssuedInvoice 1 -> 0..N VerifactuRecord`.
|
||||||
|
|
||||||
|
## Documentación vigente (`CURRENT`)
|
||||||
|
|
||||||
### Contratos funcionales y de transporte
|
### Contratos funcionales y de transporte
|
||||||
|
|
||||||
- [Plazos de pago](../catalogs/payment-terms.md)
|
- [Plazos de pago](../catalogs/payment-terms.md)
|
||||||
- [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md)
|
|
||||||
- [Contrato de series de proformas](./proforma-series-contract.md)
|
- [Contrato de series de proformas](./proforma-series-contract.md)
|
||||||
- [Contrato de create de proformas](./proforma-create-contract.md)
|
- [Contrato de create de proformas](./proforma-create-contract.md)
|
||||||
- [Contrato de readiness de emisión de proformas](./proforma-issuance-readiness-contract.md)
|
- [Contrato de readiness de emisión de proformas](./proforma-issuance-readiness-contract.md)
|
||||||
@ -19,6 +31,11 @@
|
|||||||
- [Contrato de series de issued invoices](./issued-invoice-series-contract.md)
|
- [Contrato de series de issued invoices](./issued-invoice-series-contract.md)
|
||||||
- [Contrato de snapshot documental](./document-series-snapshot-contract.md)
|
- [Contrato de snapshot documental](./document-series-snapshot-contract.md)
|
||||||
|
|
||||||
|
## Documentación vigente con secciones superadas (`PARTIALLY SUPERSEDED`)
|
||||||
|
|
||||||
|
- [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md)
|
||||||
|
Referencia canónica para `CompanyVerifactuProfile` y settings backend; no usar sus secciones antiguas como requisito para reintroducir `IssuedInvoice.verifactuPolicy`.
|
||||||
|
|
||||||
### Reports y flujos integrados
|
### Reports y flujos integrados
|
||||||
|
|
||||||
- [Reports de proformas](./proformas-reports.md)
|
- [Reports de proformas](./proformas-reports.md)
|
||||||
@ -28,7 +45,7 @@
|
|||||||
|
|
||||||
- [Índice SQL de `customer-invoices`](./sql/README.md)
|
- [Índice SQL de `customer-invoices`](./sql/README.md)
|
||||||
|
|
||||||
## Histórico o transicional
|
## Histórico o transicional (`HISTORICAL`)
|
||||||
|
|
||||||
- [Split Proformas / Issued Invoices](./split-proformas-issued-invoices-migration.md)
|
- [Split Proformas / Issued Invoices](./split-proformas-issued-invoices-migration.md)
|
||||||
- [InvoiceSeries legacy](./invoice-series.md)
|
- [InvoiceSeries legacy](./invoice-series.md)
|
||||||
@ -38,3 +55,4 @@
|
|||||||
- Para semántica vigente de negocio, empieza por `proforma-series-contract.md`, `proforma-create-contract.md` y `proforma-tax-config.md`.
|
- Para semántica vigente de negocio, empieza por `proforma-series-contract.md`, `proforma-create-contract.md` y `proforma-tax-config.md`.
|
||||||
- Para SQL manual o runbooks, usa `sql/README.md`.
|
- Para SQL manual o runbooks, usa `sql/README.md`.
|
||||||
- Para decisiones antiguas, migraciones o trazabilidad, consulta solo la sección histórica.
|
- Para decisiones antiguas, migraciones o trazabilidad, consulta solo la sección histórica.
|
||||||
|
- Para saber qué documento manda en cada contexto y qué piezas están superadas, consulta `../documentation-ssot-audit.md`.
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 1. Estado del documento
|
## 1. Estado del documento
|
||||||
|
|
||||||
Estado: **IMPLEMENTADO PARCIALMENTE**
|
Estado: **PARTIALLY SUPERSEDED**
|
||||||
|
|
||||||
Este documento define el diseño backend del perfil VeriFactu de empresa dentro del módulo `customer-invoices`.
|
Este documento define el diseño backend del perfil VeriFactu de empresa dentro del módulo `customer-invoices`.
|
||||||
|
|
||||||
@ -14,12 +14,28 @@ Decisiones consolidadas:
|
|||||||
- El estado de configuración es explícito.
|
- El estado de configuración es explícito.
|
||||||
- Se permite `obligated_without_verifactu` como estado real.
|
- Se permite `obligated_without_verifactu` como estado real.
|
||||||
- Los catálogos de clasificación y exención quedan modelados por código pero no se cierran todavía como enums.
|
- Los catálogos de clasificación y exención quedan modelados por código pero no se cierran todavía como enums.
|
||||||
- `IssuedInvoice` deberá persistir dos snapshots distintos en una fase posterior:
|
- `IssuedInvoice` no incorpora actualmente `verifactuPolicy` como requisito vigente.
|
||||||
- clasificación fiscal efectiva;
|
- La integración vigente de `IssuedInvoice` con VeriFactu es:
|
||||||
- política VeriFactu efectiva.
|
- snapshot fiscal efectivo cuando aplique;
|
||||||
|
- relación operativa actual `IssuedInvoice 1 -> 0..1 VerifactuRecord`.
|
||||||
|
- La dirección objetivo futura sigue siendo:
|
||||||
|
- `IssuedInvoice 1 -> 0..N VerifactuRecord` para histórico operativo.
|
||||||
- `VerifactuRecord` mantiene responsabilidad operativa y no absorbe el perfil empresarial.
|
- `VerifactuRecord` mantiene responsabilidad operativa y no absorbe el perfil empresarial.
|
||||||
- El perfil no expone secretos ni passwords de certificados.
|
- El perfil no expone secretos ni passwords de certificados.
|
||||||
|
|
||||||
|
Alcance canónico actual de este documento:
|
||||||
|
|
||||||
|
- diseño del agregado `CompanyVerifactuProfile`;
|
||||||
|
- reglas de configuración empresarial VeriFactu;
|
||||||
|
- API/settings de perfil empresarial;
|
||||||
|
- interacción conceptual con emisión solo en lo relativo a clasificación fiscal.
|
||||||
|
|
||||||
|
Secciones superadas dentro de este mismo documento:
|
||||||
|
|
||||||
|
- cualquier requisito que pida persistir `IssuedInvoice.verifactuPolicy`;
|
||||||
|
- cualquier DDL o criterio de aceptación que reintroduzca `verifactu_profile_status`, `uses_verifactu` o `verifactu_effective_at` en `issued_invoices`;
|
||||||
|
- cualquier lectura que presente `IssuedInvoice 1 -> 0..N VerifactuRecord` como comportamiento ya implantado.
|
||||||
|
|
||||||
## 2. Propósito
|
## 2. Propósito
|
||||||
|
|
||||||
Proporcionar una fuente de verdad empresarial para:
|
Proporcionar una fuente de verdad empresarial para:
|
||||||
@ -30,7 +46,7 @@ Proporcionar una fuente de verdad empresarial para:
|
|||||||
- fecha efectiva de activación;
|
- fecha efectiva de activación;
|
||||||
- renovación automática si aplica;
|
- renovación automática si aplica;
|
||||||
- defaults de clasificación fiscal;
|
- defaults de clasificación fiscal;
|
||||||
- snapshot histórico al emitir una factura.
|
- snapshot histórico fiscal al emitir una factura.
|
||||||
|
|
||||||
El diseño debe integrarse con el flujo actual:
|
El diseño debe integrarse con el flujo actual:
|
||||||
|
|
||||||
@ -246,17 +262,23 @@ export interface IssuedInvoiceFiscalClassificationSnapshot {
|
|||||||
|
|
||||||
Queda diseñado, pero no implementado todavía en esta fase.
|
Queda diseñado, pero no implementado todavía en esta fase.
|
||||||
|
|
||||||
## 12. Snapshot de política VeriFactu
|
## 12. Nota superada sobre política VeriFactu en `IssuedInvoice`
|
||||||
|
|
||||||
```ts
|
La propuesta anterior de añadir un snapshot `verifactuPolicy` a `IssuedInvoice`
|
||||||
export interface IssuedInvoiceVerifactuPolicySnapshot {
|
queda superada y no forma parte del SSOT vigente.
|
||||||
profileStatus: CompanyVerifactuProfileStatus;
|
|
||||||
usesVerifactu: boolean;
|
|
||||||
effectiveAt: string | null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Queda diseñado, pero no implementado todavía en esta fase.
|
Estado actual:
|
||||||
|
|
||||||
|
- `IssuedInvoice` no debe añadir ni persistir `verifactuPolicy`;
|
||||||
|
- no debe reintroducir columnas legacy como `verifactu_profile_status`,
|
||||||
|
`uses_verifactu` o `verifactu_effective_at`;
|
||||||
|
- `VerifactuRecord` sigue siendo la pieza operativa para el lifecycle VeriFactu;
|
||||||
|
- la cardinalidad vigente implementada es `IssuedInvoice 1 -> 0..1 VerifactuRecord`.
|
||||||
|
|
||||||
|
Dirección futura:
|
||||||
|
|
||||||
|
- evolucionar a `IssuedInvoice 1 -> 0..N VerifactuRecord` cuando el histórico
|
||||||
|
operativo entre en alcance real.
|
||||||
|
|
||||||
## 13. Resolución durante la emisión
|
## 13. Resolución durante la emisión
|
||||||
|
|
||||||
@ -275,8 +297,7 @@ ProformaIssueReadModelAssembler
|
|||||||
proforma-to-issued-invoice-props-converter
|
proforma-to-issued-invoice-props-converter
|
||||||
│
|
│
|
||||||
├── resuelve precedencia
|
├── resuelve precedencia
|
||||||
├── construye FiscalClassificationSnapshot
|
└── construye FiscalClassificationSnapshot
|
||||||
└── construye VerifactuPolicySnapshot
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
IssuedInvoice.create(...)
|
IssuedInvoice.create(...)
|
||||||
@ -287,7 +308,7 @@ IssuedInvoice.create(...)
|
|||||||
`IssuedInvoice`:
|
`IssuedInvoice`:
|
||||||
- snapshot documental e histórico;
|
- snapshot documental e histórico;
|
||||||
- clasificación fiscal aplicada;
|
- clasificación fiscal aplicada;
|
||||||
- política VeriFactu aplicada.
|
- relación documental con el contexto de emisión.
|
||||||
|
|
||||||
`VerifactuRecord`:
|
`VerifactuRecord`:
|
||||||
- estado operativo;
|
- estado operativo;
|
||||||
@ -295,9 +316,15 @@ IssuedInvoice.create(...)
|
|||||||
- QR;
|
- QR;
|
||||||
- UUID;
|
- UUID;
|
||||||
- tipo de operación.
|
- tipo de operación.
|
||||||
|
- histórico operativo futuro.
|
||||||
|
|
||||||
No debe contener defaults empresariales ni obligación/exención.
|
No debe contener defaults empresariales ni obligación/exención.
|
||||||
|
|
||||||
|
Cardinalidad:
|
||||||
|
|
||||||
|
- vigente: `IssuedInvoice 1 -> 0..1 VerifactuRecord`;
|
||||||
|
- objetivo futuro: `IssuedInvoice 1 -> 0..N VerifactuRecord`.
|
||||||
|
|
||||||
## 15. Certificados
|
## 15. Certificados
|
||||||
|
|
||||||
El perfil no contiene:
|
El perfil no contiene:
|
||||||
@ -434,7 +461,14 @@ DDL operativo vigente:
|
|||||||
|
|
||||||
```txt
|
```txt
|
||||||
docs/customer-invoices/sql/add-company-verifactu-profiles.sql
|
docs/customer-invoices/sql/add-company-verifactu-profiles.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
SQL histórico/superado relacionado con la antigua propuesta de snapshots
|
||||||
|
VeriFactu en `issued_invoices`:
|
||||||
|
|
||||||
|
```txt
|
||||||
docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql
|
docs/customer-invoices/sql/add-issued-invoice-verifactu-snapshots.sql
|
||||||
|
docs/customer-invoices/sql/drop-issued-invoice-verifactu-snapshot-columns.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## 22. Concurrencia
|
## 22. Concurrencia
|
||||||
@ -454,12 +488,11 @@ Orden recomendado:
|
|||||||
|
|
||||||
```txt
|
```txt
|
||||||
1. ejecutar DDL de `company_verifactu_profiles`
|
1. ejecutar DDL de `company_verifactu_profiles`
|
||||||
2. ejecutar DDL de snapshots VeriFactu en `issued_invoices`
|
2. persistencia y GET seguro
|
||||||
3. persistencia y GET seguro
|
3. PUT/configuración
|
||||||
4. PUT/configuración
|
4. UI Settings
|
||||||
5. UI Settings
|
5. uso de defaults en emisión
|
||||||
6. uso de defaults en emisión
|
6. enforcement posterior
|
||||||
7. enforcement posterior
|
|
||||||
```
|
```
|
||||||
|
|
||||||
La ausencia de perfil no debe romper automáticamente la emisión actual durante transición.
|
La ausencia de perfil no debe romper automáticamente la emisión actual durante transición.
|
||||||
@ -545,32 +578,40 @@ versionado
|
|||||||
|
|
||||||
## 28. Integración en `IssuedInvoice`
|
## 28. Integración en `IssuedInvoice`
|
||||||
|
|
||||||
Implementado en Fase B:
|
Estado documental vigente:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
CompanyVerifactuProfile
|
CompanyVerifactuProfile
|
||||||
→ ProformaIssueReadModelAssembler
|
→ configuración empresarial
|
||||||
→ resolución backend de clasificación/política
|
→ resolución backend de clasificación fiscal cuando aplique
|
||||||
→ ProformaToIssuedInvoiceConverter
|
→ IssuedInvoice / emisión
|
||||||
→ IssuedInvoice
|
→ VerifactuRecord operativo
|
||||||
→ persistencia física en issued_invoices
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`IssuedInvoice` mantiene `taxRegime` como snapshot legacy existente y añade además:
|
`IssuedInvoice` mantiene `taxRegime` como snapshot legacy existente y, como
|
||||||
|
evolución vigente de diseño, solo puede incorporar snapshots fiscales
|
||||||
|
compatibles con el documento emitido.
|
||||||
|
|
||||||
|
No forma parte del SSOT actual:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
fiscalClassification
|
|
||||||
verifactuPolicy
|
verifactuPolicy
|
||||||
```
|
```
|
||||||
|
|
||||||
Decisiones reales:
|
Sí forma parte del SSOT actual:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
fiscalClassification
|
||||||
|
```
|
||||||
|
|
||||||
|
Decisiones vigentes:
|
||||||
|
|
||||||
- `taxRegimeCode` se resuelve con precedencia `document -> customer -> company -> sin valor`.
|
- `taxRegimeCode` se resuelve con precedencia `document -> customer -> company -> sin valor`.
|
||||||
- `operationQualificationCode` se resuelve hoy desde defaults empresariales o `null`.
|
- `operationQualificationCode` se resuelve hoy desde defaults empresariales o `null`.
|
||||||
- `exemptionReasonCode` se resuelve hoy desde defaults empresariales o `null`.
|
- `exemptionReasonCode` se resuelve hoy desde defaults empresariales o `null`.
|
||||||
- `verifactuPolicy.profileStatus` se persiste como señal de snapshot V2.
|
|
||||||
- facturas antiguas sin esa señal cargan con `fiscalClassification = null` y `verifactuPolicy = null`.
|
|
||||||
- no se inventan labels de `operationQualification` ni `exemptionReason`; se exponen como `null` hasta disponer de catálogo backend real.
|
- no se inventan labels de `operationQualification` ni `exemptionReason`; se exponen como `null` hasta disponer de catálogo backend real.
|
||||||
|
- la cardinalidad implementada entre `IssuedInvoice` y `VerifactuRecord` sigue siendo `1 -> 0..1`.
|
||||||
|
- la evolución futura deseada es `1 -> 0..N`, pero no debe documentarse como runtime actual.
|
||||||
|
|
||||||
## 29. Tests implementados
|
## 29. Tests implementados
|
||||||
|
|
||||||
@ -591,7 +632,7 @@ Use cases / controllers:
|
|||||||
Fase B:
|
Fase B:
|
||||||
- precedencia `document -> customer -> company` para `taxRegimeCode`;
|
- precedencia `document -> customer -> company` para `taxRegimeCode`;
|
||||||
- fallback legacy sin perfil;
|
- fallback legacy sin perfil;
|
||||||
- validación de `IssuedInvoiceVerifactuPolicy`.
|
- separación explícita entre perfil empresarial y estado operativo VeriFactu.
|
||||||
|
|
||||||
## 30. Criterios de aceptación
|
## 30. Criterios de aceptación
|
||||||
|
|
||||||
@ -606,7 +647,9 @@ Fase B:
|
|||||||
9. `VerifactuRecord` sigue siendo operativo.
|
9. `VerifactuRecord` sigue siendo operativo.
|
||||||
10. No se exponen secretos.
|
10. No se exponen secretos.
|
||||||
11. Empresas sin perfil se leen como `not_configured`.
|
11. Empresas sin perfil se leen como `not_configured`.
|
||||||
12. `IssuedInvoice` persiste snapshots de clasificación/política sin reescribir facturas legacy.
|
12. `IssuedInvoice` no reintroduce `verifactuPolicy` como requisito vigente.
|
||||||
|
13. La cardinalidad vigente es `IssuedInvoice 1 -> 0..1 VerifactuRecord`.
|
||||||
|
14. La dirección futura deseada es `IssuedInvoice 1 -> 0..N VerifactuRecord`.
|
||||||
|
|
||||||
## 31. Regla mental final
|
## 31. Regla mental final
|
||||||
|
|
||||||
@ -618,10 +661,12 @@ CompanyVerifactuProfile
|
|||||||
→ política/defaults empresariales
|
→ política/defaults empresariales
|
||||||
|
|
||||||
IssuedInvoice
|
IssuedInvoice
|
||||||
→ snapshot histórico efectivo
|
→ snapshot fiscal histórico efectivo cuando aplique
|
||||||
|
→ relación actual 1 -> 0..1 con VerifactuRecord
|
||||||
|
|
||||||
VerifactuRecord
|
VerifactuRecord
|
||||||
→ estado operativo
|
→ estado operativo
|
||||||
|
→ futuro histórico 0..N
|
||||||
|
|
||||||
AEAT integration
|
AEAT integration
|
||||||
→ lifecycle futuro
|
→ lifecycle futuro
|
||||||
|
|||||||
@ -9,6 +9,12 @@ No describe el runtime minimo vigente por si solo; la fuente operativa actual pa
|
|||||||
- `docs/customer-invoices/proforma-tax-config.md`
|
- `docs/customer-invoices/proforma-tax-config.md`
|
||||||
- `docs/catalogs/payment-terms.md`
|
- `docs/catalogs/payment-terms.md`
|
||||||
|
|
||||||
|
Nota SSOT VeriFactu:
|
||||||
|
|
||||||
|
- cardinalidad vigente implementada: `IssuedInvoice 1 -> 0..1 VerifactuRecord`;
|
||||||
|
- direccion futura deseada: `IssuedInvoice 1 -> 0..N VerifactuRecord`;
|
||||||
|
- este documento no debe usarse para reintroducir `IssuedInvoice.verifactuPolicy`.
|
||||||
|
|
||||||
Estado: diseno tecnico / fases 1A-1D + 2A-2B
|
Estado: diseno tecnico / fases 1A-1D + 2A-2B
|
||||||
Estado del backend: persistencia V2 activada; integracion con `document-series` activa para numeracion nueva; sin ejecucion confirmada de migracion DB
|
Estado del backend: persistencia V2 activada; integracion con `document-series` activa para numeracion nueva; sin ejecucion confirmada de migracion DB
|
||||||
|
|
||||||
|
|||||||
@ -8,10 +8,16 @@ Las tablas legacy `customer_invoices`, `customer_invoice_items` y
|
|||||||
`customer_invoice_taxes` no forman parte del runtime actual. Solo pueden aparecer
|
`customer_invoice_taxes` no forman parte del runtime actual. Solo pueden aparecer
|
||||||
en SQL historico de migracion o validacion.
|
en SQL historico de migracion o validacion.
|
||||||
|
|
||||||
|
Regla SSOT relevante para VeriFactu:
|
||||||
|
|
||||||
|
- `issued_invoices` no debe reintroducir `verifactu_profile_status`,
|
||||||
|
`uses_verifactu` ni `verifactu_effective_at` como columnas vigentes;
|
||||||
|
- la cardinalidad implementada sigue siendo `IssuedInvoice 1 -> 0..1 VerifactuRecord`;
|
||||||
|
- `0..N` es objetivo futuro, no estado operativo actual.
|
||||||
|
|
||||||
## SQL vigentes
|
## SQL vigentes
|
||||||
|
|
||||||
- `add-company-verifactu-profiles.sql`
|
- `add-company-verifactu-profiles.sql`
|
||||||
- `add-issued-invoice-verifactu-snapshots.sql`
|
|
||||||
- `add-proforma-document-series-columns.sql`
|
- `add-proforma-document-series-columns.sql`
|
||||||
- `add-proforma-tax-config-columns.sql`
|
- `add-proforma-tax-config-columns.sql`
|
||||||
- `add-issued-invoice-reference.sql`
|
- `add-issued-invoice-reference.sql`
|
||||||
@ -22,6 +28,8 @@ en SQL historico de migracion o validacion.
|
|||||||
|
|
||||||
## SQL históricos / transicionales
|
## SQL históricos / transicionales
|
||||||
|
|
||||||
|
- `add-issued-invoice-verifactu-snapshots.sql`
|
||||||
|
- `drop-issued-invoice-verifactu-snapshot-columns.sql`
|
||||||
- `dev-split-proformas-issued-invoices.sql`
|
- `dev-split-proformas-issued-invoices.sql`
|
||||||
- `validate-split-proformas-issued-invoices.sql`
|
- `validate-split-proformas-issued-invoices.sql`
|
||||||
|
|
||||||
@ -32,11 +40,17 @@ Estos archivos no deben tratarse como el camino normal de entornos nuevos salvo
|
|||||||
1. Verificar que la BD objetivo es inequívocamente de desarrollo.
|
1. Verificar que la BD objetivo es inequívocamente de desarrollo.
|
||||||
2. Hacer backup o confirmar que los datos son descartables.
|
2. Hacer backup o confirmar que los datos son descartables.
|
||||||
3. Ejecutar `add-company-verifactu-profiles.sql` si el entorno aún no tiene el perfil VeriFactu empresarial.
|
3. Ejecutar `add-company-verifactu-profiles.sql` si el entorno aún no tiene el perfil VeriFactu empresarial.
|
||||||
4. Ejecutar `add-issued-invoice-verifactu-snapshots.sql` si el entorno aún no tiene los snapshots fiscales/política en `issued_invoices`.
|
4. Ejecutar el DDL vigente que falte para `proformas`.
|
||||||
5. Ejecutar el DDL vigente que falte para `proformas`.
|
5. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado.
|
||||||
6. Ejecutar `normalize-document-series-snapshots.sql` para completar snapshots y validaciones de seriado.
|
6. Ejecutar las validaciones SQL vigentes.
|
||||||
7. Ejecutar las validaciones SQL vigentes.
|
7. Levantar backend V2 y validar flujos funcionales.
|
||||||
8. Levantar backend V2 y validar flujos funcionales.
|
|
||||||
|
## SQL expresamente superados para VeriFactu en `issued_invoices`
|
||||||
|
|
||||||
|
- `add-issued-invoice-verifactu-snapshots.sql` responde a una propuesta anterior
|
||||||
|
que mezclaba snapshots fiscales con un `verifactuPolicy` hoy descartado.
|
||||||
|
- `drop-issued-invoice-verifactu-snapshot-columns.sql` se conserva como runbook
|
||||||
|
correctivo para entornos legacy donde esas columnas todavía existan.
|
||||||
|
|
||||||
## Propiedades de los scripts históricos de split
|
## Propiedades de los scripts históricos de split
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
-- DDL vigente para snapshots fiscales y de politica VeriFactu en issued_invoices.
|
-- Estado documental: SUPERSEDED / solo auditoria legacy.
|
||||||
-- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado.
|
-- Este script responde a una propuesta antigua que reintroducia
|
||||||
-- No ejecutar automaticamente en produccion.
|
-- `verifactu_profile_status`, `uses_verifactu` y `verifactu_effective_at`
|
||||||
|
-- en `issued_invoices`.
|
||||||
|
-- No es SSOT vigente y no debe ejecutarse como camino normal en entornos nuevos.
|
||||||
|
-- Ejecutar solo para auditar drift historico o comparar esquemas legacy.
|
||||||
|
|
||||||
SET @target_schema = DATABASE();
|
SET @target_schema = DATABASE();
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
-- Elimina las columnas legacy de snapshot VeriFactu de `issued_invoices`.
|
-- Estado documental: CURRENT como runbook correctivo para entornos legacy.
|
||||||
|
-- Elimina columnas obsoletas de snapshot VeriFactu en `issued_invoices`.
|
||||||
|
-- Refuerza el SSOT vigente: `IssuedInvoice` no debe persistir
|
||||||
|
-- `verifactu_profile_status`, `uses_verifactu` ni `verifactu_effective_at`.
|
||||||
-- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado.
|
-- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado.
|
||||||
-- No ejecutar automaticamente en produccion.
|
-- No ejecutar automaticamente en produccion.
|
||||||
|
|
||||||
|
|||||||
@ -112,6 +112,9 @@ WHERE ii.id IS NULL;
|
|||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Verifactu
|
-- Verifactu
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Cardinalidad vigente esperada: IssuedInvoice 1 -> 0..1 VerifactuRecord.
|
||||||
|
-- Direccion futura deseada: IssuedInvoice 1 -> 0..N VerifactuRecord.
|
||||||
|
-- Este SQL historico no debe interpretarse como prueba de que 0..N ya esta implantado.
|
||||||
|
|
||||||
SELECT COUNT(*) AS orphan_verifactu_records
|
SELECT COUNT(*) AS orphan_verifactu_records
|
||||||
FROM verifactu_records vr
|
FROM verifactu_records vr
|
||||||
|
|||||||
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",
|
"@lglab/react-qr-code": "^1.4.10",
|
||||||
"@repo/i18next": "workspace:*",
|
"@repo/i18next": "workspace:*",
|
||||||
"@repo/rdx-criteria": "workspace:*",
|
"@repo/rdx-criteria": "workspace:*",
|
||||||
|
"@repo/rdx-audit": "workspace:*",
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
"@repo/rdx-logger": "workspace:*",
|
"@repo/rdx-logger": "workspace:*",
|
||||||
"@repo/rdx-ui": "workspace:*",
|
"@repo/rdx-ui": "workspace:*",
|
||||||
|
|||||||
@ -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"],
|
props: IIssuedInvoiceCreatorParams["props"],
|
||||||
context: IIssuedInvoiceServicesContext
|
context: IIssuedInvoiceServicesContext
|
||||||
) => Promise<Result<IssuedInvoice, Error>>;
|
) => 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-catalog-resolvers.di";
|
||||||
export * from "./proforma-creator.di";
|
export * from "./proforma-creator.di";
|
||||||
export * from "./proforma-deleter.di";
|
export * from "./proforma-deleter.di";
|
||||||
|
export * from "./proforma-duplicator.di";
|
||||||
export * from "./proforma-finder.di";
|
export * from "./proforma-finder.di";
|
||||||
export * from "./proforma-input-mappers.di";
|
export * from "./proforma-input-mappers.di";
|
||||||
export * from "./proforma-issuer.di";
|
export * from "./proforma-issuer.di";
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
|
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
import { type IProformaArchiver, ProformaArchiver } from "../services";
|
import { type IProformaArchiver, ProformaArchiver } from "../services";
|
||||||
|
|
||||||
export function buildProformaArchiver(deps: {
|
export function buildProformaArchiver(deps: {
|
||||||
repository: IProformaRepository;
|
repository: IProformaRepository;
|
||||||
|
audit: IAudit;
|
||||||
}): IProformaArchiver {
|
}): IProformaArchiver {
|
||||||
return new ProformaArchiver({
|
return new ProformaArchiver({
|
||||||
repository: deps.repository,
|
repository: deps.repository,
|
||||||
|
audit: deps.audit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ICompanyPublicServices } from "@erp/companies/api";
|
import type { ICompanyPublicServices } from "@erp/companies/api";
|
||||||
import type { ICustomerPublicServices } from "@erp/customers/api";
|
import type { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
|
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
import type { ProformaPaymentResolver } from "../services";
|
import type { ProformaPaymentResolver } from "../services";
|
||||||
@ -12,7 +13,7 @@ import {
|
|||||||
type ProformaTaxResolver,
|
type ProformaTaxResolver,
|
||||||
} from "../services";
|
} from "../services";
|
||||||
|
|
||||||
export const buildProformaCreator = (params: {
|
export const buildProformaCreator = (deps: {
|
||||||
taxResolver: ProformaTaxResolver;
|
taxResolver: ProformaTaxResolver;
|
||||||
paymentResolver: ProformaPaymentResolver;
|
paymentResolver: ProformaPaymentResolver;
|
||||||
numberService: IProformaNumberGenerator;
|
numberService: IProformaNumberGenerator;
|
||||||
@ -21,6 +22,7 @@ export const buildProformaCreator = (params: {
|
|||||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||||
companiesServices: ICompanyPublicServices;
|
companiesServices: ICompanyPublicServices;
|
||||||
customerServices: ICustomerPublicServices;
|
customerServices: ICustomerPublicServices;
|
||||||
|
audit: IAudit;
|
||||||
}): IProformaCreator => {
|
}): IProformaCreator => {
|
||||||
return new ProformaCreator(params);
|
return new ProformaCreator(deps);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
|
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
import { type IProformaDeleter, ProformaDeleter } from "../services";
|
import { type IProformaDeleter, ProformaDeleter } from "../services";
|
||||||
|
|
||||||
export function buildProformaDeleter(deps: { repository: IProformaRepository }): IProformaDeleter {
|
export function buildProformaDeleter(deps: {
|
||||||
|
repository: IProformaRepository;
|
||||||
|
audit: IAudit;
|
||||||
|
}): IProformaDeleter {
|
||||||
return new ProformaDeleter({
|
return new ProformaDeleter({
|
||||||
repository: deps.repository,
|
repository: deps.repository,
|
||||||
|
audit: deps.audit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { IProformaRepository } from "../repositories";
|
||||||
import { type IProformaStatusChanger, ProformaStatusChanger } from "../services";
|
import { type IProformaStatusChanger, ProformaStatusChanger } from "../services";
|
||||||
|
|
||||||
export const buildProformaStatusChanger = (params: {
|
export const buildProformaStatusChanger = (params: {
|
||||||
repository: IProformaRepository;
|
repository: IProformaRepository;
|
||||||
|
audit: IAudit;
|
||||||
}): IProformaStatusChanger => {
|
}): IProformaStatusChanger => {
|
||||||
return new ProformaStatusChanger(params);
|
return new ProformaStatusChanger(params);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts
|
// modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts
|
||||||
import type { ICustomerPublicServices } from "@erp/customers/api";
|
import type { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
|
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
import type { ProformaPaymentResolver } from "../services";
|
import type { ProformaPaymentResolver } from "../services";
|
||||||
@ -16,6 +17,7 @@ export const buildProformaUpdater = (params: {
|
|||||||
paymentResolver: ProformaPaymentResolver;
|
paymentResolver: ProformaPaymentResolver;
|
||||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||||
customerServices: ICustomerPublicServices;
|
customerServices: ICustomerPublicServices;
|
||||||
|
audit: IAudit;
|
||||||
}): IProformaUpdater => {
|
}): IProformaUpdater => {
|
||||||
return new ProformaUpdater(params);
|
return new ProformaUpdater(params);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import type { IPaymentTermPublicFinder } from "@erp/catalogs/api";
|
|
||||||
import type { ICompanyPublicServices } from "@erp/companies/api";
|
|
||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
import type { ICustomerPublicServices } from "@erp/customers/api";
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
|
|
||||||
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
|
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
|
||||||
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
|
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
|
||||||
@ -11,6 +9,7 @@ import type {
|
|||||||
IProformaArchiver,
|
IProformaArchiver,
|
||||||
IProformaCreator,
|
IProformaCreator,
|
||||||
IProformaDeleter,
|
IProformaDeleter,
|
||||||
|
IProformaDuplicator,
|
||||||
IProformaFinder,
|
IProformaFinder,
|
||||||
IProformaFullReadModelAssembler,
|
IProformaFullReadModelAssembler,
|
||||||
IProformaIssuanceReadinessChecker,
|
IProformaIssuanceReadinessChecker,
|
||||||
@ -142,25 +141,17 @@ export function buildCreateProformaUseCase(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildDuplicateProformaByIdUseCase(deps: {
|
export function buildDuplicateProformaByIdUseCase(deps: {
|
||||||
creator: IProformaCreator;
|
duplicator: IProformaDuplicator;
|
||||||
finder: IProformaFinder;
|
finder: IProformaFinder;
|
||||||
fullReadModelAssembler: IProformaFullReadModelAssembler;
|
fullReadModelAssembler: IProformaFullReadModelAssembler;
|
||||||
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
||||||
documentSeriesServices: import("@erp/document-series/api").DocumentSeriesPublicServicesType;
|
|
||||||
companiesServices: ICompanyPublicServices;
|
|
||||||
customerServices: ICustomerPublicServices;
|
|
||||||
paymentTermFinder: IPaymentTermPublicFinder;
|
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
}) {
|
}) {
|
||||||
return new DuplicateProformaByIdUseCase({
|
return new DuplicateProformaByIdUseCase({
|
||||||
creator: deps.creator,
|
duplicator: deps.duplicator,
|
||||||
finder: deps.finder,
|
finder: deps.finder,
|
||||||
fullReadModelAssembler: deps.fullReadModelAssembler,
|
fullReadModelAssembler: deps.fullReadModelAssembler,
|
||||||
fullSnapshotBuilder: deps.fullSnapshotBuilder,
|
fullSnapshotBuilder: deps.fullSnapshotBuilder,
|
||||||
documentSeriesServices: deps.documentSeriesServices,
|
|
||||||
companiesServices: deps.companiesServices,
|
|
||||||
customerServices: deps.customerServices,
|
|
||||||
paymentTermFinder: deps.paymentTermFinder,
|
|
||||||
transactionManager: deps.transactionManager,
|
transactionManager: deps.transactionManager,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -175,6 +166,7 @@ export function buildIssueProformaUseCase(deps: {
|
|||||||
issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder;
|
issueResponseSnapshotBuilder: IIssueProformaResponseSnapshotBuilder;
|
||||||
repository: IProformaRepository;
|
repository: IProformaRepository;
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
|
audit: IAudit;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
finder,
|
finder,
|
||||||
@ -183,6 +175,7 @@ export function buildIssueProformaUseCase(deps: {
|
|||||||
issueResponseSnapshotBuilder,
|
issueResponseSnapshotBuilder,
|
||||||
repository,
|
repository,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
|
audit,
|
||||||
publicServices: { issuedInvoiceServices },
|
publicServices: { issuedInvoiceServices },
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
@ -194,6 +187,7 @@ export function buildIssueProformaUseCase(deps: {
|
|||||||
issueResponseSnapshotBuilder,
|
issueResponseSnapshotBuilder,
|
||||||
repository,
|
repository,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
|
audit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
export * from "./proforma-archiver";
|
|
||||||
export * from "./assemblers";
|
export * from "./assemblers";
|
||||||
export * from "./catalog-resolver";
|
export * from "./catalog-resolver";
|
||||||
export * from "./company-report-profile-finder";
|
export * from "./company-report-profile-finder";
|
||||||
|
export * from "./proforma-archiver";
|
||||||
export * from "./proforma-creator";
|
export * from "./proforma-creator";
|
||||||
|
export * from "./proforma-deleter";
|
||||||
export * from "./proforma-document-generator.interface";
|
export * from "./proforma-document-generator.interface";
|
||||||
export * from "./proforma-document-properties-factory";
|
export * from "./proforma-document-properties-factory";
|
||||||
export * from "./proforma-deleter";
|
export * from "./proforma-duplicator";
|
||||||
export * from "./proforma-finder";
|
export * from "./proforma-finder";
|
||||||
export * from "./proforma-issuer";
|
|
||||||
export * from "./proforma-issuance-readiness-checker";
|
export * from "./proforma-issuance-readiness-checker";
|
||||||
|
export * from "./proforma-issuer";
|
||||||
export * from "./proforma-number-generator.interface";
|
export * from "./proforma-number-generator.interface";
|
||||||
export * from "./proforma-public-services.interface";
|
export * from "./proforma-public-services.interface";
|
||||||
export * from "./proforma-series-validator.interface";
|
export * from "./proforma-series-validator.interface";
|
||||||
|
|||||||
@ -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 type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { ProformaCannotBeArchivedError, ProformaCannotBeUnarchivedError } from "../../../domain";
|
||||||
import {
|
import {
|
||||||
ProformaCannotBeArchivedError,
|
PROFORMA_AUDIT_ACTIONS,
|
||||||
ProformaCannotBeUnarchivedError,
|
toProformaAuditState,
|
||||||
} from "../../../domain";
|
toProformaAuditTarget,
|
||||||
|
} from "../audit/proforma-audit";
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
|
|
||||||
export interface IProformaArchiver {
|
export interface IProformaArchiver {
|
||||||
archive(params: { companyId: UniqueID; id: UniqueID; transaction: unknown }): Promise<Result<void, Error>>;
|
archive(params: {
|
||||||
|
companyId: UniqueID;
|
||||||
|
id: UniqueID;
|
||||||
|
transaction: unknown;
|
||||||
|
}): Promise<Result<void, Error>>;
|
||||||
unarchive(params: {
|
unarchive(params: {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
id: UniqueID;
|
id: UniqueID;
|
||||||
@ -17,7 +24,7 @@ export interface IProformaArchiver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ProformaArchiver implements IProformaArchiver {
|
export class ProformaArchiver implements IProformaArchiver {
|
||||||
public constructor(private readonly deps: { repository: IProformaRepository }) {}
|
public constructor(private readonly deps: { repository: IProformaRepository; audit: IAudit }) {}
|
||||||
|
|
||||||
public async archive(params: {
|
public async archive(params: {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
@ -35,6 +42,7 @@ export class ProformaArchiver implements IProformaArchiver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proforma = proformaResult.data;
|
const proforma = proformaResult.data;
|
||||||
|
const before = toProformaAuditState(proforma);
|
||||||
const canArchiveResult = proforma.ensureCanBeArchived();
|
const canArchiveResult = proforma.ensureCanBeArchived();
|
||||||
|
|
||||||
if (canArchiveResult.isFailure) {
|
if (canArchiveResult.isFailure) {
|
||||||
@ -57,6 +65,27 @@ export class ProformaArchiver implements IProformaArchiver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistedResult = await this.deps.repository.getByIdIncludingArchivedInCompany(
|
||||||
|
params.companyId,
|
||||||
|
params.id,
|
||||||
|
params.transaction
|
||||||
|
);
|
||||||
|
|
||||||
|
if (persistedResult.isFailure) {
|
||||||
|
return Result.fail(persistedResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.deps.audit.recordMutation({
|
||||||
|
action: PROFORMA_AUDIT_ACTIONS.ARCHIVED,
|
||||||
|
target: toProformaAuditTarget(persistedResult.data),
|
||||||
|
before,
|
||||||
|
after: toProformaAuditState(persistedResult.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +105,7 @@ export class ProformaArchiver implements IProformaArchiver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proforma = proformaResult.data;
|
const proforma = proformaResult.data;
|
||||||
|
const before = toProformaAuditState(proforma);
|
||||||
const canUnarchiveResult = proforma.ensureCanBeUnarchived();
|
const canUnarchiveResult = proforma.ensureCanBeUnarchived();
|
||||||
|
|
||||||
if (canUnarchiveResult.isFailure) {
|
if (canUnarchiveResult.isFailure) {
|
||||||
@ -94,13 +124,31 @@ export class ProformaArchiver implements IProformaArchiver {
|
|||||||
|
|
||||||
if (!persistenceResult.data) {
|
if (!persistenceResult.data) {
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
new ProformaCannotBeUnarchivedError(
|
new ProformaCannotBeUnarchivedError(params.id.toString(), "Proforma is not archived.")
|
||||||
params.id.toString(),
|
|
||||||
"Proforma is not archived."
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistedResult = await this.deps.repository.getByIdIncludingArchivedInCompany(
|
||||||
|
params.companyId,
|
||||||
|
params.id,
|
||||||
|
params.transaction
|
||||||
|
);
|
||||||
|
|
||||||
|
if (persistedResult.isFailure) {
|
||||||
|
return Result.fail(persistedResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.deps.audit.recordMutation({
|
||||||
|
action: PROFORMA_AUDIT_ACTIONS.UNARCHIVED,
|
||||||
|
target: toProformaAuditTarget(persistedResult.data),
|
||||||
|
before,
|
||||||
|
after: toProformaAuditState(persistedResult.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,25 +2,57 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { DiscountPercentage } from "@erp/core/api";
|
import { DiscountPercentage } from "@erp/core/api";
|
||||||
|
import type {
|
||||||
|
IAudit,
|
||||||
|
RecordAuditEventShortcutInput,
|
||||||
|
RecordAuditInput,
|
||||||
|
RecordAuditMutationShortcutInput,
|
||||||
|
} from "@repo/rdx-audit";
|
||||||
import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { InvoiceStatus, type Proforma } from "../../../domain";
|
||||||
|
import type { ProformaCreateInputProps } from "../models";
|
||||||
|
|
||||||
import { ProformaCreator } from "./proforma-creator";
|
import { ProformaCreator } from "./proforma-creator";
|
||||||
|
|
||||||
|
class FakeAudit implements IAudit {
|
||||||
|
public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = [];
|
||||||
|
private failure: Error | null = null;
|
||||||
|
|
||||||
|
public failWith(error: Error): void {
|
||||||
|
this.failure = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async record(_input: RecordAuditInput) {
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
async recordEvent(_input: RecordAuditEventShortcutInput) {
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
async recordMutation(input: RecordAuditMutationShortcutInput) {
|
||||||
|
this.recordMutationCalls.push(input);
|
||||||
|
if (this.failure) return Result.fail(this.failure);
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buildCreateProps = () => {
|
const buildCreateProps = () => {
|
||||||
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
|
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
|
||||||
const languageCodeResult = LanguageCode.create("es");
|
const languageCodeResult = LanguageCode.create("es");
|
||||||
const currencyCodeResult = CurrencyCode.create("EUR");
|
const currencyCodeResult = CurrencyCode.create("EUR");
|
||||||
const globalDiscountPercentageResult = DiscountPercentage.create({ value: 0 });
|
const globalDiscountPercentageResult = DiscountPercentage.create({ value: 0 });
|
||||||
|
const statusResult = InvoiceStatus.create("draft");
|
||||||
|
|
||||||
assert.equal(proformaDateResult.isSuccess, true);
|
assert.equal(proformaDateResult.isSuccess, true);
|
||||||
assert.equal(languageCodeResult.isSuccess, true);
|
assert.equal(languageCodeResult.isSuccess, true);
|
||||||
assert.equal(currencyCodeResult.isSuccess, true);
|
assert.equal(currencyCodeResult.isSuccess, true);
|
||||||
assert.equal(globalDiscountPercentageResult.isSuccess, true);
|
assert.equal(globalDiscountPercentageResult.isSuccess, true);
|
||||||
|
assert.equal(statusResult.isSuccess, true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
companyId: UniqueID.generateNewID(),
|
companyId: UniqueID.generateNewID(),
|
||||||
status: {} as never,
|
status: statusResult.data,
|
||||||
proformaSeriesCode: Maybe.none(),
|
proformaSeriesCode: Maybe.none(),
|
||||||
targetInvoiceSeriesCode: Maybe.none(),
|
targetInvoiceSeriesCode: Maybe.none(),
|
||||||
proformaDate: proformaDateResult.data,
|
proformaDate: proformaDateResult.data,
|
||||||
@ -45,7 +77,7 @@ const buildCreateProps = () => {
|
|||||||
},
|
},
|
||||||
globalDiscountPercentage: globalDiscountPercentageResult.data,
|
globalDiscountPercentage: globalDiscountPercentageResult.data,
|
||||||
items: [],
|
items: [],
|
||||||
};
|
} satisfies ProformaCreateInputProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
test("create hereda payment term del customer cuando no llega explicito", async () => {
|
test("create hereda payment term del customer cuando no llega explicito", async () => {
|
||||||
@ -53,16 +85,23 @@ test("create hereda payment term del customer cuando no llega explicito", async
|
|||||||
const props = buildCreateProps();
|
const props = buildCreateProps();
|
||||||
const proformaId = UniqueID.generateNewID();
|
const proformaId = UniqueID.generateNewID();
|
||||||
let capturedPaymentTermId = Maybe.none<UniqueID>();
|
let capturedPaymentTermId = Maybe.none<UniqueID>();
|
||||||
|
let persistedProforma: Proforma | null = null;
|
||||||
let companyLookups = 0;
|
let companyLookups = 0;
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
|
||||||
const creator = new ProformaCreator({
|
const creator = new ProformaCreator({
|
||||||
repository: {
|
repository: {
|
||||||
create: async (proforma) => {
|
create: async (proforma: Proforma) => {
|
||||||
|
persistedProforma = proforma;
|
||||||
capturedPaymentTermId = proforma.paymentTermId;
|
capturedPaymentTermId = proforma.paymentTermId;
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
},
|
},
|
||||||
getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never),
|
getByIdInCompany: async () => {
|
||||||
|
if (!persistedProforma) throw new Error("Proforma was not persisted before reload");
|
||||||
|
return Result.ok(persistedProforma);
|
||||||
|
},
|
||||||
} as never,
|
} as never,
|
||||||
|
audit,
|
||||||
numberService: {
|
numberService: {
|
||||||
getNextForCompany: async () =>
|
getNextForCompany: async () =>
|
||||||
Result.ok({
|
Result.ok({
|
||||||
@ -117,6 +156,15 @@ test("create hereda payment term del customer cuando no llega explicito", async
|
|||||||
assert.equal(capturedPaymentTermId.isSome(), true);
|
assert.equal(capturedPaymentTermId.isSome(), true);
|
||||||
assert.equal(capturedPaymentTermId.unwrap().equals(customerPaymentTermId), true);
|
assert.equal(capturedPaymentTermId.unwrap().equals(customerPaymentTermId), true);
|
||||||
assert.equal(companyLookups, 0);
|
assert.equal(companyLookups, 0);
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 1);
|
||||||
|
const [auditCall] = audit.recordMutationCalls;
|
||||||
|
assert.equal(auditCall.action, "proforma.created");
|
||||||
|
assert.equal(auditCall.target.type, "proforma");
|
||||||
|
assert.equal(auditCall.target.id, proformaId.toPrimitive());
|
||||||
|
assert.equal(auditCall.target.label, "1");
|
||||||
|
assert.deepEqual(auditCall.before, {});
|
||||||
|
assert.equal(auditCall.after.status, "draft");
|
||||||
|
assert.equal(auditCall.after.proformaReference, "1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("create usa fallback a company default cuando customer no tiene payment term", async () => {
|
test("create usa fallback a company default cuando customer no tiene payment term", async () => {
|
||||||
@ -124,15 +172,21 @@ test("create usa fallback a company default cuando customer no tiene payment ter
|
|||||||
const props = buildCreateProps();
|
const props = buildCreateProps();
|
||||||
const proformaId = UniqueID.generateNewID();
|
const proformaId = UniqueID.generateNewID();
|
||||||
let capturedPaymentTermId = Maybe.none<UniqueID>();
|
let capturedPaymentTermId = Maybe.none<UniqueID>();
|
||||||
|
let persistedProforma: Proforma | null = null;
|
||||||
|
|
||||||
const creator = new ProformaCreator({
|
const creator = new ProformaCreator({
|
||||||
repository: {
|
repository: {
|
||||||
create: async (proforma) => {
|
create: async (proforma: Proforma) => {
|
||||||
|
persistedProforma = proforma;
|
||||||
capturedPaymentTermId = proforma.paymentTermId;
|
capturedPaymentTermId = proforma.paymentTermId;
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
},
|
},
|
||||||
getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never),
|
getByIdInCompany: async () => {
|
||||||
|
if (!persistedProforma) throw new Error("Proforma was not persisted before reload");
|
||||||
|
return Result.ok(persistedProforma);
|
||||||
|
},
|
||||||
} as never,
|
} as never,
|
||||||
|
audit: { recordMutation: async () => Result.ok(undefined) } as never,
|
||||||
numberService: {
|
numberService: {
|
||||||
getNextForCompany: async () =>
|
getNextForCompany: async () =>
|
||||||
Result.ok({
|
Result.ok({
|
||||||
@ -206,15 +260,21 @@ test("create con payment_term_id null explicito conserva el clear", async () =>
|
|||||||
};
|
};
|
||||||
const proformaId = UniqueID.generateNewID();
|
const proformaId = UniqueID.generateNewID();
|
||||||
let capturedPaymentTermId = Maybe.some(UniqueID.generateNewID());
|
let capturedPaymentTermId = Maybe.some(UniqueID.generateNewID());
|
||||||
|
let persistedProforma: Proforma | null = null;
|
||||||
|
|
||||||
const creator = new ProformaCreator({
|
const creator = new ProformaCreator({
|
||||||
repository: {
|
repository: {
|
||||||
create: async (proforma) => {
|
create: async (proforma: Proforma) => {
|
||||||
|
persistedProforma = proforma;
|
||||||
capturedPaymentTermId = proforma.paymentTermId;
|
capturedPaymentTermId = proforma.paymentTermId;
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
},
|
},
|
||||||
getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never),
|
getByIdInCompany: async () => {
|
||||||
|
if (!persistedProforma) throw new Error("Proforma was not persisted before reload");
|
||||||
|
return Result.ok(persistedProforma);
|
||||||
|
},
|
||||||
} as never,
|
} as never,
|
||||||
|
audit: { recordMutation: async () => Result.ok(undefined) } as never,
|
||||||
numberService: {
|
numberService: {
|
||||||
getNextForCompany: async () =>
|
getNextForCompany: async () =>
|
||||||
Result.ok({
|
Result.ok({
|
||||||
@ -265,3 +325,94 @@ test("create con payment_term_id null explicito conserva el clear", async () =>
|
|||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
assert.equal(capturedPaymentTermId.isNone(), true);
|
assert.equal(capturedPaymentTermId.isNone(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("create rechaza cuando no puede cargar el customer sin registrar Audit", async () => {
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
let createCalls = 0;
|
||||||
|
const creator = new ProformaCreator({
|
||||||
|
repository: {
|
||||||
|
create: async () => {
|
||||||
|
createCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
customerServices: {
|
||||||
|
getCustomerById: async () => Result.fail(new Error("Customer not found")),
|
||||||
|
} as never,
|
||||||
|
audit,
|
||||||
|
} as never);
|
||||||
|
const props = buildCreateProps();
|
||||||
|
|
||||||
|
const result = await creator.create({
|
||||||
|
companyId: props.companyId,
|
||||||
|
id: UniqueID.generateNewID(),
|
||||||
|
props,
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(createCalls, 0);
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create propaga el fallo de Audit", async () => {
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
const auditError = new Error("Audit failed");
|
||||||
|
const customerPaymentTermId = UniqueID.generateNewID();
|
||||||
|
audit.failWith(auditError);
|
||||||
|
let persistedProforma: Proforma | null = null;
|
||||||
|
const creator = new ProformaCreator({
|
||||||
|
repository: {
|
||||||
|
create: async (proforma: Proforma) => {
|
||||||
|
persistedProforma = proforma;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
getByIdInCompany: async () => {
|
||||||
|
if (!persistedProforma) throw new Error("Proforma was not persisted before reload");
|
||||||
|
return Result.ok(persistedProforma);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
audit,
|
||||||
|
customerServices: {
|
||||||
|
getCustomerById: async () =>
|
||||||
|
Result.ok({
|
||||||
|
paymentTermId: Maybe.some(customerPaymentTermId),
|
||||||
|
taxRegimeCode: Maybe.none(),
|
||||||
|
usesEquivalenceSurcharge: false,
|
||||||
|
usesRetention: false,
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
numberService: {
|
||||||
|
getNextForCompany: async () =>
|
||||||
|
Result.ok({
|
||||||
|
documentSeriesId: UniqueID.generateNewID().toPrimitive(),
|
||||||
|
documentSeriesCode: "PF",
|
||||||
|
number: "1",
|
||||||
|
reference: "1",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
taxResolver: {
|
||||||
|
ensureTaxRegimeByCode: async () => Result.ok(undefined),
|
||||||
|
ensureItemTaxes: async () => Result.fail(new Error("unused")),
|
||||||
|
findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")),
|
||||||
|
},
|
||||||
|
paymentResolver: {
|
||||||
|
ensurePaymentMethodById: async () => Result.ok(undefined),
|
||||||
|
ensurePaymentTermById: async () => Result.ok(undefined),
|
||||||
|
},
|
||||||
|
proformaSeriesValidator: { ensureValidProformaSeries: async () => Result.ok(undefined) },
|
||||||
|
targetInvoiceSeriesValidator: {
|
||||||
|
ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined),
|
||||||
|
},
|
||||||
|
companiesServices: { finder: { findById: async () => Result.fail(new Error("unused")) } },
|
||||||
|
} as never);
|
||||||
|
const props = buildCreateProps();
|
||||||
|
const result = await creator.create({
|
||||||
|
companyId: props.companyId,
|
||||||
|
id: UniqueID.generateNewID(),
|
||||||
|
props,
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 1);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ICompanyPublicServices } from "@erp/companies/api";
|
import type { ICompanyPublicServices } from "@erp/companies/api";
|
||||||
import type { ICustomerPublicServices } from "@erp/customers/api";
|
import type { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
import { UniqueID, type UniqueID as UniqueIDType } from "@repo/rdx-ddd";
|
import { UniqueID, type UniqueID as UniqueIDType } from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -11,6 +12,11 @@ import {
|
|||||||
Proforma,
|
Proforma,
|
||||||
ProformaTaxConfig,
|
ProformaTaxConfig,
|
||||||
} from "../../../domain";
|
} from "../../../domain";
|
||||||
|
import {
|
||||||
|
PROFORMA_AUDIT_ACTIONS,
|
||||||
|
toProformaAuditState,
|
||||||
|
toProformaAuditTarget,
|
||||||
|
} from "../audit/proforma-audit";
|
||||||
import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models";
|
import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models";
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
|
|
||||||
@ -42,6 +48,7 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||||
companiesServices: ICompanyPublicServices;
|
companiesServices: ICompanyPublicServices;
|
||||||
customerServices: ICustomerPublicServices;
|
customerServices: ICustomerPublicServices;
|
||||||
|
audit: IAudit;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -144,6 +151,17 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
return Result.fail(verifyResult.error);
|
return Result.fail(verifyResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.deps.audit.recordMutation({
|
||||||
|
action: PROFORMA_AUDIT_ACTIONS.CREATED,
|
||||||
|
target: toProformaAuditTarget(verifyResult.data),
|
||||||
|
before: {},
|
||||||
|
after: toProformaAuditState(verifyResult.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok(verifyResult.data);
|
return Result.ok(verifyResult.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { ProformaCannotBeDeletedError } from "../../../domain";
|
import { ProformaCannotBeDeletedError } from "../../../domain";
|
||||||
|
import {
|
||||||
|
PROFORMA_AUDIT_ACTIONS,
|
||||||
|
toProformaAuditState,
|
||||||
|
toProformaAuditTarget,
|
||||||
|
} from "../audit/proforma-audit";
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
|
|
||||||
export interface IProformaDeleter {
|
export interface IProformaDeleter {
|
||||||
@ -16,6 +22,7 @@ export class ProformaDeleter implements IProformaDeleter {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
repository: IProformaRepository;
|
repository: IProformaRepository;
|
||||||
|
audit: IAudit;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -35,6 +42,7 @@ export class ProformaDeleter implements IProformaDeleter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proforma = proformaResult.data;
|
const proforma = proformaResult.data;
|
||||||
|
const before = toProformaAuditState(proforma);
|
||||||
const canDeleteResult = proforma.ensureCanBeDeleted();
|
const canDeleteResult = proforma.ensureCanBeDeleted();
|
||||||
|
|
||||||
if (canDeleteResult.isFailure) {
|
if (canDeleteResult.isFailure) {
|
||||||
@ -54,7 +62,10 @@ export class ProformaDeleter implements IProformaDeleter {
|
|||||||
|
|
||||||
if (hasIssuedInvoiceResult.data) {
|
if (hasIssuedInvoiceResult.data) {
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
new ProformaCannotBeDeletedError(params.id.toString(), "Issued proformas cannot be deleted.")
|
new ProformaCannotBeDeletedError(
|
||||||
|
params.id.toString(),
|
||||||
|
"Issued proformas cannot be deleted."
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +79,17 @@ export class ProformaDeleter implements IProformaDeleter {
|
|||||||
return Result.fail(deleteResult.error);
|
return Result.fail(deleteResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.deps.audit.recordMutation({
|
||||||
|
action: PROFORMA_AUDIT_ACTIONS.DELETED,
|
||||||
|
target: toProformaAuditTarget(proforma),
|
||||||
|
before,
|
||||||
|
after: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { InvoiceStatus, type Proforma } from "../../../domain";
|
import { InvoiceStatus, type Proforma } from "../../../domain";
|
||||||
|
import {
|
||||||
|
PROFORMA_AUDIT_ACTIONS,
|
||||||
|
toProformaAuditState,
|
||||||
|
toProformaAuditTarget,
|
||||||
|
} from "../audit/proforma-audit";
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
|
|
||||||
export interface IProformaStatusChanger {
|
export interface IProformaStatusChanger {
|
||||||
@ -17,6 +23,7 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
repository: IProformaRepository;
|
repository: IProformaRepository;
|
||||||
|
audit: IAudit;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -43,6 +50,7 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proforma = proformaResult.data;
|
const proforma = proformaResult.data;
|
||||||
|
const before = toProformaAuditState(proforma);
|
||||||
|
|
||||||
const changeResult = proforma.changeStatus(statusResult.data);
|
const changeResult = proforma.changeStatus(statusResult.data);
|
||||||
|
|
||||||
@ -60,6 +68,17 @@ export class ProformaStatusChanger implements IProformaStatusChanger {
|
|||||||
return Result.fail(updateResult.error);
|
return Result.fail(updateResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.deps.audit.recordMutation({
|
||||||
|
action: PROFORMA_AUDIT_ACTIONS.STATUS_CHANGED,
|
||||||
|
target: toProformaAuditTarget(proforma),
|
||||||
|
before,
|
||||||
|
after: toProformaAuditState(proforma),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok(proforma);
|
return Result.ok(proforma);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { Result } from "@repo/rdx-utils";
|
import type {
|
||||||
import { Maybe } from "@repo/rdx-utils";
|
IAudit,
|
||||||
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
RecordAuditEventShortcutInput,
|
||||||
|
RecordAuditInput,
|
||||||
|
RecordAuditMutationShortcutInput,
|
||||||
|
} from "@repo/rdx-audit";
|
||||||
|
import {
|
||||||
|
City,
|
||||||
|
Country,
|
||||||
|
Name,
|
||||||
|
PostalCode,
|
||||||
|
Province,
|
||||||
|
Street,
|
||||||
|
TINNumber,
|
||||||
|
UniqueID,
|
||||||
|
UtcDate,
|
||||||
|
} from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { ItemAmount, ItemDescription, ItemQuantity, ProformaItemTaxes } from "../../../domain";
|
||||||
|
import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture";
|
||||||
|
|
||||||
import { ProformaUpdater } from "./proforma-updater";
|
import { ProformaUpdater } from "./proforma-updater";
|
||||||
|
|
||||||
const buildProformaDouble = (paymentTermId?: UniqueID) => {
|
class FakeAudit implements IAudit {
|
||||||
|
public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = [];
|
||||||
|
private error: Error | undefined;
|
||||||
|
|
||||||
|
failWith(error: Error): void {
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async record(_input: RecordAuditInput) {
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
async recordEvent(_input: RecordAuditEventShortcutInput) {
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
async recordMutation(input: RecordAuditMutationShortcutInput) {
|
||||||
|
this.recordMutationCalls.push(input);
|
||||||
|
|
||||||
|
if (this.error) {
|
||||||
|
return Result.fail(this.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unwrap = <T>(result: { data: T; isSuccess: boolean }): T => {
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildProformaDouble = (params?: { paymentTermId?: UniqueID; didChange?: boolean }) => {
|
||||||
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
|
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
|
||||||
assert.equal(proformaDateResult.isSuccess, true);
|
assert.equal(proformaDateResult.isSuccess, true);
|
||||||
|
|
||||||
@ -20,7 +68,7 @@ const buildProformaDouble = (paymentTermId?: UniqueID) => {
|
|||||||
isApproved: () => false,
|
isApproved: () => false,
|
||||||
},
|
},
|
||||||
proformaDate: proformaDateResult.data,
|
proformaDate: proformaDateResult.data,
|
||||||
paymentTermId: paymentTermId ? Maybe.some(paymentTermId) : Maybe.none(),
|
paymentTermId: params?.paymentTermId ? Maybe.some(params.paymentTermId) : Maybe.none(),
|
||||||
taxConfig: {
|
taxConfig: {
|
||||||
taxMode: "single",
|
taxMode: "single",
|
||||||
defaultIvaCode: Maybe.none(),
|
defaultIvaCode: Maybe.none(),
|
||||||
@ -31,16 +79,312 @@ const buildProformaDouble = (paymentTermId?: UniqueID) => {
|
|||||||
},
|
},
|
||||||
update: (patch: unknown) => {
|
update: (patch: unknown) => {
|
||||||
receivedPatch = patch;
|
receivedPatch = patch;
|
||||||
return Result.ok(undefined);
|
return Result.ok(params?.didChange ?? true);
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
getReceivedPatch: () => receivedPatch,
|
getReceivedPatch: () => receivedPatch,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test("update cambia la descripción en una Proforma real", async () => {
|
||||||
|
const proforma = createProformaTestFixture({ description: "Before" });
|
||||||
|
let updateCalls = 0;
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
|
||||||
|
const updater = new ProformaUpdater({
|
||||||
|
repository: {
|
||||||
|
getByIdInCompany: async () => Result.ok(proforma),
|
||||||
|
update: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
audit,
|
||||||
|
taxResolver: {} as never,
|
||||||
|
paymentResolver: {} as never,
|
||||||
|
targetInvoiceSeriesValidator: {} as never,
|
||||||
|
customerServices: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
companyId: proforma.companyId,
|
||||||
|
id: proforma.id,
|
||||||
|
patchProps: { description: Maybe.some("After") },
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(updateCalls, 1);
|
||||||
|
assert.equal(result.data.description.unwrap(), "After");
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 1);
|
||||||
|
const [auditCall] = audit.recordMutationCalls;
|
||||||
|
assert.equal(auditCall.action, "proforma.updated");
|
||||||
|
assert.equal(auditCall.target.type, "proforma");
|
||||||
|
assert.equal(auditCall.target.id, proforma.id.toPrimitive());
|
||||||
|
assert.equal(auditCall.target.label, proforma.proformaReference.toPrimitive());
|
||||||
|
assert.equal(auditCall.before.description, "Before");
|
||||||
|
assert.equal(auditCall.after.description, "After");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update registra customer y recipient en una sola mutación Audit", async () => {
|
||||||
|
const customerAId = UniqueID.generateNewID();
|
||||||
|
const customerBId = UniqueID.generateNewID();
|
||||||
|
const proforma = createProformaTestFixture({ customerId: customerAId });
|
||||||
|
let updateCalls = 0;
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
|
||||||
|
const updater = new ProformaUpdater({
|
||||||
|
repository: {
|
||||||
|
getByIdInCompany: async () => Result.ok(proforma),
|
||||||
|
update: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
audit,
|
||||||
|
taxResolver: {} as never,
|
||||||
|
paymentResolver: {} as never,
|
||||||
|
targetInvoiceSeriesValidator: {} as never,
|
||||||
|
customerServices: {
|
||||||
|
getCustomerById: async () =>
|
||||||
|
Result.ok({
|
||||||
|
id: customerBId,
|
||||||
|
name: unwrap(Name.create("Cliente B")),
|
||||||
|
tin: Maybe.some(unwrap(TINNumber.create("ESB1234567"))),
|
||||||
|
address: {
|
||||||
|
street: Maybe.some(unwrap(Street.create("Calle Uno 1"))),
|
||||||
|
street2: Maybe.some(unwrap(Street.create("Piso 2"))),
|
||||||
|
city: Maybe.some(unwrap(City.create("Madrid"))),
|
||||||
|
postalCode: Maybe.some(unwrap(PostalCode.create("28001"))),
|
||||||
|
province: Maybe.some(unwrap(Province.create("Madrid"))),
|
||||||
|
country: Maybe.some(unwrap(Country.create("ES"))),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
companyId: proforma.companyId,
|
||||||
|
id: proforma.id,
|
||||||
|
patchProps: { customerId: customerBId },
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(updateCalls, 1);
|
||||||
|
assert.equal(result.data.customerId.equals(customerBId), true);
|
||||||
|
assert.equal(result.data.recipient.unwrap().name.toPrimitive(), "Cliente B");
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 1);
|
||||||
|
const [auditCall] = audit.recordMutationCalls;
|
||||||
|
assert.equal(auditCall.action, "proforma.updated");
|
||||||
|
assert.equal(auditCall.before.customerId, customerAId.toPrimitive());
|
||||||
|
assert.equal(auditCall.after.customerId, customerBId.toPrimitive());
|
||||||
|
assert.notDeepEqual(auditCall.before.recipient, auditCall.after.recipient);
|
||||||
|
assert.equal(auditCall.before.recipient?.name, "Cliente de prueba");
|
||||||
|
assert.equal(auditCall.after.recipient?.name, "Cliente B");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update registra los items antes y después de un cambio funcional", async () => {
|
||||||
|
const initialItem = {
|
||||||
|
description: Maybe.some(unwrap(ItemDescription.create("Línea A"))),
|
||||||
|
quantity: Maybe.some(unwrap(ItemQuantity.create({ value: 1 }))),
|
||||||
|
unitAmount: Maybe.some(unwrap(ItemAmount.create({ value: 1000, currency_code: "EUR" }))),
|
||||||
|
itemDiscountPercentage: Maybe.none(),
|
||||||
|
taxes: ProformaItemTaxes.empty().getProps(),
|
||||||
|
};
|
||||||
|
const proforma = createProformaTestFixture({ items: [initialItem] });
|
||||||
|
let updateCalls = 0;
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
|
||||||
|
const updater = new ProformaUpdater({
|
||||||
|
repository: {
|
||||||
|
getByIdInCompany: async () => Result.ok(proforma),
|
||||||
|
update: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
audit,
|
||||||
|
taxResolver: {
|
||||||
|
ensureItemTaxes: async () => Result.ok(ProformaItemTaxes.empty().getProps()),
|
||||||
|
} as never,
|
||||||
|
paymentResolver: {} as never,
|
||||||
|
targetInvoiceSeriesValidator: {} as never,
|
||||||
|
customerServices: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
companyId: proforma.companyId,
|
||||||
|
id: proforma.id,
|
||||||
|
patchProps: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...initialItem,
|
||||||
|
description: Maybe.some(unwrap(ItemDescription.create("Línea B"))),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(updateCalls, 1);
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 1);
|
||||||
|
const [auditCall] = audit.recordMutationCalls;
|
||||||
|
assert.equal(auditCall.action, "proforma.updated");
|
||||||
|
assert.equal(auditCall.before.items.length, auditCall.after.items.length);
|
||||||
|
assert.notDeepEqual(auditCall.before.items, auditCall.after.items);
|
||||||
|
assert.equal(auditCall.before.items[0]?.description, "Línea A");
|
||||||
|
assert.equal(auditCall.after.items[0]?.description, "Línea B");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update limpia el payment term y lo registra en Audit", async () => {
|
||||||
|
const existingPaymentTermId = unwrap(UniqueID.create("01900000-0000-7000-8000-000000000005"));
|
||||||
|
const proforma = createProformaTestFixture({
|
||||||
|
paymentTermId: Maybe.some(existingPaymentTermId),
|
||||||
|
});
|
||||||
|
let updateCalls = 0;
|
||||||
|
let paymentTermValidationCalls = 0;
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
|
||||||
|
const updater = new ProformaUpdater({
|
||||||
|
repository: {
|
||||||
|
getByIdInCompany: async () => Result.ok(proforma),
|
||||||
|
update: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
audit,
|
||||||
|
taxResolver: {} as never,
|
||||||
|
paymentResolver: {
|
||||||
|
ensurePaymentTermById: async () => {
|
||||||
|
paymentTermValidationCalls += 1;
|
||||||
|
return Result.fail(new Error("A cleared payment term must not be validated"));
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
targetInvoiceSeriesValidator: {} as never,
|
||||||
|
customerServices: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
companyId: proforma.companyId,
|
||||||
|
id: proforma.id,
|
||||||
|
patchProps: { paymentTermId: Maybe.none() },
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(paymentTermValidationCalls, 0);
|
||||||
|
assert.equal(updateCalls, 1);
|
||||||
|
assert.equal(result.data.paymentTermId.isNone(), true);
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 1);
|
||||||
|
const [auditCall] = audit.recordMutationCalls;
|
||||||
|
assert.equal(auditCall.action, "proforma.updated");
|
||||||
|
assert.equal(auditCall.before.paymentTermId, existingPaymentTermId.toPrimitive());
|
||||||
|
assert.equal(auditCall.after.paymentTermId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update no persiste ni registra Audit cuando no puede cargar la Proforma", async () => {
|
||||||
|
let updateCalls = 0;
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
|
||||||
|
const updater = new ProformaUpdater({
|
||||||
|
repository: {
|
||||||
|
getByIdInCompany: async () => Result.fail(new Error("Proforma not found")),
|
||||||
|
update: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
audit,
|
||||||
|
taxResolver: {} as never,
|
||||||
|
paymentResolver: {} as never,
|
||||||
|
targetInvoiceSeriesValidator: {} as never,
|
||||||
|
customerServices: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
companyId: UniqueID.generateNewID(),
|
||||||
|
id: UniqueID.generateNewID(),
|
||||||
|
patchProps: {},
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(updateCalls, 0);
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update sin cambio funcional no persiste ni registra Audit", async () => {
|
||||||
|
const proforma = createProformaTestFixture({ description: "Same" });
|
||||||
|
let updateCalls = 0;
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
|
||||||
|
const updater = new ProformaUpdater({
|
||||||
|
repository: {
|
||||||
|
getByIdInCompany: async () => Result.ok(proforma),
|
||||||
|
update: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
audit,
|
||||||
|
taxResolver: {} as never,
|
||||||
|
paymentResolver: {} as never,
|
||||||
|
targetInvoiceSeriesValidator: {} as never,
|
||||||
|
customerServices: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
companyId: proforma.companyId,
|
||||||
|
id: proforma.id,
|
||||||
|
patchProps: { description: Maybe.some("Same") },
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(updateCalls, 0);
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 0);
|
||||||
|
assert.equal(result.data.description.unwrap(), "Same");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update propaga el fallo de Audit tras un cambio funcional", async () => {
|
||||||
|
const proforma = createProformaTestFixture({ description: "Before" });
|
||||||
|
let updateCalls = 0;
|
||||||
|
const audit = new FakeAudit();
|
||||||
|
audit.failWith(new Error("Audit failed"));
|
||||||
|
|
||||||
|
const updater = new ProformaUpdater({
|
||||||
|
repository: {
|
||||||
|
getByIdInCompany: async () => Result.ok(proforma),
|
||||||
|
update: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
audit,
|
||||||
|
taxResolver: {} as never,
|
||||||
|
paymentResolver: {} as never,
|
||||||
|
targetInvoiceSeriesValidator: {} as never,
|
||||||
|
customerServices: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
companyId: proforma.companyId,
|
||||||
|
id: proforma.id,
|
||||||
|
patchProps: { description: Maybe.some("After") },
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(updateCalls, 1);
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 1);
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
});
|
||||||
|
|
||||||
test("update conserva un payment term inactivo ya asignado si no cambia", async () => {
|
test("update conserva un payment term inactivo ya asignado si no cambia", async () => {
|
||||||
const existingPaymentTermId = UniqueID.generateNewID();
|
const existingPaymentTermId = UniqueID.generateNewID();
|
||||||
const { proforma } = buildProformaDouble(existingPaymentTermId);
|
const { proforma } = buildProformaDouble({ paymentTermId: existingPaymentTermId });
|
||||||
let validationCalls = 0;
|
let validationCalls = 0;
|
||||||
|
|
||||||
const updater = new ProformaUpdater({
|
const updater = new ProformaUpdater({
|
||||||
@ -81,7 +425,7 @@ test("update conserva un payment term inactivo ya asignado si no cambia", async
|
|||||||
|
|
||||||
test("update permite limpiar el payment term con null explicito", async () => {
|
test("update permite limpiar el payment term con null explicito", async () => {
|
||||||
const existingPaymentTermId = UniqueID.generateNewID();
|
const existingPaymentTermId = UniqueID.generateNewID();
|
||||||
const { proforma } = buildProformaDouble(existingPaymentTermId);
|
const { proforma } = buildProformaDouble({ paymentTermId: existingPaymentTermId });
|
||||||
let validationCalls = 0;
|
let validationCalls = 0;
|
||||||
|
|
||||||
const updater = new ProformaUpdater({
|
const updater = new ProformaUpdater({
|
||||||
@ -121,7 +465,7 @@ test("update permite limpiar el payment term con null explicito", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("update rechaza asignar un nuevo payment term inactivo", async () => {
|
test("update rechaza asignar un nuevo payment term inactivo", async () => {
|
||||||
const { proforma } = buildProformaDouble(UniqueID.generateNewID());
|
const { proforma } = buildProformaDouble({ paymentTermId: UniqueID.generateNewID() });
|
||||||
|
|
||||||
const updater = new ProformaUpdater({
|
const updater = new ProformaUpdater({
|
||||||
repository: {
|
repository: {
|
||||||
@ -203,6 +547,90 @@ test("update refresca el snapshot del destinatario al cambiar el customer", asyn
|
|||||||
|
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
assert.equal(getReceivedPatch() !== undefined, true);
|
assert.equal(getReceivedPatch() !== undefined, true);
|
||||||
assert.equal((getReceivedPatch() as { customerId: UniqueID }).customerId.equals(customerId), true);
|
assert.equal(
|
||||||
|
(getReceivedPatch() as { customerId: UniqueID }).customerId.equals(customerId),
|
||||||
|
true
|
||||||
|
);
|
||||||
assert.equal((getReceivedPatch() as { recipient: Maybe<unknown> }).recipient.isSome(), true);
|
assert.equal((getReceivedPatch() as { recipient: Maybe<unknown> }).recipient.isSome(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("update no persiste ni relee cuando el aggregate no tiene cambios funcionales", async () => {
|
||||||
|
const { proforma } = buildProformaDouble({ didChange: false });
|
||||||
|
let updateCalls = 0;
|
||||||
|
let getByIdCalls = 0;
|
||||||
|
|
||||||
|
const updater = new ProformaUpdater({
|
||||||
|
repository: {
|
||||||
|
getByIdInCompany: async () => {
|
||||||
|
getByIdCalls += 1;
|
||||||
|
return Result.ok(proforma);
|
||||||
|
},
|
||||||
|
update: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
taxResolver: {
|
||||||
|
findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")),
|
||||||
|
} as never,
|
||||||
|
paymentResolver: {
|
||||||
|
ensurePaymentMethodById: async () => Result.ok(undefined),
|
||||||
|
ensurePaymentTermById: async () => Result.ok(undefined),
|
||||||
|
} as never,
|
||||||
|
targetInvoiceSeriesValidator: {
|
||||||
|
ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined),
|
||||||
|
} as never,
|
||||||
|
customerServices: {
|
||||||
|
getCustomerById: async () => Result.fail(new Error("unused")),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
companyId: UniqueID.generateNewID(),
|
||||||
|
id: UniqueID.generateNewID(),
|
||||||
|
patchProps: {},
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(updateCalls, 0);
|
||||||
|
assert.equal(getByIdCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update persiste una vez cuando el aggregate informa un cambio funcional", async () => {
|
||||||
|
const { proforma } = buildProformaDouble({ didChange: true });
|
||||||
|
let updateCalls = 0;
|
||||||
|
|
||||||
|
const updater = new ProformaUpdater({
|
||||||
|
repository: {
|
||||||
|
getByIdInCompany: async () => Result.ok(proforma),
|
||||||
|
update: async () => {
|
||||||
|
updateCalls += 1;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
taxResolver: {
|
||||||
|
findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")),
|
||||||
|
} as never,
|
||||||
|
paymentResolver: {
|
||||||
|
ensurePaymentMethodById: async () => Result.ok(undefined),
|
||||||
|
ensurePaymentTermById: async () => Result.ok(undefined),
|
||||||
|
} as never,
|
||||||
|
targetInvoiceSeriesValidator: {
|
||||||
|
ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined),
|
||||||
|
} as never,
|
||||||
|
customerServices: {
|
||||||
|
getCustomerById: async () => Result.fail(new Error("unused")),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
companyId: UniqueID.generateNewID(),
|
||||||
|
id: UniqueID.generateNewID(),
|
||||||
|
patchProps: {},
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(updateCalls, 1);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
|
import type { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
import { DomainValidationError, type UniqueID, type UtcDate } from "@repo/rdx-ddd";
|
import { DomainValidationError, type UniqueID, type UtcDate } from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
import type { ICustomerPublicServices } from "@erp/customers/api";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type Proforma,
|
type Proforma,
|
||||||
type ProformaItemPatchProps,
|
type ProformaItemPatchProps,
|
||||||
type ProformaPatchProps,
|
type ProformaPatchProps,
|
||||||
ProformaTaxConfig,
|
|
||||||
ProformaRecipient,
|
ProformaRecipient,
|
||||||
|
ProformaTaxConfig,
|
||||||
} from "../../../domain";
|
} from "../../../domain";
|
||||||
|
import {
|
||||||
|
PROFORMA_AUDIT_ACTIONS,
|
||||||
|
toProformaAuditState,
|
||||||
|
toProformaAuditTarget,
|
||||||
|
} from "../audit/proforma-audit";
|
||||||
import type { ProformaPatchInputProps } from "../models";
|
import type { ProformaPatchInputProps } from "../models";
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
|
|
||||||
@ -33,6 +39,7 @@ export class ProformaUpdater implements IProformaUpdater {
|
|||||||
paymentResolver: ProformaPaymentResolver;
|
paymentResolver: ProformaPaymentResolver;
|
||||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||||
customerServices: ICustomerPublicServices;
|
customerServices: ICustomerPublicServices;
|
||||||
|
audit: IAudit;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -52,6 +59,7 @@ export class ProformaUpdater implements IProformaUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proforma = existingResult.data;
|
const proforma = existingResult.data;
|
||||||
|
const before = toProformaAuditState(proforma);
|
||||||
|
|
||||||
if (proforma.status.isIssued()) {
|
if (proforma.status.isIssued()) {
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
@ -97,6 +105,10 @@ export class ProformaUpdater implements IProformaUpdater {
|
|||||||
return Result.fail(updateResult.error);
|
return Result.fail(updateResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!updateResult.data) {
|
||||||
|
return Result.ok(proforma);
|
||||||
|
}
|
||||||
|
|
||||||
// Persistir cambios
|
// Persistir cambios
|
||||||
const saveResult = await this.deps.repository.update(proforma, transaction);
|
const saveResult = await this.deps.repository.update(proforma, transaction);
|
||||||
|
|
||||||
@ -104,6 +116,17 @@ export class ProformaUpdater implements IProformaUpdater {
|
|||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.deps.audit.recordMutation({
|
||||||
|
action: PROFORMA_AUDIT_ACTIONS.UPDATED,
|
||||||
|
target: toProformaAuditTarget(proforma),
|
||||||
|
before,
|
||||||
|
after: toProformaAuditState(proforma),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
// Volvemos a recuperar lo guardado para asegurarnos de que los cambios se reflejen correctamente
|
// Volvemos a recuperar lo guardado para asegurarnos de que los cambios se reflejen correctamente
|
||||||
const verifyResult = await this.deps.repository.getByIdInCompany(companyId, id, transaction);
|
const verifyResult = await this.deps.repository.getByIdInCompany(companyId, id, transaction);
|
||||||
|
|
||||||
@ -148,7 +171,9 @@ export class ProformaUpdater implements IProformaUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (patch.paymentTermId !== undefined) {
|
if (patch.paymentTermId !== undefined) {
|
||||||
if (
|
if (patch.paymentTermId.isNone()) {
|
||||||
|
// Una limpieza explícita no referencia ningún término que validar.
|
||||||
|
} else if (
|
||||||
patch.paymentTermId.isSome() &&
|
patch.paymentTermId.isSome() &&
|
||||||
params.currentPaymentTermId.isSome() &&
|
params.currentPaymentTermId.isSome() &&
|
||||||
patch.paymentTermId.unwrap().equals(params.currentPaymentTermId.unwrap())
|
patch.paymentTermId.unwrap().equals(params.currentPaymentTermId.unwrap())
|
||||||
|
|||||||
@ -1,224 +1,106 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import type { IPaymentTermPublicFinder } from "@erp/catalogs/api";
|
|
||||||
import type { ICompanyPublicServices } from "@erp/companies/api";
|
|
||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
import type { ICustomerPublicServices } from "@erp/customers/api";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import { UniqueID } from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { createProformaTestFixture } from "../../../domain/proformas/testing/proforma-test-fixture";
|
||||||
|
|
||||||
import { DuplicateProformaByIdUseCase } from "./duplicate-proforma-by-id.use-case";
|
import { DuplicateProformaByIdUseCase } from "./duplicate-proforma-by-id.use-case";
|
||||||
|
|
||||||
const buildTransactionManager = (): ITransactionManager => ({
|
const transactionManager: ITransactionManager = {
|
||||||
start: async () => undefined,
|
start: async () => undefined,
|
||||||
getTransaction: () => undefined,
|
getTransaction: () => undefined,
|
||||||
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined),
|
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined),
|
||||||
commit: async () => undefined,
|
commit: async () => undefined,
|
||||||
rollback: async () => undefined,
|
rollback: async () => undefined,
|
||||||
});
|
};
|
||||||
|
|
||||||
const buildUseCase = (params?: {
|
const buildUseCase = (params?: { sourceFailure?: Error; duplicatorFailure?: Error }) => {
|
||||||
sourcePaymentTermId?: Maybe<UniqueID>;
|
const source = createProformaTestFixture();
|
||||||
customerPaymentTermId?: Maybe<UniqueID>;
|
const duplicated = createProformaTestFixture({ description: "Duplicada" });
|
||||||
companyDefaultPaymentTermId?: string | null;
|
let duplicatorCalls = 0;
|
||||||
activePaymentTermIds?: Set<string>;
|
let receivedParams: unknown;
|
||||||
}) => {
|
|
||||||
const companyId = UniqueID.generateNewID();
|
|
||||||
const sourceId = UniqueID.generateNewID();
|
|
||||||
const customerId = UniqueID.generateNewID();
|
|
||||||
const activePaymentTermIds = params?.activePaymentTermIds ?? new Set<string>();
|
|
||||||
let receivedPaymentTermId: Maybe<UniqueID> | undefined;
|
|
||||||
|
|
||||||
const paymentTermFinder: IPaymentTermPublicFinder = {
|
|
||||||
findByIdInCompany: async (params: { id: UniqueID }) =>
|
|
||||||
Result.ok(
|
|
||||||
activePaymentTermIds.has(params.id.toString())
|
|
||||||
? Maybe.some({
|
|
||||||
id: params.id,
|
|
||||||
companyId,
|
|
||||||
name: "Plazo",
|
|
||||||
description: Maybe.none(),
|
|
||||||
isActive: true,
|
|
||||||
isSystem: false,
|
|
||||||
})
|
|
||||||
: Maybe.none()
|
|
||||||
),
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
const customerServices: ICustomerPublicServices = {
|
|
||||||
findCustomerByTIN: async () => Result.fail(new Error("unused")),
|
|
||||||
createCustomer: async () => Result.fail(new Error("unused")),
|
|
||||||
getCustomerById: async () =>
|
|
||||||
Result.ok({
|
|
||||||
paymentTermId: params?.customerPaymentTermId ?? Maybe.none(),
|
|
||||||
} as never),
|
|
||||||
};
|
|
||||||
|
|
||||||
const companiesServices: ICompanyPublicServices = {
|
|
||||||
finder: {
|
|
||||||
findById: async () =>
|
|
||||||
Result.ok(
|
|
||||||
Maybe.some({
|
|
||||||
id: companyId.toString(),
|
|
||||||
defaultPaymentTermId: params?.companyDefaultPaymentTermId ?? null,
|
|
||||||
} as never)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
const useCase = new DuplicateProformaByIdUseCase({
|
const useCase = new DuplicateProformaByIdUseCase({
|
||||||
creator: {
|
duplicator: {
|
||||||
create: async (input: { props: { paymentTermId?: Maybe<UniqueID> }; id: UniqueID }) => {
|
duplicate: async (input) => {
|
||||||
receivedPaymentTermId = input.props.paymentTermId;
|
duplicatorCalls += 1;
|
||||||
return Result.ok({ id: input.id } as never);
|
receivedParams = input;
|
||||||
|
return params?.duplicatorFailure
|
||||||
|
? Result.fail(params.duplicatorFailure)
|
||||||
|
: Result.ok({ proforma: duplicated, paymentTermAdaptation: null });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as never,
|
|
||||||
finder: {
|
finder: {
|
||||||
findProformaByIdIncludingArchived: async () =>
|
findProformaByIdIncludingArchived: async () =>
|
||||||
Result.ok({
|
params?.sourceFailure ? Result.fail(params.sourceFailure) : Result.ok(source),
|
||||||
documentSeriesId: Maybe.some(UniqueID.generateNewID().toPrimitive()),
|
|
||||||
targetInvoiceSeriesCode: Maybe.none(),
|
|
||||||
customerId,
|
|
||||||
reference: Maybe.none(),
|
|
||||||
description: Maybe.none(),
|
|
||||||
notes: Maybe.none(),
|
|
||||||
languageCode: { toPrimitive: () => "es" },
|
|
||||||
currencyCode: { toPrimitive: () => "EUR" },
|
|
||||||
paymentMethodId: Maybe.none(),
|
|
||||||
paymentTermId: params?.sourcePaymentTermId ?? Maybe.none(),
|
|
||||||
taxRegimeCode: Maybe.some("01"),
|
|
||||||
taxConfig: {
|
|
||||||
taxMode: "single",
|
|
||||||
defaultIvaCode: Maybe.none(),
|
|
||||||
usesEquivalenceSurcharge: false,
|
|
||||||
defaultRecCode: Maybe.none(),
|
|
||||||
usesRetention: false,
|
|
||||||
defaultRetentionCode: Maybe.none(),
|
|
||||||
},
|
|
||||||
globalDiscountPercentage: { toPrimitive: () => ({ value: 0, scale: 2 }) },
|
|
||||||
items: {
|
|
||||||
getAll: () => [],
|
|
||||||
},
|
|
||||||
} as never),
|
|
||||||
findProformaById: async () => Result.ok({ id: sourceId } as never),
|
|
||||||
} as never,
|
} as never,
|
||||||
fullReadModelAssembler: {
|
fullReadModelAssembler: {
|
||||||
assemble: async () => Result.ok({} as never),
|
assemble: async () => Result.ok(duplicated),
|
||||||
} as never,
|
} as never,
|
||||||
fullSnapshotBuilder: {
|
fullSnapshotBuilder: {
|
||||||
toOutput: () => ({ id: sourceId.toPrimitive() }),
|
toOutput: (proforma) => ({
|
||||||
|
id: proforma.id.toPrimitive(),
|
||||||
|
description: proforma.description.getOrUndefined() ?? null,
|
||||||
|
}),
|
||||||
} as never,
|
} as never,
|
||||||
documentSeriesServices: {
|
transactionManager,
|
||||||
listActiveSeries: async (params: { documentType: string }) =>
|
|
||||||
Result.ok([
|
|
||||||
{
|
|
||||||
id: UniqueID.generateNewID().toPrimitive(),
|
|
||||||
code: params.documentType === "proforma" ? "PF" : "FV",
|
|
||||||
is_default: true,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
} as never,
|
|
||||||
companiesServices,
|
|
||||||
customerServices,
|
|
||||||
paymentTermFinder,
|
|
||||||
transactionManager: buildTransactionManager(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
companyId,
|
source,
|
||||||
sourceId,
|
duplicated,
|
||||||
useCase,
|
useCase,
|
||||||
getReceivedPaymentTermId: () => receivedPaymentTermId,
|
getDuplicatorCalls: () => duplicatorCalls,
|
||||||
|
getReceivedParams: () => receivedParams,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
test("duplicate conserva el payment term original si sigue activo", async () => {
|
test("duplicate usa Duplicator y conserva la respuesta final", async () => {
|
||||||
const paymentTermId = UniqueID.generateNewID();
|
const setup = buildUseCase();
|
||||||
const setup = buildUseCase({
|
|
||||||
sourcePaymentTermId: Maybe.some(paymentTermId),
|
|
||||||
activePaymentTermIds: new Set([paymentTermId.toString()]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await setup.useCase.execute({
|
const result = await setup.useCase.execute({
|
||||||
companyId: setup.companyId,
|
companyId: setup.source.companyId,
|
||||||
params: { proforma_id: setup.sourceId.toPrimitive() },
|
params: { proforma_id: setup.source.id.toPrimitive() },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
assert.equal(setup.getReceivedPaymentTermId()?.unwrap().equals(paymentTermId), true);
|
assert.equal(setup.getDuplicatorCalls(), 1);
|
||||||
|
assert.deepEqual(setup.getReceivedParams(), {
|
||||||
|
companyId: setup.source.companyId,
|
||||||
|
source: setup.source,
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
assert.equal(result.data.id, setup.duplicated.id.toPrimitive());
|
||||||
|
assert.equal(result.data.description, "Duplicada");
|
||||||
assert.equal(result.data.payment_term_adaptation, null);
|
assert.equal(result.data.payment_term_adaptation, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("duplicate usa el payment term activo del customer si el original está inactivo", async () => {
|
test("duplicate no invoca Duplicator si falla la carga de source", async () => {
|
||||||
const originalPaymentTermId = UniqueID.generateNewID();
|
const sourceError = new Error("Source not found");
|
||||||
const customerPaymentTermId = UniqueID.generateNewID();
|
const setup = buildUseCase({ sourceFailure: sourceError });
|
||||||
const setup = buildUseCase({
|
|
||||||
sourcePaymentTermId: Maybe.some(originalPaymentTermId),
|
|
||||||
customerPaymentTermId: Maybe.some(customerPaymentTermId),
|
|
||||||
activePaymentTermIds: new Set([customerPaymentTermId.toString()]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await setup.useCase.execute({
|
const result = await setup.useCase.execute({
|
||||||
companyId: setup.companyId,
|
companyId: setup.source.companyId,
|
||||||
params: { proforma_id: setup.sourceId.toPrimitive() },
|
params: { proforma_id: setup.source.id.toPrimitive() },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isFailure, true);
|
||||||
assert.equal(setup.getReceivedPaymentTermId()?.unwrap().equals(customerPaymentTermId), true);
|
assert.equal(result.error, sourceError);
|
||||||
assert.deepEqual(result.data.payment_term_adaptation, {
|
assert.equal(setup.getDuplicatorCalls(), 0);
|
||||||
source: "inactive_original",
|
|
||||||
resolution: "customer",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("duplicate usa el default activo de company si el original y el customer no son válidos", async () => {
|
test("duplicate propaga el fallo de Duplicator", async () => {
|
||||||
const originalPaymentTermId = UniqueID.generateNewID();
|
const duplicateError = new Error("Duplicate failed");
|
||||||
const inactiveCustomerPaymentTermId = UniqueID.generateNewID();
|
const setup = buildUseCase({ duplicatorFailure: duplicateError });
|
||||||
const companyDefaultPaymentTermId = UniqueID.generateNewID();
|
|
||||||
const setup = buildUseCase({
|
|
||||||
sourcePaymentTermId: Maybe.some(originalPaymentTermId),
|
|
||||||
customerPaymentTermId: Maybe.some(inactiveCustomerPaymentTermId),
|
|
||||||
companyDefaultPaymentTermId: companyDefaultPaymentTermId.toPrimitive(),
|
|
||||||
activePaymentTermIds: new Set([companyDefaultPaymentTermId.toString()]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await setup.useCase.execute({
|
const result = await setup.useCase.execute({
|
||||||
companyId: setup.companyId,
|
companyId: setup.source.companyId,
|
||||||
params: { proforma_id: setup.sourceId.toPrimitive() },
|
params: { proforma_id: setup.source.id.toPrimitive() },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isFailure, true);
|
||||||
assert.equal(
|
assert.equal(result.error, duplicateError);
|
||||||
setup.getReceivedPaymentTermId()?.unwrap().equals(companyDefaultPaymentTermId),
|
assert.equal(setup.getDuplicatorCalls(), 1);
|
||||||
true
|
|
||||||
);
|
|
||||||
assert.deepEqual(result.data.payment_term_adaptation, {
|
|
||||||
source: "inactive_original",
|
|
||||||
resolution: "company",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("duplicate crea sin payment term si no hay fallback válido", async () => {
|
|
||||||
const originalPaymentTermId = UniqueID.generateNewID();
|
|
||||||
const inactiveCustomerPaymentTermId = UniqueID.generateNewID();
|
|
||||||
const inactiveCompanyDefaultPaymentTermId = UniqueID.generateNewID();
|
|
||||||
const setup = buildUseCase({
|
|
||||||
sourcePaymentTermId: Maybe.some(originalPaymentTermId),
|
|
||||||
customerPaymentTermId: Maybe.some(inactiveCustomerPaymentTermId),
|
|
||||||
companyDefaultPaymentTermId: inactiveCompanyDefaultPaymentTermId.toPrimitive(),
|
|
||||||
activePaymentTermIds: new Set(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await setup.useCase.execute({
|
|
||||||
companyId: setup.companyId,
|
|
||||||
params: { proforma_id: setup.sourceId.toPrimitive() },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.isSuccess, true);
|
|
||||||
assert.equal(setup.getReceivedPaymentTermId()?.isNone(), true);
|
|
||||||
assert.deepEqual(result.data.payment_term_adaptation, {
|
|
||||||
source: "inactive_original",
|
|
||||||
resolution: "none",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,27 +1,15 @@
|
|||||||
import type { IPaymentTermPublicFinder } from "@erp/catalogs/api";
|
|
||||||
import type { ICompanyPublicServices } from "@erp/companies/api";
|
|
||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
import type { ICustomerPublicServices } from "@erp/customers/api";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
|
|
||||||
import type { DuplicateProformaByIdParamsRequestDTO } from "../../../../common";
|
import type { DuplicateProformaByIdParamsRequestDTO } from "../../../../common";
|
||||||
import { InvoiceSerie, InvoiceStatus, type ProformaRecipient } from "../../../domain";
|
|
||||||
import { InvoiceSeriesNotFoundError } from "../../invoice-series";
|
|
||||||
import type { ProformaCreateInputProps } from "../models";
|
|
||||||
import type {
|
import type {
|
||||||
IProformaCreator,
|
IProformaDuplicator,
|
||||||
IProformaFinder,
|
IProformaFinder,
|
||||||
IProformaFullReadModelAssembler,
|
IProformaFullReadModelAssembler,
|
||||||
} from "../services";
|
} from "../services";
|
||||||
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
|
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
|
||||||
|
|
||||||
type DuplicatePaymentTermAdaptation = {
|
|
||||||
source: "inactive_original";
|
|
||||||
resolution: "customer" | "company" | "none";
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
type DuplicateProformaByIdUseCaseInput = {
|
type DuplicateProformaByIdUseCaseInput = {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
params: DuplicateProformaByIdParamsRequestDTO;
|
params: DuplicateProformaByIdParamsRequestDTO;
|
||||||
@ -30,23 +18,17 @@ type DuplicateProformaByIdUseCaseInput = {
|
|||||||
export class DuplicateProformaByIdUseCase {
|
export class DuplicateProformaByIdUseCase {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
creator: IProformaCreator;
|
duplicator: IProformaDuplicator;
|
||||||
finder: IProformaFinder;
|
finder: IProformaFinder;
|
||||||
fullReadModelAssembler: IProformaFullReadModelAssembler;
|
fullReadModelAssembler: IProformaFullReadModelAssembler;
|
||||||
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
||||||
documentSeriesServices: DocumentSeriesPublicServicesType;
|
|
||||||
companiesServices: ICompanyPublicServices;
|
|
||||||
customerServices: ICustomerPublicServices;
|
|
||||||
paymentTermFinder: IPaymentTermPublicFinder;
|
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async execute(params: DuplicateProformaByIdUseCaseInput) {
|
public async execute(params: DuplicateProformaByIdUseCaseInput) {
|
||||||
const proformaIdResult = UniqueID.create(params.params.proforma_id);
|
const proformaIdResult = UniqueID.create(params.params.proforma_id);
|
||||||
if (proformaIdResult.isFailure) {
|
if (proformaIdResult.isFailure) return Result.fail(proformaIdResult.error);
|
||||||
return Result.fail(proformaIdResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||||
try {
|
try {
|
||||||
@ -55,370 +37,29 @@ export class DuplicateProformaByIdUseCase {
|
|||||||
proformaIdResult.data,
|
proformaIdResult.data,
|
||||||
transaction
|
transaction
|
||||||
);
|
);
|
||||||
|
if (sourceResult.isFailure) return Result.fail(sourceResult.error);
|
||||||
|
|
||||||
if (sourceResult.isFailure) {
|
const duplicatedResult = await this.deps.duplicator.duplicate({
|
||||||
return Result.fail(sourceResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = sourceResult.data;
|
|
||||||
const duplicateId = UniqueID.generateNewID();
|
|
||||||
const proformaDateResult = UtcDate.createFromISO(new Date().toISOString().slice(0, 10));
|
|
||||||
|
|
||||||
if (proformaDateResult.isFailure) {
|
|
||||||
return Result.fail(proformaDateResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proformaSeriesCodeResult = await this.resolveDuplicateSeriesCode({
|
|
||||||
companyId: params.companyId,
|
companyId: params.companyId,
|
||||||
sourceDocumentSeriesId: source.documentSeriesId,
|
source: sourceResult.data,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
if (duplicatedResult.isFailure) return Result.fail(duplicatedResult.error);
|
||||||
if (proformaSeriesCodeResult.isFailure) {
|
|
||||||
return Result.fail(proformaSeriesCodeResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetInvoiceSeriesCodeResult = await this.resolveValidTargetInvoiceSeriesCode({
|
|
||||||
companyId: params.companyId,
|
|
||||||
targetInvoiceSeriesCode: source.targetInvoiceSeriesCode,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (targetInvoiceSeriesCodeResult.isFailure) {
|
|
||||||
return Result.fail(targetInvoiceSeriesCodeResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentTermResolutionResult = await this.resolveDuplicatePaymentTermId({
|
|
||||||
companyId: params.companyId,
|
|
||||||
sourceCustomerId: source.customerId,
|
|
||||||
sourcePaymentTermId: source.paymentTermId,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (paymentTermResolutionResult.isFailure) {
|
|
||||||
return Result.fail(paymentTermResolutionResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const props: ProformaCreateInputProps = {
|
|
||||||
companyId: params.companyId,
|
|
||||||
status: InvoiceStatus.draft(),
|
|
||||||
proformaSeriesCode: proformaSeriesCodeResult.data,
|
|
||||||
targetInvoiceSeriesCode: targetInvoiceSeriesCodeResult.data,
|
|
||||||
proformaDate: proformaDateResult.data,
|
|
||||||
operationDate: Maybe.none(),
|
|
||||||
customerId: source.customerId,
|
|
||||||
recipient: Maybe.none<ProformaRecipient>(),
|
|
||||||
reference: source.reference,
|
|
||||||
description: source.description,
|
|
||||||
notes: source.notes,
|
|
||||||
languageCode: source.languageCode,
|
|
||||||
currencyCode: source.currencyCode,
|
|
||||||
linkedInvoiceId: Maybe.none(),
|
|
||||||
paymentMethodId: source.paymentMethodId,
|
|
||||||
paymentTermId: paymentTermResolutionResult.data.paymentTermId,
|
|
||||||
taxRegimeCode: source.taxRegimeCode,
|
|
||||||
taxConfig: {
|
|
||||||
taxMode: source.taxConfig.taxMode,
|
|
||||||
defaultIvaCode: source.taxConfig.defaultIvaCode,
|
|
||||||
usesEquivalenceSurcharge: source.taxConfig.usesEquivalenceSurcharge,
|
|
||||||
defaultRecCode: source.taxConfig.defaultRecCode,
|
|
||||||
usesRetention: source.taxConfig.usesRetention,
|
|
||||||
defaultRetentionCode: source.taxConfig.defaultRetentionCode,
|
|
||||||
},
|
|
||||||
globalDiscountPercentage: source.globalDiscountPercentage,
|
|
||||||
items: source.items.getAll().map((item) => ({
|
|
||||||
description: item.description,
|
|
||||||
quantity: item.quantity,
|
|
||||||
unitAmount: item.unitAmount,
|
|
||||||
itemDiscountPercentage: item.itemDiscountPercentage,
|
|
||||||
taxes: {
|
|
||||||
ivaCode: item.ivaCode(),
|
|
||||||
recCode: item.recCode(),
|
|
||||||
retentionCode: item.retentionCode(),
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const createResult = await this.deps.creator.create({
|
|
||||||
companyId: params.companyId,
|
|
||||||
id: duplicateId,
|
|
||||||
props,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (createResult.isFailure) {
|
|
||||||
return Result.fail(createResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const duplicatedResult = await this.deps.finder.findProformaById(
|
|
||||||
params.companyId,
|
|
||||||
createResult.data.id,
|
|
||||||
transaction
|
|
||||||
);
|
|
||||||
|
|
||||||
if (duplicatedResult.isFailure) {
|
|
||||||
return Result.fail(duplicatedResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
|
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
|
||||||
companyId: params.companyId,
|
companyId: params.companyId,
|
||||||
proforma: duplicatedResult.data,
|
proforma: duplicatedResult.data.proforma,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
if (readModelResult.isFailure) return Result.fail(readModelResult.error);
|
||||||
if (readModelResult.isFailure) {
|
|
||||||
return Result.fail(readModelResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok({
|
return Result.ok({
|
||||||
...this.deps.fullSnapshotBuilder.toOutput(readModelResult.data),
|
...this.deps.fullSnapshotBuilder.toOutput(readModelResult.data),
|
||||||
payment_term_adaptation: paymentTermResolutionResult.data.adaptation,
|
payment_term_adaptation: duplicatedResult.data.paymentTermAdaptation,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return Result.fail(error as Error);
|
return Result.fail(error as Error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveDuplicateSeriesCode(params: {
|
|
||||||
companyId: UniqueID;
|
|
||||||
sourceDocumentSeriesId: Maybe<string>;
|
|
||||||
transaction?: unknown;
|
|
||||||
}): Promise<Result<Maybe<InvoiceSerie>, Error>> {
|
|
||||||
const listResult = await this.deps.documentSeriesServices.listActiveSeries({
|
|
||||||
companyId: params.companyId.toString(),
|
|
||||||
documentType: "proforma",
|
|
||||||
transaction: params.transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (listResult.isFailure) {
|
|
||||||
return Result.fail(listResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceSeriesId = params.sourceDocumentSeriesId.getOrUndefined();
|
|
||||||
const sourceSeries = sourceSeriesId
|
|
||||||
? listResult.data.find((series) => series.id === sourceSeriesId)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (sourceSeries) {
|
|
||||||
const seriesCodeResult = InvoiceSerie.create(sourceSeries.code);
|
|
||||||
if (seriesCodeResult.isFailure) {
|
|
||||||
return Result.fail(seriesCodeResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(Maybe.some(seriesCodeResult.data));
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultSeries = listResult.data.find((series) => series.is_default);
|
|
||||||
if (!defaultSeries) {
|
|
||||||
return Result.fail(
|
|
||||||
new InvoiceSeriesNotFoundError({
|
|
||||||
cause: "No active proforma document series is available.",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultSeriesCodeResult = InvoiceSerie.create(defaultSeries.code);
|
|
||||||
if (defaultSeriesCodeResult.isFailure) {
|
|
||||||
return Result.fail(defaultSeriesCodeResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(Maybe.some(defaultSeriesCodeResult.data));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveValidTargetInvoiceSeriesCode(params: {
|
|
||||||
companyId: UniqueID;
|
|
||||||
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
|
|
||||||
transaction?: unknown;
|
|
||||||
}): Promise<Result<Maybe<InvoiceSerie>, Error>> {
|
|
||||||
if (params.targetInvoiceSeriesCode.isNone()) {
|
|
||||||
return Result.ok(Maybe.none());
|
|
||||||
}
|
|
||||||
|
|
||||||
const listResult = await this.deps.documentSeriesServices.listActiveSeries({
|
|
||||||
companyId: params.companyId.toString(),
|
|
||||||
documentType: "issued_invoice",
|
|
||||||
transaction: params.transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (listResult.isFailure) {
|
|
||||||
return Result.fail(listResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetCode = params.targetInvoiceSeriesCode.unwrap().toPrimitive();
|
|
||||||
const isValid = listResult.data.some((series) => series.code === targetCode);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return Result.ok(Maybe.none());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(params.targetInvoiceSeriesCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveDuplicatePaymentTermId(params: {
|
|
||||||
companyId: UniqueID;
|
|
||||||
sourceCustomerId: UniqueID;
|
|
||||||
sourcePaymentTermId: Maybe<UniqueID>;
|
|
||||||
transaction?: unknown;
|
|
||||||
}): Promise<
|
|
||||||
Result<
|
|
||||||
{
|
|
||||||
paymentTermId: Maybe<UniqueID> | undefined;
|
|
||||||
adaptation: DuplicatePaymentTermAdaptation;
|
|
||||||
},
|
|
||||||
Error
|
|
||||||
>
|
|
||||||
> {
|
|
||||||
if (params.sourcePaymentTermId.isNone()) {
|
|
||||||
return Result.ok({
|
|
||||||
paymentTermId: params.sourcePaymentTermId,
|
|
||||||
adaptation: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeSourcePaymentTermResult = await this.resolveActivePaymentTermId({
|
|
||||||
companyId: params.companyId,
|
|
||||||
paymentTermId: params.sourcePaymentTermId,
|
|
||||||
transaction: params.transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (activeSourcePaymentTermResult.isFailure) {
|
|
||||||
return Result.fail(activeSourcePaymentTermResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSourcePaymentTermResult.data.isSome()) {
|
|
||||||
return Result.ok({
|
|
||||||
paymentTermId: activeSourcePaymentTermResult.data,
|
|
||||||
adaptation: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const customerResult = await this.deps.customerServices.getCustomerById(
|
|
||||||
params.sourceCustomerId,
|
|
||||||
{
|
|
||||||
companyId: params.companyId,
|
|
||||||
transaction: params.transaction,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (customerResult.isFailure) {
|
|
||||||
return Result.fail(customerResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeCustomerPaymentTermResult = await this.resolveActivePaymentTermId({
|
|
||||||
companyId: params.companyId,
|
|
||||||
paymentTermId: customerResult.data.paymentTermId,
|
|
||||||
transaction: params.transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (activeCustomerPaymentTermResult.isFailure) {
|
|
||||||
return Result.fail(activeCustomerPaymentTermResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeCustomerPaymentTermResult.data.isSome()) {
|
|
||||||
return Result.ok({
|
|
||||||
paymentTermId: activeCustomerPaymentTermResult.data,
|
|
||||||
adaptation: {
|
|
||||||
source: "inactive_original",
|
|
||||||
resolution: "customer",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const companyResult = await this.deps.companiesServices.finder.findById({
|
|
||||||
companyId: params.companyId,
|
|
||||||
transaction: params.transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (companyResult.isFailure) {
|
|
||||||
return Result.fail(companyResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (companyResult.data.isNone()) {
|
|
||||||
return Result.fail(new Error("Company not found while duplicating proforma."));
|
|
||||||
}
|
|
||||||
|
|
||||||
const companyDefaultPaymentTermIdResult = this.toMaybeUniqueId(
|
|
||||||
companyResult.data.unwrap().defaultPaymentTermId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (companyDefaultPaymentTermIdResult.isFailure) {
|
|
||||||
return Result.fail(
|
|
||||||
new Error("Company default payment term is invalid while duplicating proforma.", {
|
|
||||||
cause: companyDefaultPaymentTermIdResult.error,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeCompanyDefaultPaymentTermResult = await this.resolveActivePaymentTermId({
|
|
||||||
companyId: params.companyId,
|
|
||||||
paymentTermId: companyDefaultPaymentTermIdResult.data,
|
|
||||||
transaction: params.transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (activeCompanyDefaultPaymentTermResult.isFailure) {
|
|
||||||
return Result.fail(activeCompanyDefaultPaymentTermResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeCompanyDefaultPaymentTermResult.data.isSome()) {
|
|
||||||
return Result.ok({
|
|
||||||
paymentTermId: activeCompanyDefaultPaymentTermResult.data,
|
|
||||||
adaptation: {
|
|
||||||
source: "inactive_original",
|
|
||||||
resolution: "company",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok({
|
|
||||||
paymentTermId: Maybe.none(),
|
|
||||||
adaptation: {
|
|
||||||
source: "inactive_original",
|
|
||||||
resolution: "none",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveActivePaymentTermId(params: {
|
|
||||||
companyId: UniqueID;
|
|
||||||
paymentTermId: Maybe<UniqueID>;
|
|
||||||
transaction?: unknown;
|
|
||||||
}): Promise<Result<Maybe<UniqueID>, Error>> {
|
|
||||||
if (params.paymentTermId.isNone()) {
|
|
||||||
return Result.ok(Maybe.none());
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentTermResult = await this.deps.paymentTermFinder.findByIdInCompany({
|
|
||||||
companyId: params.companyId,
|
|
||||||
id: params.paymentTermId.unwrap(),
|
|
||||||
transaction: params.transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (paymentTermResult.isFailure) {
|
|
||||||
return Result.fail(paymentTermResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paymentTermResult.data.isNone()) {
|
|
||||||
return Result.ok(Maybe.none());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!paymentTermResult.data.unwrap().isActive) {
|
|
||||||
return Result.ok(Maybe.none());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(params.paymentTermId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private toMaybeUniqueId(value: string | null): Result<Maybe<UniqueID>, Error> {
|
|
||||||
if (value == null) {
|
|
||||||
return Result.ok(Maybe.none());
|
|
||||||
}
|
|
||||||
|
|
||||||
const idResult = UniqueID.create(value);
|
|
||||||
if (idResult.isFailure) {
|
|
||||||
return Result.fail(idResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(Maybe.some(idResult.data));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,141 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { DiscountPercentage } from "@erp/core/api";
|
|
||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
import { CurrencyCode, LanguageCode, UtcDate, UniqueID } from "@repo/rdx-ddd";
|
import { DiscountPercentage } from "@erp/core/api";
|
||||||
|
import type { IAudit, RecordAuditMutationShortcutInput } from "@repo/rdx-audit";
|
||||||
|
import {
|
||||||
|
City,
|
||||||
|
Country,
|
||||||
|
CurrencyCode,
|
||||||
|
LanguageCode,
|
||||||
|
Name,
|
||||||
|
PostalCode,
|
||||||
|
Province,
|
||||||
|
Street,
|
||||||
|
TINNumber,
|
||||||
|
UniqueID,
|
||||||
|
UtcDate,
|
||||||
|
} from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { ProformaToIssuedInvoiceCreateProps } from "../../issued-invoices/services/proforma-to-issued-invoice-props-converter";
|
|
||||||
import type { IIssuedInvoicePublicServices } from "../../issued-invoices/services/issued-invoice-public-services.interface";
|
|
||||||
import {
|
import {
|
||||||
|
InvoiceAmount,
|
||||||
InvoiceNumber,
|
InvoiceNumber,
|
||||||
|
InvoicePaymentMethod,
|
||||||
|
InvoicePaymentTerm,
|
||||||
|
InvoiceSerie,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
|
InvoiceTaxRegime,
|
||||||
|
IssuedInvoice,
|
||||||
|
IssuedInvoiceFiscalClassification,
|
||||||
|
IssuedInvoiceItems,
|
||||||
|
IssuedInvoiceRecipient,
|
||||||
|
IssuedInvoiceTaxes,
|
||||||
Proforma,
|
Proforma,
|
||||||
ProformaItems,
|
ProformaItems,
|
||||||
ProformaTaxConfig,
|
ProformaTaxConfig,
|
||||||
} from "../../../domain";
|
} from "../../../domain";
|
||||||
|
import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit";
|
||||||
|
import type { IIssuedInvoicePublicServices } from "../../issued-invoices/services/issued-invoice-public-services.interface";
|
||||||
|
import type { ProformaToIssuedInvoiceCreateProps } from "../../issued-invoices/services/proforma-to-issued-invoice-props-converter";
|
||||||
import { ProformaIssuer } from "../services/proforma-issuer";
|
import { ProformaIssuer } from "../services/proforma-issuer";
|
||||||
|
|
||||||
import { IssueProformaUseCase } from "./issue-proforma.use-case";
|
import { IssueProformaUseCase } from "./issue-proforma.use-case";
|
||||||
|
|
||||||
type CreateIssuedInvoiceProps = Parameters<IIssuedInvoicePublicServices["createIssuedInvoice"]>[1];
|
type CreateIssuedInvoiceProps = Parameters<IIssuedInvoicePublicServices["createIssuedInvoice"]>[1];
|
||||||
|
|
||||||
|
class SuccessAudit implements IAudit {
|
||||||
|
public readonly recordMutationCalls: RecordAuditMutationShortcutInput[] = [];
|
||||||
|
|
||||||
|
async recordMutation(input: RecordAuditMutationShortcutInput) {
|
||||||
|
this.recordMutationCalls.push(input);
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = <T>(result: Result<T, Error>): T => {
|
||||||
|
if (result.isFailure) throw result.error;
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createReloadedIssuedInvoice = (id: UniqueID, companyId: UniqueID): IssuedInvoice => {
|
||||||
|
const languageCode = required(LanguageCode.create("es"));
|
||||||
|
const currencyCode = required(CurrencyCode.create("EUR"));
|
||||||
|
const amount = required(InvoiceAmount.create({ value: 0, currency_code: "EUR" }));
|
||||||
|
const recipient = required(
|
||||||
|
IssuedInvoiceRecipient.create({
|
||||||
|
name: required(Name.create("Acme")),
|
||||||
|
tin: required(TINNumber.create("ESB1234567")),
|
||||||
|
street: required(Street.create("Calle Mayor 1")),
|
||||||
|
street2: Maybe.none(),
|
||||||
|
city: required(City.create("Madrid")),
|
||||||
|
postalCode: required(PostalCode.create("28001")),
|
||||||
|
province: Maybe.some(required(Province.create("Madrid"))),
|
||||||
|
country: Maybe.some(required(Country.create("ES"))),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const paymentTerm = required(
|
||||||
|
InvoicePaymentTerm.create({
|
||||||
|
name: "30 días",
|
||||||
|
description: Maybe.none(),
|
||||||
|
dueRules: [{ dueDays: "30", percentage: { value: "10000", scale: 2 } }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const fiscalClassification = required(
|
||||||
|
IssuedInvoiceFiscalClassification.create({
|
||||||
|
taxRegimeCode: "01",
|
||||||
|
taxRegimeLabel: Maybe.none(),
|
||||||
|
operationQualificationCode: Maybe.none(),
|
||||||
|
exemptionReasonCode: Maybe.none(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return IssuedInvoice.rehydrate(
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
status: InvoiceStatus.issued(),
|
||||||
|
linkedProformaId: UniqueID.generateNewID(),
|
||||||
|
documentSeriesId: Maybe.none(),
|
||||||
|
series: required(InvoiceSerie.create("F")),
|
||||||
|
invoiceNumber: required(InvoiceNumber.create("1")),
|
||||||
|
invoiceReference: "F-1",
|
||||||
|
invoiceDate: required(UtcDate.createFromISO("2026-08-17")),
|
||||||
|
operationDate: Maybe.none(),
|
||||||
|
customerId: UniqueID.generateNewID(),
|
||||||
|
recipient,
|
||||||
|
reference: Maybe.none(),
|
||||||
|
description: "Factura emitida",
|
||||||
|
notes: Maybe.none(),
|
||||||
|
languageCode,
|
||||||
|
currencyCode,
|
||||||
|
paymentMethod: required(InvoicePaymentMethod.create({ name: "Transferencia" })),
|
||||||
|
paymentTerm: Maybe.some(paymentTerm),
|
||||||
|
taxRegime: required(InvoiceTaxRegime.create({ code: "01", description: "Régimen general" })),
|
||||||
|
taxes: IssuedInvoiceTaxes.create({ languageCode, currencyCode }),
|
||||||
|
subtotalAmount: amount,
|
||||||
|
itemsDiscountAmount: amount,
|
||||||
|
globalDiscountPercentage: required(DiscountPercentage.create({ value: 0 })),
|
||||||
|
globalDiscountAmount: amount,
|
||||||
|
totalDiscountAmount: amount,
|
||||||
|
taxableAmount: amount,
|
||||||
|
ivaAmount: amount,
|
||||||
|
recAmount: amount,
|
||||||
|
retentionAmount: amount,
|
||||||
|
taxesAmount: amount,
|
||||||
|
totalAmount: amount,
|
||||||
|
fiscalClassification: Maybe.some(fiscalClassification),
|
||||||
|
verifactu: Maybe.none(),
|
||||||
|
},
|
||||||
|
IssuedInvoiceItems.create({
|
||||||
|
items: [],
|
||||||
|
languageCode,
|
||||||
|
currencyCode,
|
||||||
|
globalDiscountPercentage: required(DiscountPercentage.create({ value: 0 })),
|
||||||
|
}),
|
||||||
|
id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const buildTransactionManager = (): ITransactionManager => ({
|
const buildTransactionManager = (): ITransactionManager => ({
|
||||||
start: async () => undefined,
|
start: async () => undefined,
|
||||||
getTransaction: () => undefined,
|
getTransaction: () => undefined,
|
||||||
@ -33,9 +149,7 @@ const buildCreateIssuedInvoiceProps = (): CreateIssuedInvoiceProps =>
|
|||||||
verifactu: Maybe.none(),
|
verifactu: Maybe.none(),
|
||||||
}) as CreateIssuedInvoiceProps;
|
}) as CreateIssuedInvoiceProps;
|
||||||
|
|
||||||
const buildIssueableProforma = (params?: {
|
const buildIssueableProforma = (params?: { operationDate?: Maybe<UtcDate> }) => {
|
||||||
operationDate?: Maybe<UtcDate>;
|
|
||||||
}) => {
|
|
||||||
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
|
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
|
||||||
const operationDateResult = UtcDate.createFromISO("2026-08-17");
|
const operationDateResult = UtcDate.createFromISO("2026-08-17");
|
||||||
const languageCodeResult = LanguageCode.create("es");
|
const languageCodeResult = LanguageCode.create("es");
|
||||||
@ -226,14 +340,18 @@ test("issue falla antes de persistir si la proforma no tiene operation date", as
|
|||||||
|
|
||||||
test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async () => {
|
test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async () => {
|
||||||
const proforma = buildIssueableProforma();
|
const proforma = buildIssueableProforma();
|
||||||
|
const reloadedInvoice = createReloadedIssuedInvoice(UniqueID.generateNewID(), proforma.companyId);
|
||||||
|
const audit = new SuccessAudit();
|
||||||
let capturedProps: CreateIssuedInvoiceProps | undefined;
|
let capturedProps: CreateIssuedInvoiceProps | undefined;
|
||||||
|
|
||||||
const useCase = new IssueProformaUseCase({
|
const useCase = new IssueProformaUseCase({
|
||||||
issuedInvoiceServices: {
|
issuedInvoiceServices: {
|
||||||
createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => {
|
createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => {
|
||||||
capturedProps = props;
|
capturedProps = props;
|
||||||
return Result.ok({ id: UniqueID.generateNewID() } as never);
|
return Result.ok(reloadedInvoice);
|
||||||
},
|
},
|
||||||
|
getIssuedInvoiceById: async (id) =>
|
||||||
|
Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)),
|
||||||
} as never,
|
} as never,
|
||||||
finder: {
|
finder: {
|
||||||
findProformaById: async () => Result.ok(proforma),
|
findProformaById: async () => Result.ok(proforma),
|
||||||
@ -270,6 +388,7 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async
|
|||||||
markAsIssuedByIdInCompany: async () => Result.ok(undefined),
|
markAsIssuedByIdInCompany: async () => Result.ok(undefined),
|
||||||
} as never,
|
} as never,
|
||||||
transactionManager: buildTransactionManager(),
|
transactionManager: buildTransactionManager(),
|
||||||
|
audit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await useCase.execute({
|
const result = await useCase.execute({
|
||||||
@ -277,7 +396,7 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async
|
|||||||
proformaId: proforma.id.toPrimitive(),
|
proformaId: proforma.id.toPrimitive(),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true, result.isFailure ? result.error.message : "");
|
||||||
if (!capturedProps) {
|
if (!capturedProps) {
|
||||||
assert.fail("Expected createIssuedInvoice to be called");
|
assert.fail("Expected createIssuedInvoice to be called");
|
||||||
}
|
}
|
||||||
@ -289,14 +408,18 @@ test("issue crea VerifactuRecord pending cuando la empresa usa VeriFactu", async
|
|||||||
|
|
||||||
test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async () => {
|
test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async () => {
|
||||||
const proforma = buildIssueableProforma();
|
const proforma = buildIssueableProforma();
|
||||||
|
const reloadedInvoice = createReloadedIssuedInvoice(UniqueID.generateNewID(), proforma.companyId);
|
||||||
|
const audit = new SuccessAudit();
|
||||||
let capturedProps: CreateIssuedInvoiceProps | undefined;
|
let capturedProps: CreateIssuedInvoiceProps | undefined;
|
||||||
|
|
||||||
const useCase = new IssueProformaUseCase({
|
const useCase = new IssueProformaUseCase({
|
||||||
issuedInvoiceServices: {
|
issuedInvoiceServices: {
|
||||||
createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => {
|
createIssuedInvoice: async (_id: UniqueID, props: CreateIssuedInvoiceProps) => {
|
||||||
capturedProps = props;
|
capturedProps = props;
|
||||||
return Result.ok({ id: UniqueID.generateNewID() } as never);
|
return Result.ok(reloadedInvoice);
|
||||||
},
|
},
|
||||||
|
getIssuedInvoiceById: async (id) =>
|
||||||
|
Result.ok(createReloadedIssuedInvoice(id, proforma.companyId)),
|
||||||
} as never,
|
} as never,
|
||||||
finder: {
|
finder: {
|
||||||
findProformaById: async () => Result.ok(proforma),
|
findProformaById: async () => Result.ok(proforma),
|
||||||
@ -333,6 +456,7 @@ test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async (
|
|||||||
markAsIssuedByIdInCompany: async () => Result.ok(undefined),
|
markAsIssuedByIdInCompany: async () => Result.ok(undefined),
|
||||||
} as never,
|
} as never,
|
||||||
transactionManager: buildTransactionManager(),
|
transactionManager: buildTransactionManager(),
|
||||||
|
audit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await useCase.execute({
|
const result = await useCase.execute({
|
||||||
@ -340,7 +464,7 @@ test("issue no crea VerifactuRecord cuando la empresa no usa VeriFactu", async (
|
|||||||
proformaId: proforma.id.toPrimitive(),
|
proformaId: proforma.id.toPrimitive(),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true, result.isFailure ? result.error.message : "");
|
||||||
if (!capturedProps) {
|
if (!capturedProps) {
|
||||||
assert.fail("Expected createIssuedInvoice to be called");
|
assert.fail("Expected createIssuedInvoice to be called");
|
||||||
}
|
}
|
||||||
@ -405,3 +529,88 @@ test("issue propaga el fallo de persistencia VeriFactu y no marca la proforma co
|
|||||||
assert.match(result.error.message, /verifactu insert failed/i);
|
assert.match(result.error.message, /verifactu insert failed/i);
|
||||||
assert.equal(markAsIssuedCalls, 0);
|
assert.equal(markAsIssuedCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("issue registra issued_invoice.created con el estado final de la factura", async () => {
|
||||||
|
const proforma = buildIssueableProforma();
|
||||||
|
const audit = new SuccessAudit();
|
||||||
|
const transaction = {};
|
||||||
|
let createdInvoiceId: UniqueID | undefined;
|
||||||
|
let reloadCalls = 0;
|
||||||
|
let reloadCompanyId: UniqueID | undefined;
|
||||||
|
let reloadTransaction: unknown;
|
||||||
|
let reloadedInvoice: IssuedInvoice | undefined;
|
||||||
|
const useCase = new IssueProformaUseCase({
|
||||||
|
issuedInvoiceServices: {
|
||||||
|
createIssuedInvoice: async (id) => {
|
||||||
|
createdInvoiceId = id;
|
||||||
|
return Result.ok(createReloadedIssuedInvoice(id, proforma.companyId));
|
||||||
|
},
|
||||||
|
getIssuedInvoiceById: async (id, context) => {
|
||||||
|
reloadCalls += 1;
|
||||||
|
reloadCompanyId = context.companyId;
|
||||||
|
reloadTransaction = context.transaction;
|
||||||
|
reloadedInvoice = createReloadedIssuedInvoice(id, context.companyId);
|
||||||
|
return Result.ok(reloadedInvoice);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
finder: { findProformaById: async () => Result.ok(proforma) },
|
||||||
|
issuer: {
|
||||||
|
issueProforma: (): Result<ProformaToIssuedInvoiceCreateProps, Error> =>
|
||||||
|
Result.ok(buildCreateIssuedInvoiceProps()),
|
||||||
|
},
|
||||||
|
issueReadModelAssembler: {
|
||||||
|
assemble: async () =>
|
||||||
|
Result.ok({
|
||||||
|
proforma,
|
||||||
|
paymentMethod: {
|
||||||
|
id: UniqueID.generateNewID(),
|
||||||
|
name: "Transferencia",
|
||||||
|
description: Maybe.none(),
|
||||||
|
},
|
||||||
|
paymentTerm: Maybe.none(),
|
||||||
|
taxRegime: { code: "01", description: "Régimen general" },
|
||||||
|
fiscalClassification: { operationQualificationCode: null, exemptionReasonCode: null },
|
||||||
|
usesVerifactu: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
issueResponseSnapshotBuilder: { toOutput: () => ({ id: "response" }) },
|
||||||
|
repository: { markAsIssuedByIdInCompany: async () => Result.ok(undefined) },
|
||||||
|
transactionManager: {
|
||||||
|
start: async () => undefined,
|
||||||
|
getTransaction: () => transaction,
|
||||||
|
complete: async <T>(work: (activeTransaction: unknown) => Promise<T>) => work(transaction),
|
||||||
|
commit: async () => undefined,
|
||||||
|
rollback: async () => undefined,
|
||||||
|
},
|
||||||
|
audit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await useCase.execute({
|
||||||
|
companyId: proforma.companyId.toPrimitive(),
|
||||||
|
proformaId: proforma.id.toPrimitive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(reloadCalls, 1);
|
||||||
|
assert.equal(reloadCompanyId?.equals(proforma.companyId), true);
|
||||||
|
assert.equal(reloadTransaction, transaction);
|
||||||
|
assert.equal(createdInvoiceId?.equals(reloadedInvoice?.id), true);
|
||||||
|
assert.equal(audit.recordMutationCalls.length, 1);
|
||||||
|
const [mutation] = audit.recordMutationCalls;
|
||||||
|
assert.equal(mutation?.action, "issued_invoice.created");
|
||||||
|
assert.deepEqual(mutation?.target, {
|
||||||
|
type: "issued_invoice",
|
||||||
|
id: reloadedInvoice?.id.toPrimitive(),
|
||||||
|
label: reloadedInvoice?.invoiceReference,
|
||||||
|
});
|
||||||
|
assert.deepEqual(mutation?.before, {});
|
||||||
|
assert.deepEqual(mutation?.after, toIssuedInvoiceAuditState(reloadedInvoice!));
|
||||||
|
assert.equal(
|
||||||
|
audit.recordMutationCalls.filter(({ action }) => action === "issued_invoice.created").length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
audit.recordMutationCalls.filter(({ action }) => action === "proforma.issued").length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
|
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
|
||||||
|
import { toIssuedInvoiceAuditState } from "../../issued-invoices/audit/issued-invoice-audit";
|
||||||
import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain";
|
import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain";
|
||||||
import type { IssueProformaCommand } from "../commands";
|
import type { IssueProformaCommand } from "../commands";
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
@ -26,6 +28,7 @@ export class IssueProformaUseCase {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
issuedInvoiceServices: IIssuedInvoicePublicServices;
|
issuedInvoiceServices: IIssuedInvoicePublicServices;
|
||||||
|
audit: IAudit;
|
||||||
finder: IProformaFinder;
|
finder: IProformaFinder;
|
||||||
issuer: IProformaIssuer;
|
issuer: IProformaIssuer;
|
||||||
issueReadModelAssembler: IProformaIssueReadModelAssembler;
|
issueReadModelAssembler: IProformaIssueReadModelAssembler;
|
||||||
@ -127,6 +130,31 @@ export class IssueProformaUseCase {
|
|||||||
return Result.fail(markAsIssuedResult.error);
|
return Result.fail(markAsIssuedResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reloadedInvoiceResult = await this.deps.issuedInvoiceServices.getIssuedInvoiceById(
|
||||||
|
issuedInvoiceId,
|
||||||
|
{ companyId, transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reloadedInvoiceResult.isFailure) {
|
||||||
|
return Result.fail(reloadedInvoiceResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadedInvoice = reloadedInvoiceResult.data;
|
||||||
|
const auditResult = await this.deps.audit.recordMutation({
|
||||||
|
action: "issued_invoice.created",
|
||||||
|
target: {
|
||||||
|
type: "issued_invoice",
|
||||||
|
id: reloadedInvoice.id.toPrimitive(),
|
||||||
|
label: reloadedInvoice.invoiceReference,
|
||||||
|
},
|
||||||
|
before: {},
|
||||||
|
after: toIssuedInvoiceAuditState(reloadedInvoice),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok(this.deps.issueResponseSnapshotBuilder.toOutput(invoiceResult.data));
|
return Result.ok(this.deps.issueResponseSnapshotBuilder.toOutput(invoiceResult.data));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return Result.fail(error as Error);
|
return Result.fail(error as Error);
|
||||||
|
|||||||
@ -11,8 +11,8 @@ import {
|
|||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Maybe } from "@repo/rdx-utils";
|
import { Maybe } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { InvoiceNumber, InvoiceStatus } from "../../common";
|
import { InvoiceNumber, InvoiceStatus, ItemDescription } from "../../common";
|
||||||
import { ProformaItems } from "../entities";
|
import { ProformaItem, ProformaItems } from "../entities";
|
||||||
import { ProformaTaxConfig } from "../value-objects";
|
import { ProformaTaxConfig } from "../value-objects";
|
||||||
|
|
||||||
import { Proforma } from "./proforma.aggregate";
|
import { Proforma } from "./proforma.aggregate";
|
||||||
@ -24,6 +24,7 @@ const buildApprovedProforma = (params?: {
|
|||||||
itemsCount?: number;
|
itemsCount?: number;
|
||||||
status?: InvoiceStatus;
|
status?: InvoiceStatus;
|
||||||
linkedInvoiceId?: Maybe<UniqueID>;
|
linkedInvoiceId?: Maybe<UniqueID>;
|
||||||
|
items?: ProformaItem[];
|
||||||
}) => {
|
}) => {
|
||||||
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
|
const proformaDateResult = UtcDate.createFromISO("2026-08-16");
|
||||||
const operationDateResult = UtcDate.createFromISO("2026-08-17");
|
const operationDateResult = UtcDate.createFromISO("2026-08-17");
|
||||||
@ -48,7 +49,8 @@ const buildApprovedProforma = (params?: {
|
|||||||
assert.equal(discountResult.isSuccess, true);
|
assert.equal(discountResult.isSuccess, true);
|
||||||
assert.equal(taxConfigResult.isSuccess, true);
|
assert.equal(taxConfigResult.isSuccess, true);
|
||||||
|
|
||||||
const items = Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never);
|
const items =
|
||||||
|
params?.items ?? Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never);
|
||||||
|
|
||||||
return Proforma.rehydrate(
|
return Proforma.rehydrate(
|
||||||
{
|
{
|
||||||
@ -90,6 +92,29 @@ const buildApprovedProforma = (params?: {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildItem = (description?: string): ProformaItem => {
|
||||||
|
const itemDescription = description ? ItemDescription.create(description) : undefined;
|
||||||
|
|
||||||
|
if (itemDescription) {
|
||||||
|
assert.equal(itemDescription.isSuccess, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemResult = ProformaItem.create({
|
||||||
|
description: itemDescription ? Maybe.some(itemDescription.data) : Maybe.none(),
|
||||||
|
quantity: Maybe.none(),
|
||||||
|
unitAmount: Maybe.none(),
|
||||||
|
itemDiscountPercentage: Maybe.none(),
|
||||||
|
taxes: {
|
||||||
|
iva: Maybe.none(),
|
||||||
|
rec: Maybe.none(),
|
||||||
|
retention: Maybe.none(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(itemResult.isSuccess, true);
|
||||||
|
return itemResult.data;
|
||||||
|
};
|
||||||
|
|
||||||
test("markAsIssued rechaza proformas sin operationDate", () => {
|
test("markAsIssued rechaza proformas sin operationDate", () => {
|
||||||
const proforma = buildApprovedProforma({
|
const proforma = buildApprovedProforma({
|
||||||
operationDate: Maybe.none(),
|
operationDate: Maybe.none(),
|
||||||
@ -159,3 +184,65 @@ test("getIssuanceBlockers informa already_issued cuando existe linked invoice",
|
|||||||
["already_issued"]
|
["already_issued"]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("update informa no-op cuando los valores funcionales son iguales", () => {
|
||||||
|
const proforma = buildApprovedProforma({ itemsCount: 0 });
|
||||||
|
|
||||||
|
const result = proforma.update({
|
||||||
|
description: Maybe.some("Servicio mensual"),
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(result.data, false);
|
||||||
|
assert.equal(proforma.description.unwrap(), "Servicio mensual");
|
||||||
|
assert.equal(proforma.items.valued().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update informa cambio cuando cambia un campo funcional", () => {
|
||||||
|
const proforma = buildApprovedProforma({ itemsCount: 0 });
|
||||||
|
|
||||||
|
const result = proforma.update({
|
||||||
|
description: Maybe.some("Servicio actualizado"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(result.data, true);
|
||||||
|
assert.equal(proforma.description.unwrap(), "Servicio actualizado");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update distingue las transiciones entre null y valor", () => {
|
||||||
|
const emptyDescription = buildApprovedProforma({
|
||||||
|
itemsCount: 0,
|
||||||
|
description: Maybe.none(),
|
||||||
|
});
|
||||||
|
const sameNull = emptyDescription.update({ description: Maybe.none() });
|
||||||
|
const nullToValue = emptyDescription.update({ description: Maybe.some("Servicio") });
|
||||||
|
const valueToNull = emptyDescription.update({ description: Maybe.none() });
|
||||||
|
|
||||||
|
assert.equal(sameNull.data, false);
|
||||||
|
assert.equal(nullToValue.data, true);
|
||||||
|
assert.equal(valueToNull.data, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update evita reescribir items semanticamente iguales", () => {
|
||||||
|
const item = buildItem("Linea A");
|
||||||
|
const proforma = buildApprovedProforma({ items: [item] });
|
||||||
|
|
||||||
|
const result = proforma.update({ items: [item.getProps()] });
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(result.data, false);
|
||||||
|
assert.equal(proforma.items.valued().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update detecta cambios y reordenaciones de items", () => {
|
||||||
|
const first = buildItem("Linea A");
|
||||||
|
const second = buildItem("Linea B");
|
||||||
|
const proforma = buildApprovedProforma({ items: [first, second] });
|
||||||
|
|
||||||
|
const reordered = proforma.update({ items: [second.getProps(), first.getProps()] });
|
||||||
|
|
||||||
|
assert.equal(reordered.isSuccess, true);
|
||||||
|
assert.equal(reordered.data, true);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { DiscountPercentage } from "@erp/core/api";
|
import type { DiscountPercentage, Tax } from "@erp/core/api";
|
||||||
import {
|
import {
|
||||||
AggregateRoot,
|
AggregateRoot,
|
||||||
type CurrencyCode,
|
type CurrencyCode,
|
||||||
@ -41,6 +41,7 @@ import type { IProformaTaxTotals, ProformaCalculationContext } from "../services
|
|||||||
import { canManuallyTransitionProformaStatus } from "../services";
|
import { canManuallyTransitionProformaStatus } from "../services";
|
||||||
import {
|
import {
|
||||||
ProformaItemTaxes,
|
ProformaItemTaxes,
|
||||||
|
type ProformaItemTaxesProps,
|
||||||
type ProformaRecipient,
|
type ProformaRecipient,
|
||||||
type ProformaTaxConfig,
|
type ProformaTaxConfig,
|
||||||
} from "../value-objects";
|
} from "../value-objects";
|
||||||
@ -214,7 +215,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mutabilidad
|
// Mutabilidad
|
||||||
public update(patchProps: ProformaPatchProps): Result<Proforma, Error> {
|
public update(patchProps: ProformaPatchProps): Result<boolean, Error> {
|
||||||
const { items, ...otherProps } = patchProps;
|
const { items, ...otherProps } = patchProps;
|
||||||
const definedProps = Object.fromEntries(
|
const definedProps = Object.fromEntries(
|
||||||
Object.entries(otherProps).filter(([, value]) => value !== undefined)
|
Object.entries(otherProps).filter(([, value]) => value !== undefined)
|
||||||
@ -232,6 +233,10 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
|
|||||||
return Result.fail(validationResult.error);
|
return Result.fail(validationResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.hasSameFunctionalState(candidateProps, items)) {
|
||||||
|
return Result.ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Aplicar cambios
|
// Aplicar cambios
|
||||||
Object.assign(this.props, candidateProps);
|
Object.assign(this.props, candidateProps);
|
||||||
|
|
||||||
@ -244,7 +249,185 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.ok();
|
return Result.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasSameFunctionalState(
|
||||||
|
candidateProps: ProformaInternalProps,
|
||||||
|
items: ProformaItemPatchProps[] | undefined
|
||||||
|
): boolean {
|
||||||
|
return this.hasSameProps(candidateProps) && (items === undefined || this.hasSameItems(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasSameProps(candidateProps: ProformaInternalProps): boolean {
|
||||||
|
return (
|
||||||
|
this.sameMaybe(
|
||||||
|
this.documentSeriesId,
|
||||||
|
candidateProps.documentSeriesId,
|
||||||
|
(left, right) => left === right
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(this.proformaSeriesCode, candidateProps.proformaSeriesCode, (left, right) =>
|
||||||
|
left.equals(right)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(this.proformaNumber, candidateProps.proformaNumber, (left, right) =>
|
||||||
|
left.equals(right)
|
||||||
|
) &&
|
||||||
|
this.proformaReference.equals(candidateProps.proformaReference) &&
|
||||||
|
this.sameMaybe(
|
||||||
|
this.targetInvoiceSeriesCode,
|
||||||
|
candidateProps.targetInvoiceSeriesCode,
|
||||||
|
(left, right) => left.equals(right)
|
||||||
|
) &&
|
||||||
|
this.proformaDate.equals(candidateProps.proformaDate) &&
|
||||||
|
this.sameMaybe(this.operationDate, candidateProps.operationDate, (left, right) =>
|
||||||
|
left.equals(right)
|
||||||
|
) &&
|
||||||
|
this.customerId.equals(candidateProps.customerId) &&
|
||||||
|
this.sameMaybe(this.recipient, candidateProps.recipient, (left, right) =>
|
||||||
|
this.sameRecipient(left, right)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(this.reference, candidateProps.reference, (left, right) => left === right) &&
|
||||||
|
this.sameMaybe(
|
||||||
|
this.description,
|
||||||
|
candidateProps.description,
|
||||||
|
(left, right) => left === right
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(this.notes, candidateProps.notes, (left, right) => left.equals(right)) &&
|
||||||
|
this.languageCode.equals(candidateProps.languageCode) &&
|
||||||
|
this.currencyCode.equals(candidateProps.currencyCode) &&
|
||||||
|
this.sameMaybe(this.linkedInvoiceId, candidateProps.linkedInvoiceId, (left, right) =>
|
||||||
|
left.equals(right)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(this.paymentMethodId, candidateProps.paymentMethodId, (left, right) =>
|
||||||
|
left.equals(right)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(this.paymentTermId, candidateProps.paymentTermId, (left, right) =>
|
||||||
|
left.equals(right)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(
|
||||||
|
this.taxRegimeCode,
|
||||||
|
candidateProps.taxRegimeCode,
|
||||||
|
(left, right) => left === right
|
||||||
|
) &&
|
||||||
|
this.sameTaxConfig(this.taxConfig, candidateProps.taxConfig) &&
|
||||||
|
this.globalDiscountPercentage.equals(candidateProps.globalDiscountPercentage)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasSameItems(items: ProformaItemPatchProps[]): boolean {
|
||||||
|
const currentItems = this._items.getAll();
|
||||||
|
|
||||||
|
return (
|
||||||
|
currentItems.length === items.length &&
|
||||||
|
currentItems.every((item, index) => this.sameItem(item, items[index]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameItem(item: ProformaItem, candidate: ProformaItemPatchProps): boolean {
|
||||||
|
return (
|
||||||
|
this.sameMaybe(item.description, candidate.description, (left, right) =>
|
||||||
|
left.equals(right)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(item.quantity, candidate.quantity, (left, right) => left.equals(right)) &&
|
||||||
|
this.sameMaybe(item.unitAmount, candidate.unitAmount, (left, right) => left.equals(right)) &&
|
||||||
|
this.sameMaybe(item.itemDiscountPercentage, candidate.itemDiscountPercentage, (left, right) =>
|
||||||
|
left.equals(right)
|
||||||
|
) &&
|
||||||
|
this.sameTaxes(item.taxes.getProps(), candidate.taxes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameRecipient(left: ProformaRecipient, right: ProformaRecipient): boolean {
|
||||||
|
if (left === right) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftProps = left.getProps();
|
||||||
|
const rightProps = right.getProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
leftProps.name.equals(rightProps.name) &&
|
||||||
|
this.sameMaybe(leftProps.tin, rightProps.tin, (current, candidate) =>
|
||||||
|
current.equals(candidate)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(leftProps.street, rightProps.street, (current, candidate) =>
|
||||||
|
current.equals(candidate)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(leftProps.street2, rightProps.street2, (current, candidate) =>
|
||||||
|
current.equals(candidate)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(leftProps.city, rightProps.city, (current, candidate) =>
|
||||||
|
current.equals(candidate)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(leftProps.postalCode, rightProps.postalCode, (current, candidate) =>
|
||||||
|
current.equals(candidate)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(leftProps.province, rightProps.province, (current, candidate) =>
|
||||||
|
current.equals(candidate)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(leftProps.country, rightProps.country, (current, candidate) =>
|
||||||
|
current.equals(candidate)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameTaxConfig(left: ProformaTaxConfig, right: ProformaTaxConfig): boolean {
|
||||||
|
return (
|
||||||
|
left.taxMode === right.taxMode &&
|
||||||
|
this.sameMaybe(
|
||||||
|
left.defaultIvaCode,
|
||||||
|
right.defaultIvaCode,
|
||||||
|
(current, candidate) => current === candidate
|
||||||
|
) &&
|
||||||
|
left.usesEquivalenceSurcharge === right.usesEquivalenceSurcharge &&
|
||||||
|
this.sameMaybe(
|
||||||
|
left.defaultRecCode,
|
||||||
|
right.defaultRecCode,
|
||||||
|
(current, candidate) => current === candidate
|
||||||
|
) &&
|
||||||
|
left.usesRetention === right.usesRetention &&
|
||||||
|
this.sameMaybe(
|
||||||
|
left.defaultRetentionCode,
|
||||||
|
right.defaultRetentionCode,
|
||||||
|
(current, candidate) => current === candidate
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameTaxes(left: ProformaItemTaxesProps, right: ProformaItemTaxesProps): boolean {
|
||||||
|
return (
|
||||||
|
this.sameMaybe(left.iva, right.iva, (current, candidate) =>
|
||||||
|
this.sameTax(current, candidate)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(left.rec, right.rec, (current, candidate) =>
|
||||||
|
this.sameTax(current, candidate)
|
||||||
|
) &&
|
||||||
|
this.sameMaybe(left.retention, right.retention, (current, candidate) =>
|
||||||
|
this.sameTax(current, candidate)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameTax(left: Tax, right: Tax): boolean {
|
||||||
|
return (
|
||||||
|
left.code === right.code &&
|
||||||
|
left.name === right.name &&
|
||||||
|
left.rate.equals(right.rate) &&
|
||||||
|
left.group === right.group &&
|
||||||
|
left.calculationBehavior === right.calculationBehavior
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameMaybe<T>(
|
||||||
|
left: Maybe<T>,
|
||||||
|
right: Maybe<T>,
|
||||||
|
equals: (current: T, candidate: T) => boolean
|
||||||
|
): boolean {
|
||||||
|
if (left.isNone() || right.isNone()) {
|
||||||
|
return left.isNone() && right.isNone();
|
||||||
|
}
|
||||||
|
|
||||||
|
return equals(left.unwrap(), right.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeItems(
|
private initializeItems(
|
||||||
|
|||||||
@ -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);
|
return Result.ok(createResult.data);
|
||||||
},
|
},
|
||||||
|
getIssuedInvoiceById: async (id: UniqueID, context: IIssuedInvoiceServicesContext) =>
|
||||||
|
repository.getByIdInCompany(context.companyId, id, context.transaction),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,12 +16,12 @@ import {
|
|||||||
buildProformaSnapshotBuilders,
|
buildProformaSnapshotBuilders,
|
||||||
} from "../../../application";
|
} from "../../../application";
|
||||||
import type { Proforma } from "../../../domain";
|
import type { Proforma } from "../../../domain";
|
||||||
|
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
|
||||||
|
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator";
|
||||||
|
|
||||||
import { buildV2ProformaPersistence } from "./proforma-persistence.di";
|
import { buildV2ProformaPersistence } from "./proforma-persistence.di";
|
||||||
import type { ProformasInternalDeps } from "./proformas.di";
|
import type { ProformasInternalDeps } from "./proformas.di";
|
||||||
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di";
|
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di";
|
||||||
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
|
|
||||||
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator";
|
|
||||||
|
|
||||||
type ProformaServicesContext = {
|
type ProformaServicesContext = {
|
||||||
transaction: Transaction;
|
transaction: Transaction;
|
||||||
@ -83,9 +83,7 @@ export function buildProformaPublicServices(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const snapshotBuilders = buildProformaSnapshotBuilders();
|
const snapshotBuilders = buildProformaSnapshotBuilders();
|
||||||
const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(
|
const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(documentSeriesServices);
|
||||||
documentSeriesServices
|
|
||||||
);
|
|
||||||
const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator(
|
const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator(
|
||||||
documentSeriesServices
|
documentSeriesServices
|
||||||
);
|
);
|
||||||
@ -99,6 +97,7 @@ export function buildProformaPublicServices(
|
|||||||
targetInvoiceSeriesValidator,
|
targetInvoiceSeriesValidator,
|
||||||
companiesServices,
|
companiesServices,
|
||||||
customerServices,
|
customerServices,
|
||||||
|
audit: params.audit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
type IssueProformaUseCase,
|
type IssueProformaUseCase,
|
||||||
type ListProformasUseCase,
|
type ListProformasUseCase,
|
||||||
type PreviewProformaReportUseCase,
|
type PreviewProformaReportUseCase,
|
||||||
|
ProformaIssuanceReadinessChecker,
|
||||||
type ReportProformaPdfUseCase,
|
type ReportProformaPdfUseCase,
|
||||||
type UnarchiveProformaByIdUseCase,
|
type UnarchiveProformaByIdUseCase,
|
||||||
type UpdateProformaByIdUseCase,
|
type UpdateProformaByIdUseCase,
|
||||||
@ -32,10 +33,10 @@ import {
|
|||||||
buildProformaCatalogResolvers,
|
buildProformaCatalogResolvers,
|
||||||
buildProformaCreator,
|
buildProformaCreator,
|
||||||
buildProformaDeleter,
|
buildProformaDeleter,
|
||||||
|
buildProformaDuplicator,
|
||||||
buildProformaFinder,
|
buildProformaFinder,
|
||||||
buildProformaInputMappers,
|
buildProformaInputMappers,
|
||||||
buildProformaIssueReadModelAssembler,
|
buildProformaIssueReadModelAssembler,
|
||||||
ProformaIssuanceReadinessChecker,
|
|
||||||
buildProformaIssuer,
|
buildProformaIssuer,
|
||||||
buildProformaReadModelAssemblers,
|
buildProformaReadModelAssemblers,
|
||||||
buildProformaSnapshotBuilders,
|
buildProformaSnapshotBuilders,
|
||||||
@ -133,6 +134,20 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
|||||||
targetInvoiceSeriesValidator,
|
targetInvoiceSeriesValidator,
|
||||||
companiesServices,
|
companiesServices,
|
||||||
customerServices,
|
customerServices,
|
||||||
|
audit: params.audit,
|
||||||
|
});
|
||||||
|
const duplicator = buildProformaDuplicator({
|
||||||
|
repository,
|
||||||
|
taxResolver: catalogResolvers.taxResolver,
|
||||||
|
paymentResolver: catalogResolvers.paymentResolver,
|
||||||
|
numberService: proformaNumberService,
|
||||||
|
proformaSeriesValidator,
|
||||||
|
targetInvoiceSeriesValidator,
|
||||||
|
companiesServices,
|
||||||
|
customerServices,
|
||||||
|
documentSeriesServices,
|
||||||
|
paymentTermFinder: catalogs.paymentTerm.finder,
|
||||||
|
audit: params.audit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updater = buildProformaUpdater({
|
const updater = buildProformaUpdater({
|
||||||
@ -141,16 +156,20 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
|||||||
paymentResolver: catalogResolvers.paymentResolver,
|
paymentResolver: catalogResolvers.paymentResolver,
|
||||||
targetInvoiceSeriesValidator,
|
targetInvoiceSeriesValidator,
|
||||||
customerServices,
|
customerServices,
|
||||||
|
audit: params.audit,
|
||||||
});
|
});
|
||||||
const deleter = buildProformaDeleter({
|
const deleter = buildProformaDeleter({
|
||||||
repository,
|
repository,
|
||||||
|
audit: params.audit,
|
||||||
});
|
});
|
||||||
const archiver = buildProformaArchiver({
|
const archiver = buildProformaArchiver({
|
||||||
repository,
|
repository,
|
||||||
|
audit: params.audit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusChanger = buildProformaStatusChanger({
|
const statusChanger = buildProformaStatusChanger({
|
||||||
repository,
|
repository,
|
||||||
|
audit: params.audit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter();
|
const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter();
|
||||||
@ -212,14 +231,10 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
|||||||
|
|
||||||
duplicateProforma: () =>
|
duplicateProforma: () =>
|
||||||
buildDuplicateProformaByIdUseCase({
|
buildDuplicateProformaByIdUseCase({
|
||||||
creator,
|
duplicator,
|
||||||
finder,
|
finder,
|
||||||
fullReadModelAssembler: readModelAssemblers.full,
|
fullReadModelAssembler: readModelAssemblers.full,
|
||||||
fullSnapshotBuilder: snapshotBuilders.full,
|
fullSnapshotBuilder: snapshotBuilders.full,
|
||||||
documentSeriesServices,
|
|
||||||
companiesServices,
|
|
||||||
customerServices,
|
|
||||||
paymentTermFinder: catalogs.paymentTerm.finder,
|
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -287,6 +302,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
|||||||
issueResponseSnapshotBuilder: snapshotBuilders.issue,
|
issueResponseSnapshotBuilder: snapshotBuilders.issue,
|
||||||
repository,
|
repository,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
|
audit: params.audit,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
changeStatusProforma: () =>
|
changeStatusProforma: () =>
|
||||||
|
|||||||
@ -18,21 +18,21 @@ import {
|
|||||||
type FindOptions,
|
type FindOptions,
|
||||||
type Includeable,
|
type Includeable,
|
||||||
type InferAttributes,
|
type InferAttributes,
|
||||||
col,
|
|
||||||
fn,
|
|
||||||
Op,
|
Op,
|
||||||
type OrderItem,
|
type OrderItem,
|
||||||
type Sequelize,
|
type Sequelize,
|
||||||
type Transaction,
|
type Transaction,
|
||||||
type WhereOptions,
|
type WhereOptions,
|
||||||
|
col,
|
||||||
|
fn,
|
||||||
} from "sequelize";
|
} from "sequelize";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
IProformaRepository,
|
IProformaRepository,
|
||||||
|
ProformaListScope,
|
||||||
ProformaStatusCounts,
|
ProformaStatusCounts,
|
||||||
ProformaSummary,
|
ProformaSummary,
|
||||||
} from "../../../../../application";
|
} from "../../../../../application";
|
||||||
import type { ProformaListScope } from "../../../../../application";
|
|
||||||
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
|
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
|
||||||
import {
|
import {
|
||||||
IssuedInvoiceModel,
|
IssuedInvoiceModel,
|
||||||
@ -41,6 +41,7 @@ import {
|
|||||||
ProformaTaxModel,
|
ProformaTaxModel,
|
||||||
} from "../../../../common";
|
} from "../../../../common";
|
||||||
import type { SequelizeProformaV2DomainMapper, SequelizeProformaV2SummaryMapper } from "../mappers";
|
import type { SequelizeProformaV2DomainMapper, SequelizeProformaV2SummaryMapper } from "../mappers";
|
||||||
|
|
||||||
import { buildProformaQuickSearchWhere } from "./proforma-quick-search";
|
import { buildProformaQuickSearchWhere } from "./proforma-quick-search";
|
||||||
|
|
||||||
export class SequelizeProformaRepositoryV2
|
export class SequelizeProformaRepositoryV2
|
||||||
@ -86,15 +87,10 @@ export class SequelizeProformaRepositoryV2
|
|||||||
if (dtoResult.isFailure) return Result.fail(dtoResult.error);
|
if (dtoResult.isFailure) return Result.fail(dtoResult.error);
|
||||||
const dto = dtoResult.data;
|
const dto = dtoResult.data;
|
||||||
const { id, items, taxes, ...updatePayload } = dto;
|
const { id, items, taxes, ...updatePayload } = dto;
|
||||||
const [affectedCount] = await ProformaModel.update(updatePayload, {
|
await ProformaModel.update(updatePayload, {
|
||||||
where: { id },
|
where: { id },
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
if (affectedCount === 0) {
|
|
||||||
return Result.fail(
|
|
||||||
new InfrastructureRepositoryError(`Proforma ${id} not found or concurrency issue`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await ProformaItemModel.destroy({ where: { proforma_id: id }, transaction });
|
await ProformaItemModel.destroy({ where: { proforma_id: id }, transaction });
|
||||||
await ProformaTaxModel.destroy({ where: { proforma_id: id }, transaction });
|
await ProformaTaxModel.destroy({ where: { proforma_id: id }, transaction });
|
||||||
if (Array.isArray(taxes) && taxes.length > 0) {
|
if (Array.isArray(taxes) && taxes.length > 0) {
|
||||||
@ -654,8 +650,7 @@ export class SequelizeProformaRepositoryV2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
counts.all =
|
counts.all = counts.draft + counts.sent + counts.approved + counts.rejected + counts.issued;
|
||||||
counts.draft + counts.sent + counts.approved + counts.rejected + counts.issued;
|
|
||||||
|
|
||||||
return Result.ok(counts);
|
return Result.ok(counts);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@ -783,6 +783,9 @@ importers:
|
|||||||
'@repo/i18next':
|
'@repo/i18next':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/i18n
|
version: link:../../packages/i18n
|
||||||
|
'@repo/rdx-audit':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/rdx-audit
|
||||||
'@repo/rdx-criteria':
|
'@repo/rdx-criteria':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-criteria
|
version: link:../../packages/rdx-criteria
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user