Uecko_ERP/modules/factuges/src/api/application/use-cases/create-proforma-from-factuges.use-case.ts

462 lines
14 KiB
TypeScript
Raw Normal View History

2026-03-16 17:45:45 +00:00
import type { JsonTaxCatalogProvider } from "@erp/core";
2026-03-28 21:10:05 +00:00
import { type ITransactionManager, isEntityNotFoundError } from "@erp/core/api";
2026-04-21 17:13:59 +00:00
import type { IProformaPublicServices } from "@erp/customer-invoices/api";
2026-03-25 09:34:17 +00:00
import {
type InvoiceAmount,
2026-03-25 09:34:17 +00:00
type InvoiceRecipient,
InvoiceStatus,
type ItemAmount,
type Proforma,
2026-03-25 09:34:17 +00:00
} from "@erp/customer-invoices/api/domain";
2026-04-21 17:13:59 +00:00
import type { ICustomerPublicServices } from "@erp/customers/api";
2026-03-16 17:45:45 +00:00
import {
type Customer,
CustomerStatus,
2026-03-25 09:49:28 +00:00
CustomerTaxes,
2026-03-16 17:45:45 +00:00
type ICustomerCreateProps,
} from "@erp/customers/api/domain";
import {
type Name,
type PhoneNumber,
type TextValue,
UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
} from "@repo/rdx-ddd";
2026-03-16 17:45:45 +00:00
import { Maybe, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { CreateProformaFromFactugesRequestDTO } from "../../../common";
2026-03-25 13:26:10 +00:00
import type { FactugesProformaPayload, ICreateProformaFromFactugesInputMapper } from "../mappers";
2026-03-16 17:45:45 +00:00
2026-03-25 09:34:17 +00:00
import paymentsCatalog from "./payments.json";
type FakePaymentMethod = {
id: UniqueID;
description: string;
factuges_id: string;
};
2026-03-16 17:45:45 +00:00
type CreateProformaFromFactugesUseCaseInput = {
companyId: UniqueID;
dto: CreateProformaFromFactugesRequestDTO;
};
type CreateProformaFromFactugesUseCaseDeps = {
2026-04-21 17:13:59 +00:00
customerServices: ICustomerPublicServices;
proformaServices: IProformaPublicServices;
2026-03-16 17:45:45 +00:00
dtoMapper: ICreateProformaFromFactugesInputMapper;
taxCatalog: JsonTaxCatalogProvider;
transactionManager: ITransactionManager;
};
2026-04-21 17:13:59 +00:00
type CreateProformaProps = Parameters<IProformaPublicServices["createProforma"]>["1"];
2026-03-25 09:34:17 +00:00
2026-03-16 17:45:45 +00:00
export class CreateProformaFromFactugesUseCase {
private readonly dtoMapper: ICreateProformaFromFactugesInputMapper;
2026-04-21 17:13:59 +00:00
private readonly customerServices: ICustomerPublicServices;
private readonly proformaServices: IProformaPublicServices;
2026-03-16 17:45:45 +00:00
private readonly taxCatalog: JsonTaxCatalogProvider;
private readonly transactionManager: ITransactionManager;
constructor(deps: CreateProformaFromFactugesUseCaseDeps) {
this.customerServices = deps.customerServices;
this.proformaServices = deps.proformaServices;
this.dtoMapper = deps.dtoMapper;
this.taxCatalog = deps.taxCatalog;
this.transactionManager = deps.transactionManager;
}
public async execute(params: CreateProformaFromFactugesUseCaseInput) {
const { dto, companyId } = params;
// 1) Mapear DTO → props
const mappedPropsResult = this.dtoMapper.map(dto, { companyId });
if (mappedPropsResult.isFailure) {
return Result.fail(mappedPropsResult.error);
}
2026-03-25 09:34:17 +00:00
const { customerLookup, paymentLookup, customerDraft, proformaDraft, paymentDraft } =
mappedPropsResult.data;
2026-03-16 17:45:45 +00:00
2026-04-21 17:13:59 +00:00
// 2) Comprobar si la proforma ya existe (idempotencia)
const existingProformaResult = await this.proformaServices.getProformaByFactuGESId(
proformaDraft.factugesID,
{ companyId, transaction: null }
);
if (existingProformaResult.isSuccess) {
const existingProforma = existingProformaResult.data;
return Result.ok({
customer_id: existingProforma.customerId.toString(),
proforma_id: existingProforma.id.toString(),
});
}
// 3) Si no existe la proforma, la creamos dentro de una transacción.
2026-03-16 17:45:45 +00:00
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const customerResult = await this.resolveCustomer(customerLookup, customerDraft, {
companyId,
transaction,
});
if (customerResult.isFailure) {
return Result.fail(customerResult.error);
}
const customer = customerResult.data;
2026-03-25 09:34:17 +00:00
const paymentResult = await this.resolvePayment(paymentLookup, paymentDraft, {
2026-03-16 17:45:45 +00:00
companyId,
transaction,
});
if (paymentResult.isFailure) {
return Result.fail(paymentResult.error);
2026-03-25 09:34:17 +00:00
}
const payment = paymentResult.data;
// Crear la proforma para ese cliente
const createPropsResult = this.buildProformaCreateProps({
proformaDraft,
payment,
customerId: customer.id,
context: {
companyId,
transaction,
},
});
2026-03-16 17:45:45 +00:00
if (createPropsResult.isFailure) {
return Result.fail(createPropsResult.error);
}
2026-03-25 09:34:17 +00:00
const newId = UniqueID.generateNewID();
2026-03-16 17:45:45 +00:00
const createResult = await this.proformaServices.createProforma(
2026-03-25 09:34:17 +00:00
newId,
2026-03-16 17:45:45 +00:00
createPropsResult.data,
{
companyId,
transaction,
}
);
if (createResult.isFailure) {
return Result.fail(createResult.error);
}
// Valida que los datos de entrada coincidan con el snapshot
const proforma = createResult.data;
const validationResult = this.validateDraftAgainstProforma(proformaDraft, proforma);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
2026-03-25 09:34:17 +00:00
const readResult = await this.proformaServices.getProformaSnapshotById(
createResult.data.id,
{
companyId,
transaction,
}
);
if (readResult.isFailure) {
return Result.fail(readResult.error);
}
const snapshot = readResult.data;
2026-03-16 17:45:45 +00:00
2026-03-25 09:34:17 +00:00
const result = {
2026-03-16 17:45:45 +00:00
customer_id: customer.id.toString(),
2026-03-25 09:34:17 +00:00
proforma_id: snapshot.id.toString(),
2026-03-16 17:45:45 +00:00
};
2026-03-25 09:34:17 +00:00
return Result.ok(result);
2026-03-16 17:45:45 +00:00
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
/**
* Valida que las magnitudes importadas del borrador coincidan con la proforma
* generada por el dominio.
*
* Motivo:
* - Detecta divergencias entre el payload legacy y los cálculos reales del dominio.
* - Actúa como validación de reconciliación, no como sustituto de las invariantes del agregado.
*/
private validateDraftAgainstProforma(
proformaDraft: FactugesProformaPayload["proformaDraft"],
proforma: Proforma
): Result<void, Error> {
const errors: ValidationErrorDetail[] = [];
const proformaTotals = proforma.totals();
if (proformaDraft.items.length !== proforma.items.size()) {
errors.push({
path: "items",
message: "La cantidad de ítems de la proforma no coincide con los datos de entrada.",
});
}
this.validateOptionalExpectedAmount({
expected: proformaDraft.subtotalAmount,
actual: proformaTotals.subtotalAmount,
path: "subtotalAmount",
message: "El subtotal de la proforma no coincide con los datos de entrada.",
errors,
});
this.validateOptionalExpectedAmount({
expected: proformaDraft.taxableAmount,
actual: proformaTotals.taxableAmount,
path: "taxableAmount",
message: "La base imponible de la proforma no coincide con los datos de entrada.",
errors,
});
this.validateOptionalExpectedAmount({
expected: proformaDraft.taxesAmount,
actual: proformaTotals.taxesAmount,
path: "taxesAmount",
message: "La suma de impuestos de la proforma no coincide con los datos de entrada.",
errors,
});
this.validateOptionalExpectedAmount({
expected: proformaDraft.totalAmount,
actual: proformaTotals.totalAmount,
path: "totalAmount",
message: "El total de la proforma no coincide con los datos de entrada.",
errors,
});
if (errors.length > 0) {
2026-04-28 09:49:21 +00:00
console.error(errors);
return Result.fail(
new ValidationErrorCollection(
"La proforma generada no coincide con las magnitudes validadas del borrador importado.",
errors
)
);
}
return Result.ok();
}
private validateOptionalExpectedAmount(params: {
expected: Maybe<InvoiceAmount | ItemAmount>;
actual: InvoiceAmount | ItemAmount;
path: string;
message: string;
errors: ValidationErrorDetail[];
}): void {
const { expected, actual, path, message, errors } = params;
if (expected.isNone()) {
return;
}
const expectedAmount = expected.unwrap();
if (!actual.equalsTo(expectedAmount)) {
errors.push({
path,
message: this.buildAmountMismatchMessage({
baseMessage: message,
expected: expectedAmount,
actual,
}),
});
}
}
private buildAmountMismatchMessage(params: {
baseMessage: string;
expected: InvoiceAmount | ItemAmount;
actual: InvoiceAmount | ItemAmount;
}): string {
const { baseMessage, expected, actual } = params;
2026-03-28 21:10:05 +00:00
return `${baseMessage} FactuGES: ${expected.formattedValue}. Calculado: ${actual.formattedValue}.`;
}
/**
* Valida un importe opcional esperado contra un importe real también opcional.
*
* Motivo:
* - Algunos campos pueden faltar tanto en el payload importado como en
* la proyección o snapshot generado.
* - Si el esperado existe pero el real no, se considera discrepancia.
*/
private validateOptionalMaybeAmount(params: {
expected: Maybe<InvoiceAmount | ItemAmount>;
actual: Maybe<InvoiceAmount | ItemAmount>;
path: string;
message: string;
errors: ValidationErrorDetail[];
}): void {
const { expected, actual, path, message, errors } = params;
if (expected.isNone()) {
return;
}
if (actual.isNone()) {
errors.push({
path,
message,
});
return;
}
if (!actual.unwrap().equals(expected.unwrap())) {
errors.push({
path,
message,
});
}
}
2026-03-25 09:34:17 +00:00
private buildProformaCreateProps(deps: {
2026-03-25 13:26:10 +00:00
proformaDraft: FactugesProformaPayload["proformaDraft"];
2026-03-25 09:34:17 +00:00
customerId: UniqueID;
payment: FakePaymentMethod;
2026-03-16 17:45:45 +00:00
context: {
companyId: UniqueID;
transaction: Transaction;
2026-03-25 09:34:17 +00:00
};
}): Result<CreateProformaProps, Error> {
const { proformaDraft, payment, customerId, context } = deps;
2026-03-16 17:45:45 +00:00
const { companyId } = context;
2026-03-28 21:10:05 +00:00
const defaultStatus = InvoiceStatus.approved();
2026-03-16 17:45:45 +00:00
const recipient = Maybe.none<InvoiceRecipient>();
2026-05-05 11:34:36 +00:00
const linkedInvoiceId = Maybe.none<UniqueID>();
const paymentMethodId = Maybe.some(payment.id);
2026-05-04 18:33:24 +00:00
2026-03-16 17:45:45 +00:00
return Result.ok({
...proformaDraft,
companyId,
customerId,
status: defaultStatus,
2026-05-05 11:34:36 +00:00
paymentMethodId,
linkedInvoiceId,
2026-03-16 17:45:45 +00:00
recipient,
});
}
/**
* Resuelve un cliente existente o lo crea si todavía no existe.
*
* Motivo:
* - Centraliza la política "find or create" del caso de uso.
* - Evita duplicar lógica de control y branching en `execute`.
* - Separa los datos de búsqueda de los datos necesarios para alta.
*
* @param customerLookup - Datos mínimos para localizar un cliente existente.
* @param customerDraft - Datos necesarios para crear el cliente si no existe.
* @param context - Contexto transaccional y de compañía.
* @returns `Result` con el cliente resuelto o el error producido.
*/
private async resolveCustomer(
2026-03-25 13:26:10 +00:00
customerLookup: FactugesProformaPayload["customerLookup"],
customerDraft: FactugesProformaPayload["customerDraft"],
2026-03-16 17:45:45 +00:00
context: {
companyId: UniqueID;
transaction: Transaction;
}
): Promise<Result<Customer, Error>> {
const { companyId, transaction } = context;
const existingCustomerResult = await this.customerServices.findCustomerByTIN(
customerLookup.tin,
{ companyId, transaction }
);
if (existingCustomerResult.isSuccess) {
return Result.ok(existingCustomerResult.data);
}
if (!isEntityNotFoundError(existingCustomerResult.error)) {
return Result.fail(existingCustomerResult.error);
}
const createPropsResult = this.buildCustomerCreateProps(customerDraft, context);
if (createPropsResult.isFailure) {
return Result.fail(createPropsResult.error);
}
return this.customerServices.createCustomer(UniqueID.generateNewID(), createPropsResult.data, {
companyId,
transaction,
});
}
2026-03-25 09:34:17 +00:00
private async resolvePayment(
2026-03-25 13:26:10 +00:00
paymentLookup: FactugesProformaPayload["paymentLookup"],
paymentDraft: FactugesProformaPayload["paymentDraft"],
2026-03-25 09:34:17 +00:00
context: {
companyId: UniqueID;
transaction: Transaction;
}
): Promise<Result<FakePaymentMethod, Error>> {
const { companyId, transaction } = context;
const existingPaymentResult = paymentsCatalog.find(
(payment) =>
payment.factuges_id === paymentLookup.factuges_id &&
payment.company_id === companyId.toString()
);
if (existingPaymentResult) {
return Result.ok({
id: UniqueID.create(existingPaymentResult.id).data,
description: existingPaymentResult.description,
factuges_id: existingPaymentResult.factuges_id,
});
}
return Result.fail(new Error("Forma de pago no existe!!!"));
}
2026-03-16 17:45:45 +00:00
private buildCustomerCreateProps(
2026-03-25 13:26:10 +00:00
customerDraft: FactugesProformaPayload["customerDraft"],
2026-03-16 17:45:45 +00:00
context: {
companyId: UniqueID;
transaction: Transaction;
}
): Result<ICustomerCreateProps, Error> {
const { companyId } = context;
const status = CustomerStatus.createActive();
2026-03-28 21:10:05 +00:00
const defaultTaxes = CustomerTaxes.fromKey("iva_21;#;#", this.taxCatalog);
2026-03-16 17:45:45 +00:00
2026-03-25 09:49:28 +00:00
if (defaultTaxes.isFailure) {
return Result.fail(defaultTaxes.error);
}
2026-03-16 17:45:45 +00:00
const tin = Maybe.some(customerDraft.tin);
const tradeName = Maybe.none<Name>();
const reference = Maybe.none<Name>();
const fax = Maybe.none<PhoneNumber>();
const legalRecord = Maybe.none<TextValue>();
return Result.ok({
...customerDraft,
companyId,
status,
tin,
tradeName,
reference,
fax,
legalRecord,
2026-03-25 09:49:28 +00:00
defaultTaxes: defaultTaxes.data,
2026-03-16 17:45:45 +00:00
});
}
}