This commit is contained in:
David Arranz 2026-06-14 14:11:17 +02:00
parent 5bc9920538
commit 3a0262cbf7
30 changed files with 477 additions and 257 deletions

View File

@ -2,6 +2,9 @@ import { type ModuleParams, type SetupParams, buildTransactionManager } from "@e
import type { Sequelize } from "sequelize";
import {
type IPaymentMethodPublicFinder,
PaymentMethodPublicFinder,
PaymentMethodPublicModelMapper,
buildPaymentMethodCreator,
buildPaymentMethodDeleter,
buildPaymentMethodFinder,
@ -11,8 +14,6 @@ import {
buildPaymentMethodUpdater,
} from "../../../application";
import type { IPaymentMethodRepository } from "../../../application/payment-methods/repositories";
import type { IPaymentMethodFinder } from "../../../application/payment-methods/services";
import { PaymentMethodFinder } from "../../../application/payment-methods/services";
import {
CreatePaymentMethodUseCase,
DeletePaymentMethodByIdUseCase,
@ -116,8 +117,13 @@ export const buildPaymentMethodsDependencies = (
export const buildPaymentMethodsPublicServices = (
_params: SetupParams,
deps: PaymentMethodsInternalDeps
): { finder: IPaymentMethodFinder } => {
): { finder: IPaymentMethodPublicFinder } => {
const mapper = new PaymentMethodPublicModelMapper();
return {
finder: new PaymentMethodFinder(deps.repository),
finder: new PaymentMethodPublicFinder({
repository: deps.repository,
mapper,
}),
};
};

View File

@ -12,7 +12,7 @@ export abstract class SequelizeDomainMapper<TModel extends Model, TModelAttribut
public abstract mapToPersistence(
domain: TEntity,
params?: MapperParamsType
): Result<TModelAttributes, Error> | Promise<Result<TModelAttributes, Error>>;
): Result<TModelAttributes, Error>;
public mapToDomainCollection(
raws: (TModel | TModelAttributes)[],

View File

@ -0,0 +1,2 @@
export * from "./invoice-payment-method-read.model";
export * from "./invoice-tax-regime-full-read.model";

View File

@ -0,0 +1,12 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
/**
* Datos del método de pago que completan a la proforma / issued-invoice
*/
export interface InvoicePaymentMethodReadModel {
id: UniqueID;
name: string;
description: Maybe<string>;
}

View File

@ -0,0 +1,8 @@
/**
* Datos del régimen de pago que completan a la proforma / issued-invoice
*/
export interface InvocieTaxRegimeFullReadModel {
code: string;
description: string;
}

View File

@ -1,10 +1,8 @@
import { buildCatalogs } from "@erp/core/api";
import {
type IProformaToIssuedInvoiceConverter,
ProformaToIssuedInvoiceConverter,
} from "../services";
export function buildProformaToIssuedInvoicePropsConverter(): IProformaToIssuedInvoiceConverter {
return new ProformaToIssuedInvoiceConverter(buildCatalogs());
}
export const buildProformaToIssuedInvoicePropsConverter = (): IProformaToIssuedInvoiceConverter => {
return new ProformaToIssuedInvoiceConverter();
};

View File

@ -1 +1,2 @@
export * from "./issued-invoice-summary";
export * from "./proforma-issue-read.model";

View File

@ -0,0 +1,13 @@
import type { Proforma } from "../../../domain";
import type { InvoicePaymentMethodReadModel } from "../../common/models";
/**
* Modelo de lectura usado exclusivamente durante la emisión de una proforma.
*
* Combina la proforma validada con los datos externos que deben materializarse
* como snapshot histórico dentro de la factura emitida.
*/
export interface ProformaIssueReadModel {
proforma: Proforma;
paymentMethod: InvoicePaymentMethodReadModel;
}

View File

@ -1,8 +1,6 @@
// modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts
import type { JsonPaymentCatalogProvider } from "@erp/core";
import type { ICatalogs } from "@erp/core/api";
import { DomainError, UtcDate } from "@repo/rdx-ddd";
import { UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
@ -14,9 +12,10 @@ import {
IssuedInvoiceTaxes,
type Proforma,
} from "../../../domain";
import type { ProformaIssueReadModel } from "../models";
export interface IProformaToIssuedInvoiceConverter {
toCreateProps(proforma: Proforma): Result<IIssuedInvoiceCreateProps, Error>;
toCreateProps(source: ProformaIssueReadModel): Result<IIssuedInvoiceCreateProps, Error>;
}
/**
@ -27,29 +26,25 @@ export interface IProformaToIssuedInvoiceConverter {
*/
export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoiceConverter {
private readonly paymentCatalog: JsonPaymentCatalogProvider;
public toCreateProps(source: ProformaIssueReadModel): Result<IIssuedInvoiceCreateProps, Error> {
const { proforma } = source;
constructor(catalogs: ICatalogs) {
this.paymentCatalog = catalogs.paymentCatalog;
}
const itemsResult = this.resolveItems(proforma);
public toCreateProps(proforma: Proforma): Result<IIssuedInvoiceCreateProps, Error> {
const itemsOrResult = this.resolveItems(proforma);
if (itemsOrResult.isFailure) {
return Result.fail(itemsOrResult.error);
if (itemsResult.isFailure) {
return Result.fail(itemsResult.error);
}
const taxesOrResult = this.resolveTaxes(proforma);
const taxesResult = this.resolveTaxes(proforma);
if (taxesOrResult.isFailure) {
return Result.fail(taxesOrResult.error);
if (taxesResult.isFailure) {
return Result.fail(taxesResult.error);
}
const paymentOrResult = this.resolvePayment(proforma);
const paymentResult = this.resolvePayment(source);
if (paymentOrResult.isFailure) {
return Result.fail(paymentOrResult.error);
if (paymentResult.isFailure) {
return Result.fail(paymentResult.error);
}
const proformaTotals = proforma.totals();
@ -74,17 +69,17 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
notes: proforma.notes,
reference: proforma.reference,
paymentMethod: paymentOrResult.data,
paymentMethod: paymentResult.data,
customerId: proforma.customerId,
recipient: proforma.recipient.getOrUndefined()!,
items: itemsOrResult.data,
items: itemsResult.data,
taxes: IssuedInvoiceTaxes.create({
currencyCode: proforma.currencyCode,
languageCode: proforma.languageCode,
taxes: taxesOrResult.data,
taxes: taxesResult.data,
}),
subtotalAmount: proformaTotals.subtotalAmount,
@ -111,13 +106,13 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
const issuedItems: IssuedInvoiceItem[] = [];
for (const item of proforma.items.getAll()) {
const itemOrResult = this.resolveItem(proforma, item);
const itemResult = this.resolveItem(proforma, item);
if (itemOrResult.isFailure) {
return Result.fail(itemOrResult.error);
if (itemResult.isFailure) {
return Result.fail(itemResult.error);
}
issuedItems.push(itemOrResult.data);
issuedItems.push(itemResult.data);
}
return Result.ok(issuedItems);
@ -173,13 +168,13 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
const issuedTaxes: IssuedInvoiceTax[] = [];
for (const tax of proforma.taxes().getAll()) {
const taxOrResult = this.resolveTax(tax);
const taxResult = this.resolveTax(tax);
if (taxOrResult.isFailure) {
return Result.fail(taxOrResult.error);
if (taxResult.isFailure) {
return Result.fail(taxResult.error);
}
issuedTaxes.push(taxOrResult.data);
issuedTaxes.push(taxResult.data);
}
return Result.ok(issuedTaxes);
@ -203,34 +198,25 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
recAmount: tax.recAmount,
retentionCode: tax.retentionCode,
retentionAmount: tax.retentionAmount,
retentionPercentage: tax.retentionPercentage,
retentionAmount: tax.retentionAmount,
taxesAmount: tax.taxesAmount,
});
}
private resolvePayment(proforma: Proforma): Result<InvoicePaymentMethod, Error> {
const paymentId = proforma.paymentMethodId.unwrap();
const existingPaymentResult = this.paymentCatalog.findById(paymentId.toString());
if (existingPaymentResult.isNone()) {
return Result.fail(
new DomainError("Missing payment method [ProformaToIssuedInvoiceConverter]")
);
}
const paymentMethodOrError = InvoicePaymentMethod.create(
private resolvePayment(source: ProformaIssueReadModel): Result<InvoicePaymentMethod, Error> {
const paymentMethodResult = InvoicePaymentMethod.create(
{
name: existingPaymentResult.unwrap().description,
name: source.paymentMethod.name,
},
paymentId
source.paymentMethod.id
);
if (paymentMethodOrError.isFailure) {
return Result.fail(paymentMethodOrError.error);
if (paymentMethodResult.isFailure) {
return Result.fail(paymentMethodResult.error);
}
return Result.ok(paymentMethodOrError.data);
return Result.ok(paymentMethodResult.data);
}
}

View File

@ -1,15 +1,20 @@
import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api";
import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices";
import type { IProformaRepository } from "../repositories";
import { type IProformaIssuer, ProformaIssuer } from "../services";
import { type IProformaIssuer, ProformaIssueReadModelAssembler, ProformaIssuer } from "../services";
export const buildProformaIssuer = (params: {
proformaConverter: IProformaToIssuedInvoiceConverter;
repository: IProformaRepository;
}): IProformaIssuer => {
const { proformaConverter, repository } = params;
return new ProformaIssuer({
proformaConverter,
repository,
proformaConverter: params.proformaConverter,
});
};
export const buildProformaIssueReadModelAssembler = (params: {
paymentMethodFinder: IPaymentMethodPublicFinder;
}) => {
return new ProformaIssueReadModelAssembler({
paymentMethodFinder: params.paymentMethodFinder,
});
};

View File

@ -2,10 +2,12 @@ import type { ITransactionManager } from "@erp/core/api";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
import type { IProformaRepository } from "../repositories";
import type {
IProformaCreator,
IProformaFinder,
IProformaFullReadModelAssembler,
IProformaIssueReadModelAssembler,
IProformaIssuer,
IProformaStatusChanger,
IProformaUpdater,
@ -92,18 +94,25 @@ export function buildIssueProformaUseCase(deps: {
};
finder: IProformaFinder;
issuer: IProformaIssuer;
issueReadModelAssembler: IProformaIssueReadModelAssembler;
repository: IProformaRepository;
transactionManager: ITransactionManager;
}) {
const {
finder,
issuer,
issueReadModelAssembler,
repository,
transactionManager,
publicServices: { issuedInvoiceServices },
} = deps;
return new IssueProformaUseCase({
issuedInvoiceServices,
finder,
issuer,
issueReadModelAssembler,
repository,
transactionManager,
});
}

View File

@ -1,23 +1,10 @@
import type { Maybe } from "@repo/rdx-utils";
import type { Proforma } from "../../../domain";
/**
* Datos del método de pago que completan a la proforma.
*/
export interface ProformaPaymentMethodReadModel {
id: string;
name: string;
description: Maybe<string>;
}
/**
* Datos del régimen de pago que completan a la proforma.
*/
export interface ProformaTaxRegimeFullReadModel {
code: string;
description: string;
}
import type {
InvocieTaxRegimeFullReadModel,
InvoicePaymentMethodReadModel,
} from "../../common/models";
/**
* Modelo de una proforma con datos accesorios.
@ -27,6 +14,6 @@ export interface ProformaTaxRegimeFullReadModel {
*/
export interface ProformaFullReadModel {
proforma: Proforma;
paymentMethod: Maybe<ProformaPaymentMethodReadModel>;
taxRegime: Maybe<ProformaTaxRegimeFullReadModel>;
paymentMethod: Maybe<InvoicePaymentMethodReadModel>;
taxRegime: Maybe<InvocieTaxRegimeFullReadModel>;
}

View File

@ -40,4 +40,10 @@ export interface IProformaRepository {
newStatus: InvoiceStatus,
transaction: unknown
): Promise<Result<boolean, Error>>;
markAsIssuedByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<void, Error>>;
}

View File

@ -1 +1,2 @@
export * from "./proforma-full-read-model.assembler";
export * from "./proforma-issue-read-model.assembler";

View File

@ -0,0 +1,61 @@
import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Proforma } from "../../../../domain";
import type { ProformaIssueReadModel } from "../../../issued-invoices";
export interface IProformaIssueReadModelAssembler {
assemble(params: {
companyId: UniqueID;
proforma: Proforma;
transaction?: unknown;
}): Promise<Result<ProformaIssueReadModel, Error>>;
}
/**
* Prepara los datos externos necesarios para emitir una proforma.
*
* Esta pieza materializa snapshots externos que pasarán a formar parte histórica
* de la factura emitida.
*/
export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelAssembler {
public constructor(
private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder;
}
) {}
public async assemble(params: {
companyId: UniqueID;
proforma: Proforma;
transaction?: unknown;
}): Promise<Result<ProformaIssueReadModel, Error>> {
if (params.proforma.paymentMethodId.isNone()) {
return Result.fail(new Error("Payment method is required to issue proforma"));
}
const paymentMethodId = params.proforma.paymentMethodId.unwrap();
const paymentMethodResult = await this.deps.paymentMethodFinder.getByIdInCompany({
companyId: params.companyId,
id: paymentMethodId,
transaction: params.transaction,
});
if (paymentMethodResult.isFailure) {
return Result.fail(paymentMethodResult.error);
}
const paymentMethod = paymentMethodResult.data;
return Result.ok({
proforma: params.proforma,
paymentMethod: {
id: paymentMethod.id,
name: paymentMethod.name,
description: paymentMethod.description,
},
});
}
}

View File

@ -8,5 +8,5 @@ export * from "./proforma-finder";
export * from "./proforma-issuer";
export * from "./proforma-number-generator.interface";
export * from "./proforma-public-services.interface";
export * from "./proforma-status-charger";
export * from "./proforma-status-changer";
export * from "./proforma-updater";

View File

@ -1,65 +1,34 @@
import type { UniqueID } from "@repo/rdx-ddd";
// modules/customer-invoices/src/api/application/proformas/services/proforma-issuer.ts
import { Result } from "@repo/rdx-utils";
import type { IIssuedInvoiceCreateProps, Proforma } from "../../../domain";
import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices";
import type { IProformaRepository } from "../repositories";
import type { IIssuedInvoiceCreateProps } from "../../../domain";
import type {
IProformaToIssuedInvoiceConverter,
ProformaIssueReadModel,
} from "../../issued-invoices";
export interface IProformaIssuerParams {
companyId: UniqueID;
proforma: Proforma;
issuedInvoiceId: UniqueID;
transaction: unknown;
source: ProformaIssueReadModel;
}
export interface IProformaIssuer {
issueProforma(params: IProformaIssuerParams): Promise<Result<IIssuedInvoiceCreateProps, Error>>;
issueProforma(params: IProformaIssuerParams): Result<IIssuedInvoiceCreateProps, Error>;
}
type ProformaIssuerDeps = {
proformaConverter: IProformaToIssuedInvoiceConverter;
repository: IProformaRepository;
};
export class ProformaIssuer implements IProformaIssuer {
private readonly proformaConverter: IProformaToIssuedInvoiceConverter;
private readonly repository: IProformaRepository;
public constructor(
private readonly deps: {
proformaConverter: IProformaToIssuedInvoiceConverter;
}
) {}
constructor(deps: ProformaIssuerDeps) {
this.proformaConverter = deps.proformaConverter;
this.repository = deps.repository;
}
public issueProforma(params: IProformaIssuerParams): Result<IIssuedInvoiceCreateProps, Error> {
const issueResult = params.source.proforma.markAsIssued();
public async issueProforma(
params: IProformaIssuerParams
): Promise<Result<IIssuedInvoiceCreateProps, Error>> {
const { proforma, companyId, transaction } = params;
// Cambiamos el estado de la proforma a 'issued'
const issueResult = proforma.issue();
if (issueResult.isFailure) {
return Result.fail(issueResult.error);
}
// Persistir
const updateStatusResult = await this.repository.updateStatusByIdInCompany(
companyId,
proforma.id,
proforma.status,
transaction
);
if (updateStatusResult.isFailure) {
return Result.fail(updateStatusResult.error);
}
// Generamos las propiedades de la factura a partir de la proforma
const propsResult = this.proformaConverter.toCreateProps(proforma);
if (propsResult.isFailure) {
return Result.fail(propsResult.error);
}
return Result.ok(propsResult.data);
return this.deps.proformaConverter.toCreateProps(params.source);
}
}

View File

@ -1,8 +1,7 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Proforma } from "../../../domain";
import { InvoiceStatus } from "../../../domain";
import { InvoiceStatus, type Proforma } from "../../../domain";
import type { IProformaRepository } from "../repositories";
export interface IProformaStatusChanger {

View File

@ -35,13 +35,11 @@ export class ChangeStatusProformaUseCase {
return Result.fail(proformaIdResult.error);
}
const proformaId = proformaIdResult.data;
return this.deps.transactionManager.complete(async (transaction) => {
try {
const changeResult = await this.deps.statusChanger.changeStatus({
companyId,
id: proformaId,
id: proformaIdResult.data,
newStatus: new_status!,
transaction,
});

View File

@ -3,7 +3,12 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { IProformaFinder, IProformaIssuer } from "../services";
import type { IProformaRepository } from "../repositories";
import type {
IProformaFinder,
IProformaIssueReadModelAssembler,
IProformaIssuer,
} from "../services";
type IssueProformaUseCaseInput = {
companyId: UniqueID;
@ -11,13 +16,13 @@ type IssueProformaUseCaseInput = {
};
/**
* Caso de uso: Conversión de una issuedinvoice a factura definitiva.
* Caso de uso: conversión de una proforma en factura definitiva.
*
* - Recupera la proforma
* - Valida su estado ("approved")
* - Genera la factura definitiva (nueva entidad)
* - Marca la proforma como "issued"
* - Persiste ambas dentro de la misma transacción
* - Recupera la proforma.
* - Valida su emisión mediante dominio.
* - Crea la factura definitiva.
* - Marca la proforma como emitida.
* - Ejecuta todo dentro de una única transacción.
*/
export class IssueProformaUseCase {
public constructor(
@ -25,6 +30,8 @@ export class IssueProformaUseCase {
issuedInvoiceServices: IIssuedInvoicePublicServices;
finder: IProformaFinder;
issuer: IProformaIssuer;
issueReadModelAssembler: IProformaIssueReadModelAssembler;
repository: IProformaRepository;
transactionManager: ITransactionManager;
}
) {}
@ -32,10 +39,13 @@ export class IssueProformaUseCase {
public execute(params: IssueProformaUseCaseInput) {
const { proforma_id, companyId } = params;
const proformaIdOrError = UniqueID.create(proforma_id);
if (proformaIdOrError.isFailure) return Result.fail(proformaIdOrError.error);
const proformaIdResult = UniqueID.create(proforma_id);
const proformaId = proformaIdOrError.data;
if (proformaIdResult.isFailure) {
return Result.fail(proformaIdResult.error);
}
const proformaId = proformaIdResult.data;
return this.deps.transactionManager.complete(async (transaction) => {
try {
@ -46,28 +56,36 @@ export class IssueProformaUseCase {
transaction
);
if (proformaResult.isFailure) return Result.fail(proformaResult.error);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const proforma = proformaResult.data;
// 2. Generamos la factura definitiva y la guardamos
// 2. Generamos la factura definitiva
const issuedInvoiceId = UniqueID.generateNewID();
const createPropsOrError = await this.deps.issuer.issueProforma({
const issueReadModelResult = await this.deps.issueReadModelAssembler.assemble({
companyId,
issuedInvoiceId,
proforma,
transaction,
});
if (createPropsOrError.isFailure) {
return Result.fail(createPropsOrError.error);
if (issueReadModelResult.isFailure) {
return Result.fail(issueReadModelResult.error);
}
const createProps = createPropsOrError.data;
const createPropsResult = this.deps.issuer.issueProforma({
source: issueReadModelResult.data,
});
if (createPropsResult.isFailure) {
return Result.fail(createPropsResult.error);
}
// Creamos y guardamos en persistencia la factura definitiva
const invoiceResult = await this.deps.issuedInvoiceServices.createIssuedInvoice(
issuedInvoiceId,
createProps,
createPropsResult.data,
{
companyId,
transaction,
@ -78,12 +96,21 @@ export class IssueProformaUseCase {
return Result.fail(invoiceResult.error);
}
const dto = {
const markAsIssuedResult = await this.deps.repository.markAsIssuedByIdInCompany(
companyId,
proformaId,
transaction
);
if (markAsIssuedResult.isFailure) {
return Result.fail(markAsIssuedResult.error);
}
return Result.ok({
issuedinvoice_id: issuedInvoiceId.toString(),
proforma_id: proformaId.toString(),
customer_id: proforma.customerId.toString(),
};
return Result.ok(dto);
});
} catch (error: unknown) {
return Result.fail(error as Error);
}

View File

@ -25,29 +25,19 @@ const INVOICE_TRANSITIONS: Record<string, string[]> = {
};
export class InvoiceStatus extends ValueObject<IInvoiceStatusProps> {
private static readonly ALLOWED_STATUSES = ["draft", "sent", "approved", "rejected", "issued"];
private static readonly ALLOWED_STATUSES = Object.values(INVOICE_STATUS);
private static readonly FIELD = "invoiceStatus";
private static readonly ERROR_CODE = "INVALID_INVOICE_STATUS";
static create(value: string): Result<InvoiceStatus, Error> {
if (!InvoiceStatus.ALLOWED_STATUSES.includes(value)) {
if (!InvoiceStatus.ALLOWED_STATUSES.includes(value as INVOICE_STATUS)) {
const detail = `Estado de la factura no válido: ${value}`;
return Result.fail(
new DomainValidationError(InvoiceStatus.ERROR_CODE, InvoiceStatus.FIELD, detail)
);
}
return Result.ok(
value === "rejected"
? InvoiceStatus.rejected()
: value === "sent"
? InvoiceStatus.sent()
: value === "issued"
? InvoiceStatus.issued()
: value === "approved"
? InvoiceStatus.approved()
: InvoiceStatus.draft()
);
return Result.ok(new InvoiceStatus({ value: value as INVOICE_STATUS }));
}
public static draft(): InvoiceStatus {
@ -94,10 +84,18 @@ export class InvoiceStatus extends ValueObject<IInvoiceStatusProps> {
return INVOICE_TRANSITIONS[this.props.value].includes(nextStatus);
}
public isIssued(): boolean {
isIssued(): boolean {
return this.props.value === INVOICE_STATUS.ISSUED;
}
isRejected(): boolean {
return this.props.value === INVOICE_STATUS.REJECTED;
}
isSent(): boolean {
return this.props.value === INVOICE_STATUS.SENT;
}
toString() {
return String(this.props.value);
}

View File

@ -29,6 +29,7 @@ import {
} from "../entities";
import { InvalidProformaTransitionError, ProformaItemMismatch } from "../errors";
import type { IProformaTaxTotals, ProformaCalculationContext } from "../services";
import { canManuallyTransitionProformaStatus } from "../services";
import { ProformaItemTaxes } from "../value-objects";
export interface IProformaCreateProps {
@ -301,6 +302,12 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return this.taxRegimeCode.isSome();
}
/**
* Cambia manualmente el estado de la proforma.
*
* No permite marcar una proforma como `issued`, porque esa transición implica
* crear una factura emitida y debe ejecutarse mediante el caso de uso de emisión.
*/
public changeStatus(nextStatus: InvoiceStatus): Result<boolean, Error> {
const currentStatus = this.status;
@ -308,17 +315,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return Result.ok(false);
}
if (nextStatus.toPrimitive() === INVOICE_STATUS.ISSUED) {
return Result.fail(
new InvalidProformaTransitionError(
currentStatus.toPrimitive(),
nextStatus.toPrimitive(),
this.id.toString()
)
);
}
if (!currentStatus.canTransitionTo(nextStatus)) {
if (!canManuallyTransitionProformaStatus({ currentStatus, nextStatus })) {
return Result.fail(
new InvalidProformaTransitionError(
currentStatus.toPrimitive(),
@ -333,21 +330,39 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return Result.ok(true);
}
public issue(): Result<void, Error> {
public markAsIssued(): Result<void, Error> {
const currentStatus = this.status;
if (currentStatus.isIssued()) {
return Result.ok();
}
// Antes de cambiar el estado de la proforma,
// comprobamos que se cumplen las condiciones
// necesarias.
if (!this.props.status.canTransitionTo("issued")) {
if (!currentStatus.isApproved()) {
return Result.fail(
new DomainValidationError(
"INVALID_STATE",
"status",
"Proforma cannot be issued from current state"
new InvalidProformaTransitionError(
currentStatus.toPrimitive(),
INVOICE_STATUS.ISSUED,
this.id.toString()
)
);
}
const validationResult = this.validateCanBeIssued();
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
this.props.status = InvoiceStatus.issued();
return Result.ok();
}
private validateCanBeIssued(): Result<void, Error> {
if (this.series.isNone()) {
return Result.fail(
new DomainValidationError(
@ -444,12 +459,11 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
new DomainValidationError(
"LINKED_INVOICE_NOT_ALLOWED",
"linkedInvoiceId",
"Proforma cannot be linked to an invoice"
"Proforma is already linked to an invoice"
)
);
}
this.props.status = InvoiceStatus.issued();
return Result.ok();
}

View File

@ -1,4 +1,5 @@
export * from "./proforma-compare-tax-totals";
export * from "./proforma-compute-tax-groups";
export * from "./proforma-items-totals-calculator";
export * from "./proforma-manual-status-transitions";
export * from "./proforma-taxes-calculator";

View File

@ -0,0 +1,27 @@
import { INVOICE_STATUS, type InvoiceStatus } from "../..";
/**
* Transiciones manuales permitidas para una proforma.
*
* No incluye `approved -> issued` porque emitir una proforma crea una factura emitida
* y debe pasar siempre por el caso de uso específico de emisión.
*/
const PROFORMA_MANUAL_STATUS_TRANSITIONS: Readonly<
Record<INVOICE_STATUS, readonly INVOICE_STATUS[]>
> = {
[INVOICE_STATUS.DRAFT]: [INVOICE_STATUS.SENT],
[INVOICE_STATUS.SENT]: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED],
[INVOICE_STATUS.APPROVED]: [INVOICE_STATUS.REJECTED, INVOICE_STATUS.DRAFT],
[INVOICE_STATUS.REJECTED]: [INVOICE_STATUS.DRAFT],
[INVOICE_STATUS.ISSUED]: [],
};
export function canManuallyTransitionProformaStatus(params: {
currentStatus: InvoiceStatus;
nextStatus: InvoiceStatus;
}): boolean {
const current = params.currentStatus.toPrimitive() as INVOICE_STATUS;
const next = params.nextStatus.toPrimitive() as INVOICE_STATUS;
return PROFORMA_MANUAL_STATUS_TRANSITIONS[current].includes(next);
}

View File

@ -14,11 +14,11 @@ import {
} from "../../../application";
import type { Proforma } from "../../../domain";
import { resolveProformaCatalogsDeps } from "./proforma-catalog-deps.di";
import { buildProformaNumberGenerator } from "./proforma-number-generator.di";
import { buildProformaPersistenceMappers } from "./proforma-persistence-mappers.di";
import { buildProformaRepository } from "./proforma-repositories.di";
import type { ProformasInternalDeps } from "./proformas.di";
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di";
type ProformaServicesContext = {
transaction: Transaction;

View File

@ -18,6 +18,7 @@ import {
buildProformaCreator,
buildProformaFinder,
buildProformaInputMappers,
buildProformaIssueReadModelAssembler,
buildProformaIssuer,
buildProformaReadModelAssemblers,
buildProformaSnapshotBuilders,
@ -104,7 +105,10 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const issuer = buildProformaIssuer({
proformaConverter: proformaToIssuedInvoiceConverter,
repository,
});
const issueReadModelAssembler = buildProformaIssueReadModelAssembler({
paymentMethodFinder: catalogs.paymentMethod.finder,
});
const documentGeneratorPipeline = buildProformaDocumentService(params);
@ -159,6 +163,8 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
publicServices,
finder,
issuer,
issueReadModelAssembler,
repository,
transactionManager,
}),

View File

@ -15,24 +15,24 @@ type ProformaCatalogsDeps = {
export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatalogsDeps {
const taxDefinition =
params.getService<CatalogsPublicServicesType["taxDefinitions"]>("catalogs:taxDefinition");
params.getService<CatalogsPublicServicesType["taxDefinitions"]>("catalogs:taxDefinitions");
if (!taxDefinition?.finder) {
throw new Error("Missing public service: catalogs:taxDefinition.finder");
throw new Error("Missing public service: catalogs:taxDefinitions.finder");
}
const taxRegime =
params.getService<CatalogsPublicServicesType["taxRegimes"]>("catalogs:taxRegime");
params.getService<CatalogsPublicServicesType["taxRegimes"]>("catalogs:taxRegimes");
if (!taxRegime?.finder) {
throw new Error("Missing public service: catalogs:taxRegime.finder");
throw new Error("Missing public service: catalogs:taxRegimes.finder");
}
const paymentMethod =
params.getService<CatalogsPublicServicesType["paymentMethods"]>("catalogs:paymentMethod");
params.getService<CatalogsPublicServicesType["paymentMethods"]>("catalogs:paymentMethods");
if (!paymentMethod?.finder) {
throw new Error("Missing public service: catalogs:paymentMethod.finder");
throw new Error("Missing public service: catalogs:paymentMethods.finder");
}
return {

View File

@ -275,7 +275,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
}
}
public async mapToPersistence(
public mapToPersistence(
source: Proforma,
params?: MapperParamsType
): Result<CustomerInvoiceCreationAttributes, Error> {
@ -350,7 +350,10 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
notes: maybeToNullable(source.notes, (v) => v.toPrimitive()),
payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()),
payment_method_description: null,
tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value),
tax_regime_description: null,
subtotal_amount_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale,

View File

@ -1,8 +1,12 @@
// modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts
import {
DiscountPercentage,
type MapperParamsType,
SequelizeDomainMapper,
Tax,
type TaxCalculationBehavior,
type TaxGroup,
TaxPercentage,
} from "@erp/core/api";
import {
UniqueID,
@ -12,7 +16,7 @@ import {
maybeFromNullableResult,
maybeToNullable,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import {
type IProformaCreateProps,
@ -54,14 +58,14 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
);
const description = extractOrPushError(
maybeFromNullableResult(raw.description, (v) => ItemDescription.create(v)),
maybeFromNullableResult(raw.description, (value) => ItemDescription.create(value)),
`items[${index}].description`,
errors
);
const quantity = extractOrPushError(
maybeFromNullableResult(raw.quantity_value, (v) =>
ItemQuantity.create({ value: v, scale: raw.quantity_scale })
maybeFromNullableResult(raw.quantity_value, (value) =>
ItemQuantity.create({ value, scale: raw.quantity_scale })
),
`items[${index}].quantity_value`,
errors
@ -80,9 +84,9 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
);
const itemDiscountPercentage = extractOrPushError(
maybeFromNullableResult(raw.item_discount_percentage_value, (v) =>
maybeFromNullableResult(raw.item_discount_percentage_value, (value) =>
DiscountPercentage.create({
value: v,
value,
scale: raw.item_discount_percentage_scale,
})
),
@ -91,22 +95,41 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
);
const iva = extractOrPushError(
maybeFromNullableResult(raw.iva_code, (code) => Tax.createFromCode(code, this._taxCatalog)),
`items[${index}].iva_code`,
this.mapTaxToDomain({
code: raw.iva_code,
percentageValue: raw.iva_percentage_value,
percentageScale: raw.iva_percentage_scale,
group: "iva",
calculationBehavior: "additive",
fieldPath: `items[${index}].iva`,
}),
`items[${index}].iva`,
errors
);
const rec = extractOrPushError(
maybeFromNullableResult(raw.rec_code, (code) => Tax.createFromCode(code, this._taxCatalog)),
`items[${index}].rec_code`,
this.mapTaxToDomain({
code: raw.rec_code,
percentageValue: raw.rec_percentage_value,
percentageScale: raw.rec_percentage_scale,
group: "surcharge",
calculationBehavior: "additive",
fieldPath: `items[${index}].rec`,
}),
`items[${index}].rec`,
errors
);
const retention = extractOrPushError(
maybeFromNullableResult(raw.retention_code, (code) =>
Tax.createFromCode(code, this._taxCatalog)
),
`items[${index}].retention_code`,
this.mapTaxToDomain({
code: raw.retention_code,
percentageValue: raw.retention_percentage_value,
percentageScale: raw.retention_percentage_scale,
group: "retention",
calculationBehavior: "subtractive",
fieldPath: `items[${index}].retention`,
}),
`items[${index}].retention`,
errors
);
@ -122,55 +145,94 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
};
}
private mapTaxToDomain(params: {
code: string | null;
percentageValue: number | null;
percentageScale?: number;
group: TaxGroup;
calculationBehavior: TaxCalculationBehavior;
fieldPath: string;
}): Result<Maybe<Tax>, Error> {
if (params.code === null || params.code.trim() === "") {
return Result.ok(Maybe.none());
}
if (params.percentageValue === null) {
return Result.fail(
new Error(`${params.fieldPath}.percentage_value is required when tax code is present`)
);
}
const percentageResult = TaxPercentage.create({
value: params.percentageValue,
});
if (percentageResult.isFailure) {
return Result.fail(percentageResult.error);
}
const taxResult = Tax.create({
code: params.code,
name: params.code,
rate: percentageResult.data,
group: params.group,
calculationBehavior: params.calculationBehavior,
});
if (taxResult.isFailure) {
return Result.fail(taxResult.error);
}
return Result.ok(Maybe.some(taxResult.data));
}
public mapToDomain(
raw: CustomerInvoiceItemModel,
params?: MapperParamsType
): Result<ProformaItem, Error> {
const { errors, index } = params as {
const { errors } = params as {
index: number;
errors: ValidationErrorDetail[];
parent: Partial<IProformaCreateProps>;
};
// 1) Valores escalares (atributos generales)
const attributes = this.mapAttributesToDomain(raw, params);
// Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer invoice item mapping failed [mapToDomain]", errors)
);
}
// 2) Construcción del elemento de taxes
const taxesResult = ProformaItemTaxes.create({
iva: attributes.iva!,
rec: attributes.rec!,
retention: attributes.retention!,
});
// 2) Construcción del elemento de dominio
const itemId = attributes.itemId!;
const newItem = ProformaItem.rehydrate(
if (taxesResult.isFailure) {
return Result.fail(taxesResult.error);
}
const item = ProformaItem.rehydrate(
{
description: attributes.description!,
quantity: attributes.quantity!,
unitAmount: attributes.unitAmount!,
itemDiscountPercentage: attributes.itemDiscountPercentage!,
taxes: taxesResult.data,
},
itemId
attributes.itemId!
);
return Result.ok(newItem);
return Result.ok(item);
}
public mapToPersistence(
source: ProformaItem,
params?: MapperParamsType
): Result<CustomerInvoiceItemCreationAttributes, Error> {
const { errors, index, parent } = params as {
const { index, parent } = params as {
index: number;
parent: Proforma;
errors: ValidationErrorDetail[];
@ -187,34 +249,32 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
invoice_id: parent.id.toPrimitive(),
position: index,
description: maybeToNullable(source.description, (v) => v.toPrimitive()),
description: maybeToNullable(source.description, (value) => value.toPrimitive()),
quantity_value: maybeToNullable(source.quantity, (v) => v.toPrimitive().value),
quantity_value: maybeToNullable(source.quantity, (value) => value.toPrimitive().value),
quantity_scale:
maybeToNullable(source.quantity, (v) => v.toPrimitive().scale) ??
maybeToNullable(source.quantity, (value) => value.toPrimitive().scale) ??
ItemQuantity.DEFAULT_SCALE,
unit_amount_value: maybeToNullable(source.unitAmount, (v) => v.toPrimitive().value),
unit_amount_value: maybeToNullable(source.unitAmount, (value) => value.toPrimitive().value),
unit_amount_scale:
maybeToNullable(source.unitAmount, (v) => v.toPrimitive().scale) ??
maybeToNullable(source.unitAmount, (value) => value.toPrimitive().scale) ??
ItemAmount.DEFAULT_SCALE,
subtotal_amount_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale,
//
item_discount_percentage_value: maybeToNullable(
source.itemDiscountPercentage,
(v) => v.toPrimitive().value
(value) => value.toPrimitive().value
),
item_discount_percentage_scale:
maybeToNullable(source.itemDiscountPercentage, (v) => v.toPrimitive().scale) ??
maybeToNullable(source.itemDiscountPercentage, (value) => value.toPrimitive().scale) ??
DiscountPercentage.DEFAULT_SCALE,
item_discount_amount_value: allAmounts.itemDiscountAmount.value,
item_discount_amount_scale: allAmounts.itemDiscountAmount.scale,
//
global_discount_percentage_value: parent.globalDiscountPercentage.toPrimitive().value,
global_discount_percentage_scale:
parent.globalDiscountPercentage.toPrimitive().scale ?? DiscountPercentage.DEFAULT_SCALE,
@ -222,52 +282,40 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
global_discount_amount_value: allAmounts.globalDiscountAmount.value,
global_discount_amount_scale: allAmounts.globalDiscountAmount.scale,
//
total_discount_amount_value: allAmounts.totalDiscountAmount.value,
total_discount_amount_scale: allAmounts.totalDiscountAmount.scale,
//
taxable_amount_value: allAmounts.taxableAmount.value,
taxable_amount_scale: allAmounts.taxableAmount.scale,
// IVA
iva_code: maybeToNullable(source.taxes.iva, (v) => v.code),
iva_percentage_value: maybeToNullable(source.taxes.iva, (v) => v.percentage.value),
iva_code: maybeToNullable(source.taxes.iva, (value) => value.code),
iva_percentage_value: maybeToNullable(source.taxes.iva, (value) => value.percentage.value),
iva_percentage_scale:
maybeToNullable(source.taxes.iva, (v) => v.percentage.scale) ?? Tax.DEFAULT_SCALE,
maybeToNullable(source.taxes.iva, (value) => value.percentage.scale) ?? Tax.DEFAULT_SCALE,
iva_amount_value: allAmounts.ivaAmount.value,
iva_amount_scale: allAmounts.ivaAmount.scale,
// REC
rec_code: maybeToNullable(source.taxes.rec, (v) => v.code),
rec_percentage_value: maybeToNullable(source.taxes.rec, (v) => v.percentage.value),
rec_code: maybeToNullable(source.taxes.rec, (value) => value.code),
rec_percentage_value: maybeToNullable(source.taxes.rec, (value) => value.percentage.value),
rec_percentage_scale:
maybeToNullable(source.taxes.rec, (v) => v.percentage.scale) ?? Tax.DEFAULT_SCALE,
maybeToNullable(source.taxes.rec, (value) => value.percentage.scale) ?? Tax.DEFAULT_SCALE,
rec_amount_value: allAmounts.recAmount.value,
rec_amount_scale: allAmounts.recAmount.scale,
// RET
retention_code: maybeToNullable(source.taxes.retention, (v) => v.code),
retention_code: maybeToNullable(source.taxes.retention, (value) => value.code),
retention_percentage_value: maybeToNullable(
source.taxes.retention,
(v) => v.percentage.value
(value) => value.percentage.value
),
retention_percentage_scale:
maybeToNullable(source.taxes.retention, (v) => v.percentage.scale) ?? Tax.DEFAULT_SCALE,
maybeToNullable(source.taxes.retention, (value) => value.percentage.scale) ??
Tax.DEFAULT_SCALE,
retention_amount_value: allAmounts.retentionAmount.value,
retention_amount_scale: allAmounts.retentionAmount.scale,
//
taxes_amount_value: allAmounts.taxesAmount.value,
taxes_amount_scale: allAmounts.taxesAmount.scale,
//
total_amount_value: allAmounts.totalAmount.value,
total_amount_scale: allAmounts.totalAmount.scale,
});

View File

@ -10,7 +10,7 @@ import { type Collection, Result } from "@repo/rdx-utils";
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize";
import type { IProformaRepository, ProformaSummary } from "../../../../../application";
import type { InvoiceStatus, Proforma } from "../../../../../domain";
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
import {
CustomerInvoiceItemModel,
CustomerInvoiceModel,
@ -241,6 +241,41 @@ export class ProformaRepository
}
}
public async markAsIssuedByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
): Promise<Result<void, Error>> {
try {
const [affectedRows] = await CustomerInvoiceModel.update(
{
status: INVOICE_STATUS.ISSUED,
},
{
where: {
id: id.toString(),
company_id: companyId.toString(),
is_proforma: true,
status: INVOICE_STATUS.APPROVED,
},
transaction: transaction as Transaction,
}
);
if (affectedRows !== 1) {
return Result.fail(
new Error(
`Proforma ${id.toString()} could not be marked as issued because it is not approved or does not exist`
)
);
}
return Result.ok();
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
/**
*
* Busca una factura por su identificador único.