This commit is contained in:
David Arranz 2026-03-28 22:10:05 +01:00
parent 3a61d726f8
commit 5d59598106
105 changed files with 1134 additions and 1263 deletions

View File

@ -5,6 +5,7 @@
"cweijan.vscode-mysql-client2",
"ms-vscode.vscode-json",
"formulahendry.auto-rename-tag",
"cweijan.dbclient-jdbc"
"cweijan.dbclient-jdbc",
"pkief.material-icon-theme"
]
}

33
.vscode/settings.json vendored
View File

@ -1,20 +1,19 @@
{
"files.associations": {
"tsconfig.json": "jsonc",
"typescript-config/*.json": "jsonc",
"*.css": "tailwindcss"
},
// Font Family
"editor.fontFamily": "'Fira Code', 'Cascadia Code', 'Consolas'",
// Enable Font Ligatures
"editor.fontLigatures": true,
// Javascript and TypeScript settings
"javascript.suggest.enabled": true,
"javascript.suggest.autoImports": true,
"javascript.preferences.importModuleSpecifier": "shortest",
"js/ts.suggest.enabled": true,
"js/ts.suggest.autoImports": true,
"js/ts.preferences.importModuleSpecifier": "shortest",
"typescript.suggest.enabled": true,
"typescript.suggest.completeFunctionCalls": true,
"typescript.suggest.includeAutomaticOptionalChainCompletions": true,
"typescript.suggestionActions.enabled": true,
"typescript.autoClosingTags": true,
"js/ts.suggest.completeFunctionCalls": true,
"js/ts.suggest.includeAutomaticOptionalChainCompletions": true,
"js/ts.suggestionActions.enabled": true,
"js/ts.autoClosingTags.enabled": true,
"editor.quickSuggestions": {
"strings": "on"
@ -54,11 +53,5 @@
// other vscode settings
"[sql]": {
"editor.defaultFormatter": "cweijan.vscode-mysql-client2"
}, // <- your root font size here
"invisibleAiChartDetector.watermark.includeSpaceFamily": true,
"invisibleAiChartDetector.watermark.includeUnicodeCf": true,
"invisibleAiChartDetector.doubleBlankThreshold": 2,
"invisibleAiChartDetector.replace.format": "unicode",
"invisibleAiChartDetector.clean.replaceSpaceLikesToAscii": true
} // <- your root font size here
}

View File

@ -154,7 +154,12 @@ async function setupModule(name: string, params: ModuleParams, stack: string[])
function makeGetService(moduleName: string, pkg: IModuleServer) {
return <T>(serviceName: string): T => {
const [serviceModule] = serviceName.split(":");
// No registrar dependencias para modulos
// que usan sus propios servicios.
if (serviceModule !== "self") {
trackDependencyUse(moduleName, serviceModule);
}
// IMPORTANTE: devolver el valor
return getServiceScoped<T>(moduleName, pkg.dependencies ?? [], serviceName);
@ -213,6 +218,8 @@ function validateModuleDependencies() {
const declared = new Set(pkg.dependencies ?? []);
const used = usedDependenciesByModule.get(moduleName) ?? new Set<string>();
console.log(declared, used);
// ❌ usadas pero no declaradas
const undeclaredUsed = [...used].filter((d) => !declared.has(d));

View File

@ -12,15 +12,25 @@ export function registerService(name: string, api: any) {
/**
* Recupera un servicio registrado bajo un "scope".
*
* getService("customers:repository")
* Debe declarar: dependencies: ["customers"]
*
* El "scope" puede ser "self" para recuperar
* los servicios propios registrados.
*
* getService("self:repository")
*/
export function getServiceScoped<T = any>(
requesterModule: string,
allowedDeps: readonly string[],
name: string
): T {
const [serviceModule] = name.split(":");
const [serviceModule, ...key] = name.split(":");
if (serviceModule === "self") {
return getService<T>(`${requesterModule}:${key.join(":")}`);
}
if (!allowedDeps.includes(serviceModule)) {
throw new Error(

View File

@ -1,50 +0,0 @@
/**
*
* @param obj - El objeto a evaluar.
* @template T - El tipo del objeto.
* @description Verifica si un objeto no tiene campos con valor undefined.
*
* Esta función recorre los valores del objeto y devuelve true si todos los valores son diferentes de undefined.
* Si al menos un valor es undefined, devuelve false.
*
* @example
* const obj = { a: 1, b: 'test', c: null };
* console.log(hasNoUndefinedFields(obj)); // true
*
* const objWithUndefined = { a: 1, b: undefined, c: null };
* console.log(hasNoUndefinedFields(objWithUndefined)); // false
*
* @template T - El tipo del objeto.
* @param obj - El objeto a evaluar.
* @returns true si el objeto no tiene campos undefined, false en caso contrario.
*/
export function hasNoUndefinedFields<T extends Record<string, unknown>>(
obj: T
): obj is { [K in keyof T]-?: Exclude<T[K], undefined> } {
return Object.values(obj).every((value) => value !== undefined);
}
/**
*
* @description Verifica si un objeto tiene campos con valor undefined.
* Esta función es el complemento de `hasNoUndefinedFields`.
*
* @example
* const obj = { a: 1, b: 'test', c: null };
* console.log(hasUndefinedFields(obj)); // false
*
* const objWithUndefined = { a: 1, b: undefined, c: null };
* console.log(hasUndefinedFields(objWithUndefined)); // true
*
* @template T - El tipo del objeto.
* @param obj - El objeto a evaluar.
* @returns true si el objeto tiene al menos un campo undefined, false en caso contrario.
*
*/
export function hasUndefinedFields<T extends Record<string, unknown>>(
obj: T
): obj is { [K in keyof T]-?: Exclude<T[K], undefined> } {
return !hasNoUndefinedFields(obj);
}

View File

@ -1,85 +0,0 @@
import {
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CreateCustomerInvoiceRequestDTO } from "../../../common";
import {
IssuedInvoiceItem,
type IssuedInvoiceItemProps,
ItemAmount,
ItemDescription,
ItemDiscountPercentage,
ItemQuantity,
} from "../../domain";
import { hasNoUndefinedFields } from "./has-no-undefined-fields";
export function mapDTOToCustomerInvoiceItemsProps(
dtoItems: Pick<CreateCustomerInvoiceRequestDTO, "items">["items"]
): Result<IssuedInvoiceItem[], ValidationErrorCollection> {
const errors: ValidationErrorDetail[] = [];
const items: IssuedInvoiceItem[] = [];
dtoItems.forEach((item, index) => {
const path = (field: string) => `items[${index}].${field}`;
const description = extractOrPushError(
maybeFromNullableResult(item.description, (value) => ItemDescription.create(value)),
path("description"),
errors
);
const quantity = extractOrPushError(
maybeFromNullableResult(item.quantity, (value) => ItemQuantity.create({ value })),
path("quantity"),
errors
);
const unitAmount = extractOrPushError(
maybeFromNullableResult(item.unit_amount, (value) => ItemAmount.create({ value })),
path("unit_amount"),
errors
);
const discountPercentage = extractOrPushError(
maybeFromNullableResult(item.discount_percentage, (value) =>
ItemDiscountPercentage.create({ value })
),
path("discount_percentage"),
errors
);
if (errors.length === 0) {
const itemProps: IssuedInvoiceItemProps = {
description: description,
quantity: quantity,
unitAmount: unitAmount,
itemDiscountPercentage: discountPercentage,
//currencyCode,
//languageCode,
//taxes:
};
if (hasNoUndefinedFields(itemProps)) {
// Validar y crear el item de factura
const itemOrError = IssuedInvoiceItem.create(itemProps);
if (itemOrError.isSuccess) {
items.push(itemOrError.data);
} else {
errors.push({ path: `items[${index}]`, message: itemOrError.error.message });
}
}
}
if (errors.length > 0) {
return Result.fail(new ValidationErrorCollection("Invoice items dto mapping failed", errors));
}
});
return Result.ok(items);
}

View File

@ -1,98 +0,0 @@
import {
CurrencyCode,
UniqueID,
UtcDate,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CreateCustomerInvoiceRequestDTO } from "../../../common";
import {
type IProformaCreateProps,
InvoiceNumber,
InvoiceSerie,
InvoiceStatus,
} from "../../domain";
import { mapDTOToCustomerInvoiceItemsProps } from "./map-dto-to-customer-invoice-items-props";
/**
* Convierte el DTO a las props validadas (CustomerInvoiceProps).
* No construye directamente el agregado.
*
* @param dto - DTO con los datos de la factura de cliente
* @returns
*
*/
export function mapDTOToCustomerInvoiceProps(dto: CreateCustomerInvoiceRequestDTO) {
const errors: ValidationErrorDetail[] = [];
const invoiceId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
const invoiceNumber = extractOrPushError(
maybeFromNullableResult(dto.invoice_number, (value) => InvoiceNumber.create(value)),
"invoice_number",
errors
);
const invoiceSeries = extractOrPushError(
maybeFromNullableResult(dto.series, (value) => InvoiceSerie.create(value)),
"invoice_series",
errors
);
const invoiceDate = extractOrPushError(
UtcDate.createFromISO(dto.invoice_date),
"invoice_date",
errors
);
const operationDate = extractOrPushError(
maybeFromNullableResult(dto.operation_date, (value) => UtcDate.createFromISO(value)),
"operation_date",
errors
);
const currencyCode = extractOrPushError(
CurrencyCode.create(dto.currency_code),
"currency",
errors
);
// 🔄 Validar y construir los items de factura con helper especializado
const itemsResult = mapDTOToCustomerInvoiceItemsProps(dto.items);
if (itemsResult.isFailure) {
return Result.fail(itemsResult.error);
}
if (errors.length > 0) {
return Result.fail(new ValidationErrorCollection("Invoice dto mapping failed", errors));
}
const invoiceProps: IProformaCreateProps = {
invoiceNumber: invoiceNumber!,
series: invoiceSeries!,
invoiceDate: invoiceDate!,
operationDate: operationDate!,
status: InvoiceStatus.fromDraft(),
currencyCode: currencyCode!,
};
return Result.ok({ id: invoiceId!, props: invoiceProps });
/*if (hasNoUndefinedFields(invoiceProps)) {
const invoiceOrError = CustomerInvoice.create(invoiceProps, invoiceId);
if (invoiceOrError.isFailure) {
return Result.fail(invoiceOrError.error);
}
return Result.ok(invoiceOrError.data);
}
return Result.fail(
new ValidationErrorCollection([
{ path: "", message: "Error building from DTO: Some fields are undefined" },
])
);*/
}

View File

@ -1,3 +1,5 @@
export * from "./issued-invoice-creator.di";
export * from "./issued-invoice-finder.di";
export * from "./issued-invoice-snapshot-builders.di";
export * from "./issued-invoice-use-cases.di";
export * from "./proforma-to-issued-invoice-props-converter.di";

View File

@ -0,0 +1,18 @@
import type { IIssuedInvoiceRepository } from "../repositories";
import {
type IIssuedInvoiceCreator,
type IIssuedInvoiceNumberGenerator,
IssuedInvoiceCreator,
} from "../services";
export function buildIssuedInvoiceCreator(params: {
numberService: IIssuedInvoiceNumberGenerator;
repository: IIssuedInvoiceRepository;
}): IIssuedInvoiceCreator {
const { numberService, repository } = params;
return new IssuedInvoiceCreator({
repository,
numberService,
});
}

View File

@ -0,0 +1,8 @@
import {
type IProformaToIssuedInvoiceConverter,
ProformaToIssuedInvoiceConverter,
} from "../services";
export function buildProformaToIssuedInvoicePropsConverter(): IProformaToIssuedInvoiceConverter {
return new ProformaToIssuedInvoiceConverter();
}

View File

@ -1,5 +1,8 @@
export * from "./issued-invoice-creator";
export * from "./issued-invoice-document-generator.interface";
export * from "./issued-invoice-document-metadata-factory";
export * from "./issued-invoice-document-properties-factory";
export * from "./issued-invoice-finder";
export * from "./proforma-to-issued-invoice-materializer";
export * from "./issued-invoice-number-generator.interface";
export * from "./issued-invoice-public-services.interface";
export * from "./proforma-to-issued-invoice-props-converter";

View File

@ -0,0 +1,64 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { type IIssuedInvoiceCreateProps, IssuedInvoice } from "../../../domain";
import type { IIssuedInvoiceRepository } from "../repositories";
import type { IIssuedInvoiceNumberGenerator } from "./issued-invoice-number-generator.interface";
export interface IIssuedInvoiceCreatorParams {
companyId: UniqueID;
id: UniqueID;
props: Omit<IIssuedInvoiceCreateProps, "invoiceNumber">;
transaction: unknown;
}
export type IIssuedInvoiceCreator = {
create(params: IIssuedInvoiceCreatorParams): Promise<Result<IssuedInvoice, Error>>;
};
type IssuedInvoiceCreatorDeps = {
numberService: IIssuedInvoiceNumberGenerator;
repository: IIssuedInvoiceRepository;
};
export class IssuedInvoiceCreator implements IIssuedInvoiceCreator {
private readonly numberService: IIssuedInvoiceNumberGenerator;
private readonly repository: IIssuedInvoiceRepository;
constructor(deps: IssuedInvoiceCreatorDeps) {
this.numberService = deps.numberService;
this.repository = deps.repository;
}
async create(params: IIssuedInvoiceCreatorParams): Promise<Result<IssuedInvoice, Error>> {
const { companyId, id, props, transaction } = params;
// 1. Obtener siguiente número
const { series } = props;
const numberResult = await this.numberService.getNextForCompany(companyId, series, transaction);
if (numberResult.isFailure) {
return Result.fail(numberResult.error);
}
const invoiceNumber = numberResult.data;
const invoiceResult = IssuedInvoice.create({ ...props, invoiceNumber, companyId }, id);
if (invoiceResult.isFailure) {
return Result.fail(invoiceResult.error);
}
const invoice = invoiceResult.data;
// 3. Persistir
const saveResult = await this.repository.create(invoice, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}
return Result.ok(invoice);
}
}

View File

@ -1,6 +1,6 @@
import type { DocumentGenerationService } from "@erp/core/api";
import type { IssuedInvoiceReportSnapshot } from "../application-models";
import type { IIssuedInvoiceReportSnapshot } from "../snapshot-builders/report";
export interface IssuedInvoiceDocumentGeneratorService
extends DocumentGenerationService<IssuedInvoiceReportSnapshot> {}
extends DocumentGenerationService<IIssuedInvoiceReportSnapshot> {}

View File

@ -1,6 +1,6 @@
import type { IDocumentMetadata, IDocumentMetadataFactory } from "@erp/core/api";
import type { IssuedInvoiceReportSnapshot } from "../application-models";
import type { IIssuedInvoiceReportSnapshot } from "../snapshot-builders";
/**
* Construye los metadatos del documento PDF de una factura emitida.
@ -10,9 +10,9 @@ import type { IssuedInvoiceReportSnapshot } from "../application-models";
* - Sin IO
*/
export class IssuedInvoiceDocumentMetadataFactory
implements IDocumentMetadataFactory<IssuedInvoiceReportSnapshot>
implements IDocumentMetadataFactory<IIssuedInvoiceReportSnapshot>
{
build(snapshot: IssuedInvoiceReportSnapshot): IDocumentMetadata {
build(snapshot: IIssuedInvoiceReportSnapshot): IDocumentMetadata {
if (!snapshot.id) {
throw new Error("IssuedInvoiceReportSnapshot.id is required");
}
@ -33,12 +33,12 @@ export class IssuedInvoiceDocumentMetadataFactory
};
}
private buildFilename(snapshot: IssuedInvoiceReportSnapshot): string {
private buildFilename(snapshot: IIssuedInvoiceReportSnapshot): string {
// Ejemplo: factura-F2024-000123-FULANITO.pdf
return `factura-${snapshot.series}${snapshot.invoice_number}-${snapshot.recipient.name}.pdf`;
}
private buildCacheKey(snapshot: IssuedInvoiceReportSnapshot): string {
private buildCacheKey(snapshot: IIssuedInvoiceReportSnapshot): string {
// Versionado explícito para invalidaciones futuras
return [
"issued-invoice",

View File

@ -1,7 +1,6 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { IssuedInvoice } from "../../../domain";
import type { IssuedInvoiceSummary } from "../models";
@ -11,19 +10,19 @@ export interface IIssuedInvoiceFinder {
findIssuedInvoiceById(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<IssuedInvoice, Error>>;
issuedInvoiceExists(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<boolean, Error>>;
findIssuedInvoicesByCriteria(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
transaction?: unknown
): Promise<Result<Collection<IssuedInvoiceSummary>, Error>>;
}
@ -33,7 +32,7 @@ export class IssuedInvoiceFinder implements IIssuedInvoiceFinder {
async findIssuedInvoiceById(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<IssuedInvoice, Error>> {
return this.repository.getByIdInCompany(companyId, invoiceId, transaction);
}
@ -41,7 +40,7 @@ export class IssuedInvoiceFinder implements IIssuedInvoiceFinder {
async issuedInvoiceExists(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, invoiceId, transaction);
}
@ -49,7 +48,7 @@ export class IssuedInvoiceFinder implements IIssuedInvoiceFinder {
async findIssuedInvoicesByCriteria(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
transaction?: unknown
): Promise<Result<Collection<IssuedInvoiceSummary>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
}

View File

@ -0,0 +1,19 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { InvoiceNumber, InvoiceSerie } from "../../../domain";
export interface IIssuedInvoiceNumberGenerator {
/**
* Devuelve el siguiente número de factura disponible para una empresa dentro de una "serie" de factura.
*
* @param companyId - Identificador de la empresa
* @param serie - Serie por la que buscar la última factura
* @param transaction - Transacción activa
*/
getNextForCompany(
companyId: UniqueID,
series: Maybe<InvoiceSerie>,
transaction: unknown
): Promise<Result<InvoiceNumber, Error>>;
}

View File

@ -1,27 +0,0 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { ICustomerInvoiceNumberGenerator, InvoiceNumber, InvoiceSerie } from "../../../domain";
export interface IIssuedInvoiceNumberService {
/**
* Devuelve el siguiente número disponible para una factura emitida.
*/
nextIssuedInvoiceNumber(
companyId: UniqueID,
series: Maybe<InvoiceSerie>,
transaction: unknown
): Promise<Result<InvoiceNumber, Error>>;
}
export class IssuedInvoiceNumberService implements IIssuedInvoiceNumberService {
constructor(private readonly numberGenerator: ICustomerInvoiceNumberGenerator) {}
async nextIssuedInvoiceNumber(
companyId: UniqueID,
series: Maybe<InvoiceSerie>,
transaction: unknown
): Promise<Result<InvoiceNumber, Error>> {
return this.numberGenerator.nextForCompany(companyId, series, transaction);
}
}

View File

@ -0,0 +1,19 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { IIssuedInvoiceCreatorParams } from ".";
import type { IssuedInvoice } from "../../../domain";
export interface IIssuedInvoiceServicesContext {
transaction: unknown;
companyId: UniqueID;
}
export interface IIssuedInvoicePublicServices {
createIssuedInvoice: (
id: UniqueID,
props: IIssuedInvoiceCreatorParams["props"],
context: IIssuedInvoiceServicesContext
) => Promise<Result<IssuedInvoice, Error>>;
}

View File

@ -1,32 +0,0 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { CustomerInvoice } from "../../../domain/aggregates";
import type { ICustomerInvoiceRepository } from "../../../domain/repositories";
export type IIssuedInvoiceWriteService = {};
export class IssuedInvoiceWriteService implements IIssuedInvoiceWriteService {
constructor(private readonly repository: ICustomerInvoiceRepository) {}
/**
* Emite (crea) una factura definitiva a partir de una factura ya preparada.
*
* Asume que:
* - el número ya ha sido asignado
* - el estado es correcto
*/
async createIssuedInvoice(
companyId: UniqueID,
invoice: CustomerInvoice,
transaction: Transaction
): Promise<Result<CustomerInvoice, Error>> {
const result = await this.repository.create(invoice, transaction);
if (result.isFailure) {
return Result.fail(result.error);
}
return result;
}
}

View File

@ -1,56 +0,0 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import type { IIssuedInvoiceProps, Proforma } from "../../../domain";
export interface IProformaToIssuedInvoiceMaterializer {
materialize(proforma: Proforma, issuedInvoiceId: UniqueID): Result<IIssuedInvoiceProps, Error>;
}
export class ProformaToIssuedInvoiceMaterializer implements IProformaToIssuedInvoiceMaterializer {
public materialize(
proforma: Proforma,
issuedInvoiceId: UniqueID
): Result<IIssuedInvoiceProps, Error> {
const amounts = proforma.calculateAllAmounts();
const taxGroups = proforma.getTaxes();
const issuedItems = proforma.items.map((item) => ({
description: item.description,
quantity: item.quantity,
unitPrice: item.unitAmount,
taxableAmount: item.getTaxableAmount(),
taxesAmount: item.getTaxesAmount(),
totalAmount: item.getTotalAmount(),
}));
const issuedTaxes = taxGroups.map((group) => ({
ivaCode: group.iva.code,
ivaPercentage: group.iva.percentage,
ivaAmount: group.calculateAmounts().ivaAmount,
recCode: group.rec?.code,
recPercentage: group.rec?.percentage,
recAmount: group.calculateAmounts().recAmount,
retentionCode: group.retention?.code,
retentionPercentage: group.retention?.percentage,
retentionAmount: group.calculateAmounts().retentionAmount,
}));
return Result.ok({
companyId: proforma.companyId,
invoiceNumber: proforma.invoiceNumber,
invoiceDate: proforma.invoiceDate,
customerId: proforma.customerId,
languageCode: proforma.languageCode,
currencyCode: proforma.currencyCode,
paymentMethod: proforma.paymentMethod,
discountPercentage: proforma.globalDiscountPercentage,
items: new Collection(issuedItems),
taxes: new Collection(issuedTaxes),
subtotalAmount: amounts.subtotalAmount,
taxableAmount: amounts.taxableAmount,
totalAmount: amounts.totalAmount,
});
}
}

View File

@ -0,0 +1,149 @@
import { Maybe, Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceCreateProps,
InvoiceStatus,
IssuedInvoiceItem,
IssuedInvoiceTax,
IssuedInvoiceTaxes,
type Proforma,
} from "../../../domain";
export interface IProformaToIssuedInvoiceConverter {
toCreateProps(proforma: Proforma): Result<IIssuedInvoiceCreateProps, Error>;
}
export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoiceConverter {
public toCreateProps(proforma: Proforma): Result<IIssuedInvoiceCreateProps, Error> {
const proformaTotals = proforma.totals();
const taxTotals = proforma.taxes();
const issuedItems: IssuedInvoiceItem[] = [];
for (const item of proforma.items.getAll()) {
const itemTotals = item.totals();
const itemOrResult = IssuedInvoiceItem.create({
description: item.description,
quantity: item.quantity,
unitAmount: item.unitAmount,
currencyCode: proforma.currencyCode,
languageCode: proforma.languageCode,
itemDiscountPercentage: item.itemDiscountPercentage,
itemDiscountAmount: itemTotals.itemDiscountAmount,
globalDiscountPercentage: item.globalDiscountPercentage,
globalDiscountAmount: itemTotals.globalDiscountAmount,
totalDiscountAmount: itemTotals.totalDiscountAmount,
ivaCode: item.ivaCode(),
ivaPercentage: item.ivaPercentage(),
ivaAmount: itemTotals.ivaAmount,
recCode: item.recCode(),
recPercentage: item.recPercentage(),
recAmount: itemTotals.recAmount,
retentionCode: item.retentionCode(),
retentionPercentage: item.retentionPercentage(),
retentionAmount: itemTotals.recAmount,
subtotalAmount: itemTotals.subtotalAmount,
taxableAmount: itemTotals.taxableAmount,
taxesAmount: itemTotals.taxesAmount,
totalAmount: itemTotals.totalAmount,
});
if (itemOrResult.isFailure) {
return Result.fail(itemOrResult.error);
}
issuedItems.push(itemOrResult.data);
}
const issuedTaxes: IssuedInvoiceTax[] = [];
for (const tax of taxTotals.getAll()) {
if (tax.ivaCode.isNone()) {
return Result.fail(new Error("IVA code is required"));
}
const taxOrResult = IssuedInvoiceTax.create({
taxableAmount: tax.taxableAmount,
ivaCode: tax.ivaCode.unwrap(),
ivaPercentage: tax.ivaPercentage.unwrap(),
ivaAmount: tax.ivaAmount,
recCode: tax.recCode,
recPercentage: tax.recPercentage,
recAmount: tax.recAmount,
retentionCode: tax.retentionCode,
retentionAmount: tax.retentionAmount,
retentionPercentage: tax.retentionPercentage,
taxesAmount: tax.taxesAmount,
});
if (taxOrResult.isFailure) {
return Result.fail(taxOrResult.error);
}
issuedTaxes.push(taxOrResult.data);
}
const issuedInvoiceProps: IIssuedInvoiceCreateProps = {
companyId: proforma.companyId,
status: InvoiceStatus.issued(),
series: proforma.series,
proformaId: proforma.id,
invoiceNumber: proforma.invoiceNumber,
invoiceDate: proforma.invoiceDate,
operationDate: proforma.operationDate,
description: proforma.description,
languageCode: proforma.languageCode,
currencyCode: proforma.currencyCode,
notes: proforma.notes,
reference: proforma.reference,
paymentMethod: proforma.paymentMethod,
customerId: proforma.customerId,
recipient: proforma.recipient,
items: issuedItems,
taxes: IssuedInvoiceTaxes.create({
currencyCode: proforma.currencyCode,
languageCode: proforma.languageCode,
taxes: issuedTaxes,
}),
subtotalAmount: proformaTotals.subtotalAmount,
itemsDiscountAmount: proformaTotals.itemsDiscountAmount,
globalDiscountPercentage: proforma.globalDiscountPercentage,
globalDiscountAmount: proformaTotals.globalDiscountAmount,
totalDiscountAmount: proformaTotals.totalDiscountAmount,
taxableAmount: proformaTotals.taxableAmount,
ivaAmount: proformaTotals.ivaAmount,
recAmount: proformaTotals.recAmount,
retentionAmount: proformaTotals.retentionAmount,
taxesAmount: proformaTotals.taxesAmount,
totalAmount: proformaTotals.totalAmount,
verifactu: Maybe.none(),
};
return Result.ok(issuedInvoiceProps);
}
}

View File

@ -1,2 +1,3 @@
export * from "./full";
export * from "./report";
export * from "./summary";

View File

@ -27,10 +27,10 @@ export class IssuedInvoiceReportItemSnapshotBuilder
quantity: QuantityDTOHelper.format(item.quantity, locale, { minimumFractionDigits: 0 }),
unit_amount: MoneyDTOHelper.format(item.unit_amount, locale, moneyOptions),
subtotal_amount: MoneyDTOHelper.format(item.subtotal_amount, locale, moneyOptions),
discount_percentage: PercentageDTOHelper.format(item.discount_percentage, locale, {
discount_percentage: PercentageDTOHelper.format(item.item_discount_percentage, locale, {
minimumFractionDigits: 0,
}),
discount_amount: MoneyDTOHelper.format(item.discount_amount, locale, moneyOptions),
discount_amount: MoneyDTOHelper.format(item.item_discount_amount, locale, moneyOptions),
taxable_amount: MoneyDTOHelper.format(item.taxable_amount, locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(item.taxes_amount, locale, moneyOptions),
total_amount: MoneyDTOHelper.format(item.total_amount, locale, moneyOptions),

View File

@ -2,7 +2,6 @@ import type { ITransactionManager } from "@erp/core/api";
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { IIssuedInvoiceFinder } from "../services";
import type { IIssuedInvoiceSummarySnapshotBuilder } from "../snapshot-builders";
@ -22,7 +21,7 @@ export class ListIssuedInvoicesUseCase {
public execute(params: ListIssuedInvoicesUseCaseInput) {
const { criteria, companyId } = params;
return this.transactionManager.complete(async (transaction: Transaction) => {
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const result = await this.finder.findIssuedInvoicesByCriteria(
companyId,

View File

@ -1,5 +1,6 @@
export * from "./proforma-creator.di";
export * from "./proforma-finder.di";
export * from "./proforma-input-mappers.di";
export * from "./proforma-issuer.di";
export * from "./proforma-snapshot-builders.di";
export * from "./proforma-use-cases.di";

View File

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

View File

@ -1,9 +1,11 @@
import type { ITransactionManager } from "@erp/core/api";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { ICreateProformaInputMapper } from "../mappers";
import type {
IProformaCreator,
IProformaFinder,
IProformaIssuer,
ProformaDocumentGeneratorService,
} from "../services";
import type {
@ -11,9 +13,13 @@ import type {
IProformaSummarySnapshotBuilder,
} from "../snapshot-builders";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders/full";
import { GetProformaByIdUseCase, ListProformasUseCase, ReportProformaUseCase } from "../use-cases";
import { CreateProformaUseCase } from "../use-cases/create-proforma";
import { IssueProformaUseCase } from "../use-cases/issue-proforma.use-case";
import {
CreateProformaUseCase,
GetProformaByIdUseCase,
IssueProformaUseCase,
ListProformasUseCase,
ReportProformaUseCase,
} from "../use-cases";
export function buildGetProformaByIdUseCase(deps: {
finder: IProformaFinder;
@ -65,8 +71,26 @@ export function buildCreateProformaUseCase(deps: {
});
}
export function buildIssueProformaUseCase(deps: { finder: IProformaFinder }) {
return new IssueProformaUseCase(deps.finder);
export function buildIssueProformaUseCase(deps: {
publicServices: {
issuedInvoiceServices: IIssuedInvoicePublicServices;
};
finder: IProformaFinder;
issuer: IProformaIssuer;
transactionManager: ITransactionManager;
}) {
const {
finder,
issuer,
transactionManager,
publicServices: { issuedInvoiceServices },
} = deps;
return new IssueProformaUseCase({
issuedInvoiceServices,
finder,
issuer,
transactionManager,
});
}
/*export function buildUpdateProformaUseCase(deps: {

View File

@ -69,7 +69,7 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
const { companyId } = params;
try {
const defaultStatus = InvoiceStatus.fromDraft();
const defaultStatus = InvoiceStatus.draft();
const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", errors);

View File

@ -5,3 +5,4 @@ export * from "./proforma-document-properties-factory";
export * from "./proforma-finder";
export * from "./proforma-issuer";
export * from "./proforma-number-generator.interface";
export * from "./proforma-public-services.interface";

View File

@ -1,6 +1,5 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import { type IProformaCreateProps, Proforma } from "../../../domain";
import type { IProformaRepository } from "../repositories";
@ -11,7 +10,7 @@ export interface IProformaCreatorParams {
companyId: UniqueID;
id: UniqueID;
props: Omit<IProformaCreateProps, "invoiceNumber">;
transaction: Transaction;
transaction: unknown;
}
export interface IProformaCreator {
@ -32,12 +31,7 @@ export class ProformaCreator implements IProformaCreator {
this.repository = deps.repository;
}
async create(params: {
companyId: UniqueID;
id: UniqueID;
props: IProformaCreateProps;
transaction: Transaction;
}): Promise<Result<Proforma, Error>> {
async create(params: IProformaCreatorParams): Promise<Result<Proforma, Error>> {
const { companyId, id, props, transaction } = params;
// 1. Obtener siguiente número

View File

@ -1,7 +1,6 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { Proforma } from "../../../domain";
import type { ProformaSummary } from "../models";
@ -11,19 +10,19 @@ export interface IProformaFinder {
findProformaById(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<Proforma, Error>>;
proformaExists(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<boolean, Error>>;
findProformasByCriteria(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
transaction?: unknown
): Promise<Result<Collection<ProformaSummary>, Error>>;
}
@ -33,7 +32,7 @@ export class ProformaFinder implements IProformaFinder {
async findProformaById(
companyId: UniqueID,
proformaId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<Proforma, Error>> {
return this.repository.getByIdInCompany(companyId, proformaId, transaction);
}
@ -41,7 +40,7 @@ export class ProformaFinder implements IProformaFinder {
async proformaExists(
companyId: UniqueID,
proformaId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, proformaId, transaction);
}
@ -49,7 +48,7 @@ export class ProformaFinder implements IProformaFinder {
async findProformasByCriteria(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
transaction?: unknown
): Promise<Result<Collection<ProformaSummary>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
}

View File

@ -1,44 +1,65 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { IProformaToIssuedInvoiceMaterializer } from "../../issued-invoices";
import type { IIssuedInvoiceCreateProps, Proforma } from "../../../domain";
import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices";
import type { IProformaRepository } from "../repositories";
export interface IProformaIssuerParams {
companyId: UniqueID;
proforma: Proforma;
issuedInvoiceId: UniqueID;
transaction: unknown;
}
export interface IProformaIssuer {
issueProforma(params: IProformaIssuerParams): Promise<Result<IIssuedInvoiceCreateProps, Error>>;
}
type ProformaIssuerDeps = {
proformaConverter: IProformaToIssuedInvoiceConverter;
repository: IProformaRepository;
};
export class ProformaIssuer implements IProformaIssuer {
private readonly proformaRepository: IProformaRepository;
private readonly issuedInvoiceFactory: IIssuedInvoiceFactory;
private readonly issuedInvoiceRepository: IIssuedInvoiceRepository;
private readonly materializer: IProformaToIssuedInvoiceMaterializer;
private readonly proformaConverter: IProformaToIssuedInvoiceConverter;
private readonly repository: IProformaRepository;
constructor(deps: ProformaIssuerDeps) {
this.proformaRepository = deps.proformaRepository;
this.issuedInvoiceFactory = deps.issuedInvoiceFactory;
this.issuedInvoiceRepository = deps.issuedInvoiceRepository;
this.materializer = deps.materializer;
this.proformaConverter = deps.proformaConverter;
this.repository = deps.repository;
}
public async issue(
proforma: Proforma,
issuedInvoiceId: UniqueID,
transaction: Transaction
): Promise<Result<Proforma, Error>> {
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);
const propsResult = this.materializer.materialize(proforma, issuedInvoiceId);
if (propsResult.isFailure) return Result.fail(propsResult.error);
const invoiceResult = this.issuedInvoiceFactory.create(propsResult.data, issuedInvoiceId);
if (invoiceResult.isFailure) {
return Result.fail(invoiceResult.error);
if (issueResult.isFailure) {
return Result.fail(issueResult.error);
}
await this.issuedInvoiceRepository.save(proforma.companyId, invoiceResult.data, transaction);
// Persistir
const updateStatusResult = await this.repository.updateStatusByIdInCompany(
companyId,
proforma.id,
proforma.status,
transaction
);
await this.proformaRepository.save(proforma.companyId, proforma, transaction);
if (updateStatusResult.isFailure) {
return Result.fail(updateStatusResult.error);
}
return Result.ok(proforma);
// 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);
}
}

View File

@ -7,10 +7,10 @@ import type { Maybe, Result } from "@repo/rdx-utils";
*/
export interface IProformaNumberGenerator {
/**
* Devuelve el siguiente número de factura disponible para una empresa dentro de una "serie" de factura.
* Devuelve el siguiente número de proforma disponible para una empresa dentro de una "serie" de proforma.
*
* @param companyId - Identificador de la empresa
* @param serie - Serie por la que buscar la última factura
* @param serie - Serie por la que buscar la última proforma
* @param transaction - Transacción activa
*/
getNextForCompany(

View File

@ -0,0 +1,34 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { Proforma } from "../../../domain/proformas";
import type { IProformaFullSnapshot } from "../snapshot-builders";
import type { IProformaCreatorParams } from "./proforma-creator";
export interface IProformaServicesContext {
transaction: unknown;
companyId: UniqueID;
}
export interface IProformaPublicServices {
createProforma: (
id: UniqueID,
props: IProformaCreatorParams["props"],
context: IProformaServicesContext
) => Promise<Result<Proforma, Error>>;
listProformas: (filters: unknown, context: unknown) => null;
getProformaById: (
id: UniqueID,
context: IProformaServicesContext
) => Promise<Result<Proforma, Error>>;
getProformaSnapshotById: (
id: UniqueID,
context: IProformaServicesContext
) => Promise<Result<IProformaFullSnapshot, Error>>;
generateProformaReport: (id: unknown, options: unknown, context: unknown) => null;
}

View File

@ -1,134 +0,0 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import {
CustomerInvoiceIsProformaSpecification,
type InvoiceStatus,
ProformaCannotBeDeletedError,
StatusInvoiceIsDraftSpecification,
} from "../../../domain";
import type {
CustomerInvoice,
CustomerInvoicePatchProps,
CustomerInvoiceProps,
} from "../../../domain/aggregates";
import type { ICustomerInvoiceRepository } from "../../../domain/repositories";
import type { IProformaFactory } from "../../services/proforma-factory";
export type IIssuedInvoiceWriteService = {};
export class IssuedInvoiceWriteService implements IIssuedInvoiceWriteService {
constructor(
private readonly repository: ICustomerInvoiceRepository,
private readonly proformaFactory: IProformaFactory
) {}
/**
* Crea y persiste una nueva proforma para una empresa.
*/
async createProforma(
companyId: UniqueID,
props: Omit<CustomerInvoiceProps, "companyId">,
transaction: Transaction
): Promise<Result<CustomerInvoice, Error>> {
const invoiceResult = this.proformaFactory.createProforma(companyId, props);
if (invoiceResult.isFailure) {
return Result.fail(invoiceResult.error);
}
return this.repository.create(invoiceResult.data, transaction);
}
/**
* Actualiza una proforma existente.
*/
async updateProforma(
companyId: UniqueID,
proforma: CustomerInvoice,
transaction: Transaction
): Promise<Result<CustomerInvoice, Error>> {
return this.repository.update(proforma, transaction);
}
/**
* Aplica cambios parciales a una proforma existente.
* No persiste automáticamente.
*/
async patchProforma(
companyId: UniqueID,
proformaId: UniqueID,
changes: CustomerInvoicePatchProps,
transaction: Transaction
): Promise<Result<CustomerInvoice, Error>> {
const proformaResult = await this.repository.getProformaByIdInCompany(
companyId,
proformaId,
transaction,
{}
);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const updated = proformaResult.data.update(changes);
if (updated.isFailure) {
return Result.fail(updated.error);
}
return Result.ok(updated.data);
}
/**
* Elimina (baja lógica) una proforma.
*/
async deleteProforma(
companyId: UniqueID,
proformaId: UniqueID,
transaction: Transaction
): Promise<Result<boolean, Error>> {
const proformaResult = await this.repository.getProformaByIdInCompany(
companyId,
proformaId,
transaction,
{}
);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const proforma = proformaResult.data;
const isProforma = new CustomerInvoiceIsProformaSpecification();
if (!(await isProforma.isSatisfiedBy(proforma))) {
return Result.fail(new ProformaCannotBeDeletedError(proformaId.toString(), "not a proforma"));
}
const isDraft = new StatusInvoiceIsDraftSpecification();
if (!(await isDraft.isSatisfiedBy(proforma))) {
return Result.fail(
new ProformaCannotBeDeletedError(proformaId.toString(), "status is not 'draft'")
);
}
return this.repository.deleteProformaByIdInCompany(companyId, proformaId, transaction);
}
/**
* Actualiza el estado de una proforma.
*/
async updateProformaStatus(
companyId: UniqueID,
proformaId: UniqueID,
newStatus: InvoiceStatus,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
return this.repository.updateProformaStatusByIdInCompany(
companyId,
proformaId,
newStatus,
transaction
);
}
}

View File

@ -9,8 +9,8 @@ export interface IProformaItemFullSnapshot {
subtotal_amount: { value: string; scale: string; currency_code: string };
discount_percentage: { value: string; scale: string };
discount_amount: { value: string; scale: string; currency_code: string };
item_discount_percentage: { value: string; scale: string };
item_discount_amount: { value: string; scale: string; currency_code: string };
global_discount_percentage: { value: string; scale: string };
global_discount_amount: { value: string; scale: string; currency_code: string };

View File

@ -32,8 +32,10 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps
? allAmounts.subtotalAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT,
discount_percentage: maybeToEmptyPercentageObjectString(proformaItem.itemDiscountPercentage),
discount_amount: isValued
item_discount_percentage: maybeToEmptyPercentageObjectString(
proformaItem.itemDiscountPercentage
),
item_discount_amount: isValued
? allAmounts.itemDiscountAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT,

View File

@ -1,14 +1,16 @@
import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/core";
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api";
import type { ProformaFullSnapshot, ProformaReportItemSnapshot } from "../../application-models";
import type { IProformaFullSnapshot } from "../full";
import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface";
export interface IProformaItemReportSnapshotBuilder
extends ISnapshotBuilder<ProformaFullSnapshot["items"], ProformaReportItemSnapshot[]> {}
extends ISnapshotBuilder<IProformaFullSnapshot["items"], ProformaReportItemSnapshot[]> {}
export class ProformaItemReportSnapshotBuilder implements IProformaItemReportSnapshotBuilder {
toOutput(
items: ProformaFullSnapshot["items"],
items: IProformaFullSnapshot["items"],
params?: ISnapshotBuilderParams
): ProformaReportItemSnapshot[] {
const locale = params?.locale as string;

View File

@ -1,30 +1,29 @@
import { DateHelper, MoneyDTOHelper, PercentageDTOHelper } from "@erp/core";
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api";
import type {
ProformaFullSnapshot,
ProformaReportItemSnapshot,
ProformaReportSnapshot,
ProformaReportTaxSnapshot,
} from "../../application-models";
import type { IProformaFullSnapshot } from "../full";
import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface";
import type { ProformaReportSnapshot } from "./proforma-report-snapshot.interface";
import type { ProformaReportTaxSnapshot } from "./proforma-report-tax-snapshot.interface";
export interface IProformaReportSnapshotBuilder
extends ISnapshotBuilder<ProformaFullSnapshot, ProformaReportSnapshot> {}
extends ISnapshotBuilder<IProformaFullSnapshot, ProformaReportSnapshot> {}
export class ProformaReportSnapshotBuilder implements IProformaReportSnapshotBuilder {
constructor(
private readonly itemsBuilder: ISnapshotBuilder<
ProformaFullSnapshot["items"],
IProformaFullSnapshot["items"],
ProformaReportItemSnapshot[]
>,
private readonly taxesBuilder: ISnapshotBuilder<
ProformaFullSnapshot["taxes"],
IProformaFullSnapshot["taxes"],
ProformaReportTaxSnapshot[]
>
) {}
toOutput(
snapshot: ProformaFullSnapshot,
snapshot: IProformaFullSnapshot,
params?: ISnapshotBuilderParams
): ProformaReportSnapshot {
const locale = params?.locale as string;
@ -61,17 +60,37 @@ export class ProformaReportSnapshotBuilder implements IProformaReportSnapshotBui
taxes: this.taxesBuilder.toOutput(snapshot.taxes, { locale }),
subtotal_amount: MoneyDTOHelper.format(snapshot.subtotal_amount, locale, moneyOptions),
discount_percentage: PercentageDTOHelper.format(snapshot.discount_percentage, locale, {
items_discount_amount: MoneyDTOHelper.format(
snapshot.items_discount_amount,
locale,
moneyOptions
),
global_discount_percentage: PercentageDTOHelper.format(
snapshot.global_discount_percentage,
locale,
{
hideZeros: true,
}),
discount_amount: MoneyDTOHelper.format(snapshot.discount_amount, locale, moneyOptions),
}
),
global_discount_amount: MoneyDTOHelper.format(
snapshot.global_discount_amount,
locale,
moneyOptions
),
total_discount_amount: MoneyDTOHelper.format(
snapshot.total_discount_amount,
locale,
moneyOptions
),
taxable_amount: MoneyDTOHelper.format(snapshot.taxable_amount, locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(snapshot.taxes_amount, locale, moneyOptions),
total_amount: MoneyDTOHelper.format(snapshot.total_amount, locale, moneyOptions),
};
}
private formatAddress(recipient: ProformaFullSnapshot["recipient"]): string {
private formatAddress(recipient: IProformaFullSnapshot["recipient"]): string {
const lines: string[] = [];
if (recipient.street) lines.push(recipient.street);

View File

@ -27,8 +27,14 @@ export interface ProformaReportSnapshot {
taxes: ProformaReportTaxSnapshot[];
subtotal_amount: string;
discount_percentage: string;
discount_amount: string;
items_discount_amount: string;
global_discount_percentage: string;
global_discount_amount: string;
total_discount_amount: string;
taxable_amount: string;
taxes_amount: string;
total_amount: string;

View File

@ -1,7 +1,6 @@
import type { ITransactionManager } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { CreateProformaRequestDTO } from "../../../../../common";
import type { ICreateProformaInputMapper } from "../../mappers";
@ -44,7 +43,7 @@ export class CreateProformaUseCase {
const { props, id } = mappedPropsResult.data;
return this.transactionManager.complete(async (transaction: Transaction) => {
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const createResult = await this.creator.create({ companyId, id, props, transaction });

View File

@ -1,20 +1,23 @@
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
IssueCustomerInvoiceDomainService,
ProformaCustomerInvoiceDomainService,
} from "../../../domain";
import type { CustomerInvoiceApplicationService } from "../../services";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { IProformaFinder, IProformaIssuer } from "../services";
type IssueProformaUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
};
type IssueProformaUseCaseDeps = {
issuedInvoiceServices: IIssuedInvoicePublicServices;
finder: IProformaFinder;
issuer: IProformaIssuer;
transactionManager: ITransactionManager;
};
/**
* Caso de uso: Conversión de una proforma a factura definitiva.
* Caso de uso: Conversión de una issuedinvoice a factura definitiva.
*
* - Recupera la proforma
* - Valida su estado ("approved")
@ -23,29 +26,30 @@ type IssueProformaUseCaseInput = {
* - Persiste ambas dentro de la misma transacción
*/
export class IssueProformaUseCase {
private readonly issueDomainService: IssueCustomerInvoiceDomainService;
private readonly proformaDomainService: ProformaCustomerInvoiceDomainService;
private readonly issuedInvoiceServices: IIssuedInvoicePublicServices;
private readonly finder: IProformaFinder;
private readonly issuer: IProformaIssuer;
private readonly transactionManager: ITransactionManager;
constructor(
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {
this.issueDomainService = new IssueCustomerInvoiceDomainService();
this.proformaDomainService = new ProformaCustomerInvoiceDomainService();
constructor(deps: IssueProformaUseCaseDeps) {
this.issuedInvoiceServices = deps.issuedInvoiceServices;
this.finder = deps.finder;
this.issuer = deps.issuer;
this.transactionManager = deps.transactionManager;
}
public execute(params: IssueProformaUseCaseInput) {
const { proforma_id, companyId } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) return Result.fail(idOrError.error);
const proformaId = idOrError.data;
const proformaIdOrError = UniqueID.create(proforma_id);
if (proformaIdOrError.isFailure) return Result.fail(proformaIdOrError.error);
const proformaId = proformaIdOrError.data;
return this.transactionManager.complete(async (transaction) => {
try {
/** 1. Recuperamos la proforma */
const proformaResult = await this.service.getProformaByIdInCompany(
// 1. Recuperamos la issuedinvoice
const proformaResult = await this.finder.findProformaById(
companyId,
proformaId,
transaction
@ -54,49 +58,39 @@ export class IssueProformaUseCase {
if (proformaResult.isFailure) return Result.fail(proformaResult.error);
const proforma = proformaResult.data;
/** 2. Generar nueva factura */
const nextNumberResult = await this.service.getNextIssuedInvoiceNumber(
// 2. Generamos la factura definitiva y la guardamos
const issuedInvoiceId = UniqueID.generateNewID();
const createPropsOrError = await this.issuer.issueProforma({
companyId,
proforma.series,
transaction
);
if (nextNumberResult.isFailure) return Result.fail(nextNumberResult.error);
/** 4. Crear factura definitiva (dominio) */
const issuedInvoiceOrError = await this.issueDomainService.issueFromProforma(proforma, {
issueNumber: nextNumberResult.data,
issueDate: UtcDate.today(),
issuedInvoiceId,
proforma,
transaction,
});
if (issuedInvoiceOrError.isFailure) return Result.fail(issuedInvoiceOrError.error);
if (createPropsOrError.isFailure) {
return Result.fail(createPropsOrError.error);
}
/** 5. Guardar la nueva factura */
const saveInvoiceResult = await this.service.createIssuedInvoiceInCompany(
const createProps = createPropsOrError.data;
// Creamos y guardamos en persistencia la factura definitiva
const invoiceResult = await this.issuedInvoiceServices.createIssuedInvoice(
issuedInvoiceId,
createProps,
{
companyId,
issuedInvoiceOrError.data,
transaction
);
if (saveInvoiceResult.isFailure) return Result.fail(saveInvoiceResult.error);
/** 6. Actualizar la proforma */
const closedProformaResult = await this.proformaDomainService.markAsIssued(proforma);
if (closedProformaResult.isFailure) return Result.fail(closedProformaResult.error);
const closedProforma = closedProformaResult.data;
/** 7. Guardar la proforma */
await this.service.updateProformaStatusByIdInCompany(
companyId,
proformaId,
closedProforma.status,
transaction
transaction,
}
);
const invoice = saveInvoiceResult.data;
if (invoiceResult.isFailure) {
return Result.fail(invoiceResult.error);
}
const dto = {
proforma_id: proforma.id.toString(),
invoice_id: invoice.id.toString(),
customer_id: invoice.customerId.toString(),
issuedinvoice_id: issuedInvoiceId.toString(),
proforma_id: proformaId.toString(),
customer_id: proforma.customerId.toString(),
};
return Result.ok(dto);
} catch (error: unknown) {

View File

@ -2,7 +2,6 @@ import type { ITransactionManager } from "@erp/core/api";
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { IProformaFinder } from "../services";
import type { IProformaSummarySnapshotBuilder } from "../snapshot-builders";
@ -22,7 +21,7 @@ export class ListProformasUseCase {
public execute(params: ListProformasUseCaseInput) {
const { criteria, companyId } = params;
return this.transactionManager.complete(async (transaction: Transaction) => {
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const result = await this.finder.findProformasByCriteria(companyId, criteria, transaction);

View File

@ -1,7 +1,6 @@
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { UpdateProformaByIdRequestDTO } from "../../../../../common";
import type { ProformaPatchProps } from "../../../../domain";
@ -43,7 +42,7 @@ export class UpdateProformaUseCase {
const patchProps: ProformaPatchProps = patchPropsResult.data;
return this.transactionManager.complete(async (transaction: Transaction) => {
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const updatedInvoice = await this.service.patchProformaByIdInCompany(
companyId,

View File

@ -1,7 +1,6 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import {
CustomerInvoiceIsProformaSpecification,
@ -35,7 +34,7 @@ export class CustomerInvoiceApplicationService {
*/
async getNextProformaNumber(
companyId: UniqueID,
transaction: Transaction
transaction: unknown
): Promise<Result<InvoiceNumber, Error>> {
return await this.numberGenerator.nextForCompany(companyId, Maybe.none(), transaction);
}
@ -51,7 +50,7 @@ export class CustomerInvoiceApplicationService {
async getNextIssuedInvoiceNumber(
companyId: UniqueID,
series: Maybe<InvoiceSerie>,
transaction: Transaction
transaction: unknown
): Promise<Result<InvoiceNumber, Error>> {
return await this.numberGenerator.nextForCompany(companyId, series, transaction);
}
@ -83,7 +82,7 @@ export class CustomerInvoiceApplicationService {
async createIssuedInvoiceInCompany(
companyId: UniqueID,
invoice: CustomerInvoice,
transaction: Transaction
transaction: unknown
): Promise<Result<CustomerInvoice, Error>> {
const result = await this.repository.create(invoice, transaction);
if (result.isFailure) {
@ -104,7 +103,7 @@ export class CustomerInvoiceApplicationService {
async createProformaInCompany(
companyId: UniqueID,
proforma: CustomerInvoice,
transaction: Transaction
transaction: unknown
): Promise<Result<CustomerInvoice, Error>> {
const result = await this.repository.create(proforma, transaction);
if (result.isFailure) {
@ -125,7 +124,7 @@ export class CustomerInvoiceApplicationService {
async updateProformaInCompany(
companyId: UniqueID,
proforma: CustomerInvoice,
transaction: Transaction
transaction: unknown
): Promise<Result<CustomerInvoice, Error>> {
const result = await this.repository.update(proforma, transaction);
if (result.isFailure) {
@ -148,7 +147,7 @@ export class CustomerInvoiceApplicationService {
async existsProformaByIdInCompany(
companyId: UniqueID,
proformaId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, proformaId, transaction, {
is_proforma: true,
@ -168,7 +167,7 @@ export class CustomerInvoiceApplicationService {
async existsIssuedInvoiceByIdInCompany(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, invoiceId, transaction, {
is_proforma: false,
@ -186,7 +185,7 @@ export class CustomerInvoiceApplicationService {
async findProformasByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
transaction?: unknown
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
return this.repository.findProformasByCriteriaInCompany(companyId, criteria, transaction, {});
}
@ -202,7 +201,7 @@ export class CustomerInvoiceApplicationService {
async findIssuedInvoiceByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
transaction?: unknown
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
return this.repository.findIssuedInvoicesByCriteriaInCompany(
companyId,
@ -222,7 +221,7 @@ export class CustomerInvoiceApplicationService {
async getIssuedInvoiceByIdInCompany(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<CustomerInvoice>> {
return await this.repository.getIssuedInvoiceByIdInCompany(
companyId,
@ -242,7 +241,7 @@ export class CustomerInvoiceApplicationService {
async getProformaByIdInCompany(
companyId: UniqueID,
proformaId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<CustomerInvoice>> {
return await this.repository.getProformaByIdInCompany(companyId, proformaId, transaction, {});
}
@ -261,7 +260,7 @@ export class CustomerInvoiceApplicationService {
companyId: UniqueID,
proformaId: UniqueID,
changes: CustomerInvoicePatchProps,
transaction?: Transaction
transaction?: unknown
): Promise<Result<CustomerInvoice, Error>> {
const proformaResult = await this.getProformaByIdInCompany(companyId, proformaId, transaction);
@ -289,7 +288,7 @@ export class CustomerInvoiceApplicationService {
async deleteProformaByIdInCompany(
companyId: UniqueID,
proformaId: UniqueID,
transaction?: Transaction
transaction?: unknown
): Promise<Result<boolean, Error>> {
// 1) Buscar la proforma
const proformaResult = await this.getProformaByIdInCompany(companyId, proformaId, transaction);
@ -329,7 +328,7 @@ export class CustomerInvoiceApplicationService {
companyId: UniqueID,
proformaId: UniqueID,
newStatus: InvoiceStatus,
transaction?: Transaction
transaction?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.updateProformaStatusByIdInCompany(
companyId,

View File

@ -32,9 +32,11 @@ export class InvoiceAmount extends MoneyValue {
}
// Ensure fluent operations keep the subclass type
convertScale(newScale: number) {
const mv = super.convertScale(newScale);
const p = mv.toPrimitive();
roundUsingScale(intermediateScale: number) {
const scaled = super.convertScale(intermediateScale);
const normalized = scaled.convertScale(InvoiceAmount.DEFAULT_SCALE);
const p = normalized.toPrimitive();
return new InvoiceAmount({
value: p.value,
currency_code: p.currency_code,

View File

@ -19,7 +19,7 @@ export enum INVOICE_STATUS {
const INVOICE_TRANSITIONS: Record<string, string[]> = {
draft: [INVOICE_STATUS.SENT],
sent: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED],
approved: [INVOICE_STATUS.ISSUED, INVOICE_STATUS.DRAFT],
approved: [INVOICE_STATUS.ISSUED, INVOICE_STATUS.REJECTED, INVOICE_STATUS.DRAFT],
rejected: [INVOICE_STATUS.DRAFT],
issued: [],
};
@ -39,34 +39,34 @@ export class InvoiceStatus extends ValueObject<IInvoiceStatusProps> {
return Result.ok(
value === "rejected"
? InvoiceStatus.fromRejected()
? InvoiceStatus.rejected()
: value === "sent"
? InvoiceStatus.fromSent()
? InvoiceStatus.sent()
: value === "issued"
? InvoiceStatus.fromIssued()
? InvoiceStatus.issued()
: value === "approved"
? InvoiceStatus.fromApproved()
: InvoiceStatus.fromDraft()
? InvoiceStatus.approved()
: InvoiceStatus.draft()
);
}
public static fromDraft(): InvoiceStatus {
public static draft(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.DRAFT });
}
public static fromIssued(): InvoiceStatus {
public static issued(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.ISSUED });
}
public static fromSent(): InvoiceStatus {
public static sent(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.SENT });
}
public static fromApproved(): InvoiceStatus {
public static approved(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.APPROVED });
}
public static fromRejected(): InvoiceStatus {
public static rejected(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.REJECTED });
}

View File

@ -32,9 +32,11 @@ export class ItemAmount extends MoneyValue {
}
// Ensure fluent operations keep the subclass type
convertScale(newScale: number) {
const mv = super.convertScale(newScale);
const p = mv.toPrimitive();
roundUsingScale(intermediateScale: number) {
const scaled = super.convertScale(intermediateScale);
const normalized = scaled.convertScale(ItemAmount.DEFAULT_SCALE);
const p = normalized.toPrimitive();
return new ItemAmount({
value: p.value,
currency_code: p.currency_code,

View File

@ -1,19 +0,0 @@
import { Percentage, type PercentageProps } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
type ItemDiscountPercentageProps = Pick<PercentageProps, "value">;
export class ItemDiscountPercentage extends Percentage {
static DEFAULT_SCALE = 2;
static create({ value }: ItemDiscountPercentageProps): Result<Percentage> {
return Percentage.create({
value,
scale: ItemDiscountPercentage.DEFAULT_SCALE,
});
}
static zero() {
return ItemDiscountPercentage.create({ value: 0 }).data;
}
}

View File

@ -19,13 +19,20 @@ import type {
InvoiceSerie,
InvoiceStatus,
} from "../../common";
import { IssuedInvoiceItems, type IssuedInvoiceTaxes, type VerifactuRecord } from "../entities";
import {
type IIssuedInvoiceItemCreateProps,
IssuedInvoiceItem,
IssuedInvoiceItems,
type IssuedInvoiceTaxes,
type VerifactuRecord,
} from "../entities";
import { IssuedInvoiceItemMismatch } from "../errors";
export interface IIssuedInvoiceProps {
export interface IIssuedInvoiceCreateProps {
companyId: UniqueID;
status: InvoiceStatus;
proformaId: Maybe<UniqueID>; // <- proforma padre en caso de issue
proformaId: UniqueID; // <- id de la proforma padre en caso de issue
series: Maybe<InvoiceSerie>;
invoiceNumber: InvoiceNumber;
@ -45,7 +52,7 @@ export interface IIssuedInvoiceProps {
paymentMethod: Maybe<InvoicePaymentMethod>;
items: IssuedInvoiceItems;
items: IIssuedInvoiceItemCreateProps[];
taxes: IssuedInvoiceTaxes;
subtotalAmount: InvoiceAmount;
@ -67,21 +74,28 @@ export interface IIssuedInvoiceProps {
verifactu: Maybe<VerifactuRecord>;
}
export class IssuedInvoice extends AggregateRoot<IIssuedInvoiceProps> {
private _items!: IssuedInvoiceItems;
export interface IIssuedInvoice {
companyId: UniqueID;
}
protected constructor(props: IIssuedInvoiceProps, id?: UniqueID) {
export type InternalIssuedInvoiceProps = Omit<IIssuedInvoiceCreateProps, "items">;
export class IssuedInvoice
extends AggregateRoot<InternalIssuedInvoiceProps>
implements IIssuedInvoice
{
private readonly _items!: IssuedInvoiceItems;
protected constructor(
props: InternalIssuedInvoiceProps,
items: IssuedInvoiceItems,
id?: UniqueID
) {
super(props, id);
this._items =
props.items ||
IssuedInvoiceItems.create({
languageCode: props.languageCode,
currencyCode: props.currencyCode,
globalDiscountPercentage: props.globalDiscountPercentage,
});
this._items = items;
}
static create(props: IIssuedInvoiceProps, id?: UniqueID): Result<IssuedInvoice, Error> {
static create(props: IIssuedInvoiceCreateProps, id?: UniqueID): Result<IssuedInvoice, Error> {
if (!props.recipient) {
return Result.fail(
new DomainValidationError(
@ -92,7 +106,21 @@ export class IssuedInvoice extends AggregateRoot<IIssuedInvoiceProps> {
);
}
const issuedInvoice = new IssuedInvoice(props, id);
const internalItems = IssuedInvoiceItems.create({
items: [],
languageCode: props.languageCode,
currencyCode: props.currencyCode,
globalDiscountPercentage: props.globalDiscountPercentage,
});
const { items, ...internalProps } = props;
const issuedInvoice = new IssuedInvoice(internalProps, internalItems, id);
const initializeResult = issuedInvoice.initializeItems(items);
if (initializeResult.isFailure) {
return Result.fail(initializeResult.error);
}
// Reglas de negocio / validaciones
// ...
@ -104,6 +132,32 @@ export class IssuedInvoice extends AggregateRoot<IIssuedInvoiceProps> {
return Result.ok(issuedInvoice);
}
// Rehidratación desde persistencia
static rehydrate(
props: InternalIssuedInvoiceProps,
items: IssuedInvoiceItems,
id: UniqueID
): IssuedInvoice {
return new IssuedInvoice(props, items, id);
}
private initializeItems(itemsProps: IIssuedInvoiceItemCreateProps[]): Result<void, Error> {
for (const [index, itemProps] of itemsProps.entries()) {
const itemResult = IssuedInvoiceItem.create(itemProps);
if (itemResult.isFailure) {
return Result.fail(itemResult.error);
}
const added = this._items.add(itemResult.data);
if (!added) {
return Result.fail(new IssuedInvoiceItemMismatch(index));
}
}
return Result.ok();
}
// Getters
public get companyId(): UniqueID {
@ -114,7 +168,7 @@ export class IssuedInvoice extends AggregateRoot<IIssuedInvoiceProps> {
return this.props.customerId;
}
public get proformaId(): Maybe<UniqueID> {
public get proformaId(): UniqueID {
return this.props.proformaId;
}
@ -230,8 +284,4 @@ export class IssuedInvoice extends AggregateRoot<IIssuedInvoiceProps> {
public get hasPaymentMethod() {
return this.paymentMethod.isSome();
}
public getProps(): IIssuedInvoiceProps {
return this.props;
}
}

View File

@ -14,7 +14,7 @@ import type { ItemAmount, ItemDescription, ItemQuantity } from "../../../common"
* Todos los importes están previamente calculados y congelados.
*/
export type IssuedInvoiceItemProps = {
export interface IIssuedInvoiceItemCreateProps {
description: Maybe<ItemDescription>;
quantity: Maybe<ItemQuantity>;
@ -49,18 +49,55 @@ export type IssuedInvoiceItemProps = {
languageCode: LanguageCode;
currencyCode: CurrencyCode;
};
export interface IIssuedInvoiceItem extends IssuedInvoiceItemProps {
isValued(): boolean; // Indica si el item tiene cantidad o precio (o ambos) para ser considerado "valorizado"
}
export interface IIssuedInvoiceItem {
isValued(): boolean; // Indica si el item tiene cantidad o precio (o ambos) para ser considerado "valorizado"
description: Maybe<ItemDescription>;
quantity: Maybe<ItemQuantity>;
unitAmount: Maybe<ItemAmount>;
subtotalAmount: ItemAmount;
itemDiscountPercentage: Maybe<DiscountPercentage>;
itemDiscountAmount: ItemAmount;
globalDiscountPercentage: DiscountPercentage;
globalDiscountAmount: ItemAmount;
totalDiscountAmount: ItemAmount;
taxableAmount: ItemAmount;
ivaCode: Maybe<string>;
ivaPercentage: Maybe<DiscountPercentage>;
ivaAmount: ItemAmount;
recCode: Maybe<string>;
recPercentage: Maybe<DiscountPercentage>;
recAmount: ItemAmount;
retentionCode: Maybe<string>;
retentionPercentage: Maybe<DiscountPercentage>;
retentionAmount: ItemAmount;
taxesAmount: ItemAmount;
totalAmount: ItemAmount;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
}
type InternalIssuedInvoiceItemProps = IIssuedInvoiceItemCreateProps;
export class IssuedInvoiceItem
extends DomainEntity<IssuedInvoiceItemProps>
extends DomainEntity<InternalIssuedInvoiceItemProps>
implements IIssuedInvoiceItem
{
public static create(
props: IssuedInvoiceItemProps,
props: IIssuedInvoiceItemCreateProps,
id?: UniqueID
): Result<IssuedInvoiceItem, Error> {
const item = new IssuedInvoiceItem(props, id);
@ -72,7 +109,11 @@ export class IssuedInvoiceItem
return Result.ok(item);
}
protected constructor(props: IssuedInvoiceItemProps, id?: UniqueID) {
static rehydrate(props: InternalIssuedInvoiceItemProps, id: UniqueID): IssuedInvoiceItem {
return new IssuedInvoiceItem(props, id);
}
protected constructor(props: InternalIssuedInvoiceItemProps, id?: UniqueID) {
super(props, id);
}
@ -163,7 +204,7 @@ export class IssuedInvoiceItem
return this.props.totalAmount;
}
getProps(): IssuedInvoiceItemProps {
getProps(): IIssuedInvoiceItemCreateProps {
return this.props;
}

View File

@ -0,0 +1 @@
export * from "./issued-invoice-item-not-valid-error";

View File

@ -0,0 +1,32 @@
import { DomainError } from "@repo/rdx-ddd";
/**
* Error de dominio que indica que al añadir un nuevo item a la lista
* de detalles, este no tiene el mismo "currencyCode" y "languageCode"
* que la colección de items.
*
*/
export class IssuedInvoiceItemMismatch extends DomainError {
/**
* Crea una instancia del error con el identificador del item.
*
* @param position - Posición del item
* @param options - Opciones nativas de Error (puedes pasar `cause`).
*/
constructor(position: number, options?: ErrorOptions) {
super(
`Error. IssuedInvoice item with position '${position}' rejected due to currency/language mismatch.`,
options
);
this.name = "IssuedInvoiceItemMismatch";
}
}
/**
* *Type guard* para `IssuedInvoiceItemNotValid`.
*
* @param e - Error desconocido
* @returns `true` si `e` es `IssuedInvoiceItemNotValid`
*/
export const isIssuedInvoiceItemMismatch = (e: unknown): e is IssuedInvoiceItemMismatch =>
e instanceof IssuedInvoiceItemMismatch;

View File

@ -1,3 +1,4 @@
export * from "./aggregates";
export * from "./entities";
export * from "./errors";
export * from "./value-objects";

View File

@ -17,7 +17,7 @@ import {
type InvoiceNumber,
type InvoiceRecipient,
type InvoiceSerie,
type InvoiceStatus,
InvoiceStatus,
type ItemAmount,
} from "../../common/value-objects";
import {
@ -27,7 +27,7 @@ import {
ProformaItems,
} from "../entities";
import { ProformaItemMismatch } from "../errors";
import { type IProformaTaxTotals, ProformaTaxesCalculator } from "../services";
import type { IProformaTaxTotals } from "../services";
import { ProformaItemTaxes } from "../value-objects";
export interface IProformaCreateProps {
@ -249,7 +249,7 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
}
public issue(): Result<void, Error> {
if (!this.props.status.canTransitionTo("ISSUED")) {
if (!this.props.status.canTransitionTo("issued")) {
return Result.fail(
new DomainValidationError(
"INVALID_STATE",
@ -259,8 +259,7 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
);
}
// Falta
//this.props.status = this.props.status.canTransitionTo("ISSUED");
this.props.status = InvoiceStatus.issued();
return Result.ok();
}
@ -292,7 +291,7 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
}
public taxes(): Collection<IProformaTaxTotals> {
return new ProformaTaxesCalculator(this.items).calculate();
return this.items.taxes();
}
public addItem(props: IProformaItemCreateProps): Result<void, Error> {

View File

@ -3,6 +3,7 @@ import type { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { ProformaItemMismatch } from "../../errors";
import { type IProformaTaxTotals, ProformaTaxesCalculator } from "../../services";
import { ProformaItemsTotalsCalculator } from "../../services/proforma-items-totals-calculator";
import type { IProformaItem, IProformaItemTotals, ProformaItem } from "./proforma-item.entity";
@ -37,6 +38,7 @@ export interface IProformaItems {
valued(): IProformaItem[]; // Devuelve solo las líneas valoradas.
totals(): IProformaItemTotals;
taxes(): Collection<IProformaTaxTotals>;
readonly globalDiscountPercentage: DiscountPercentage; // % descuento de la cabecera
readonly languageCode: LanguageCode; // Para formateos específicos de idioma
@ -57,6 +59,7 @@ export class ProformaItems extends Collection<ProformaItem> implements IProforma
if (this.items.length > 0) {
this.ensureSameContext(this.items);
}
4;
}
static create(props: IProformaItemsProps): ProformaItems {
@ -113,6 +116,10 @@ export class ProformaItems extends Collection<ProformaItem> implements IProforma
return new ProformaItemsTotalsCalculator(this).calculate();
}
public taxes(): Collection<IProformaTaxTotals> {
return new ProformaTaxesCalculator(this).calculate();
}
private ensureSameContext(items: IProformaItem[]): void {
for (const item of items) {
const same =

View File

@ -1,5 +1,5 @@
import { ItemAmount } from "../../common";
import type { IProformaItems } from "../entities";
import type { ProformaItems } from "../entities";
export interface IProformaItemsTotals {
subtotalAmount: ItemAmount;
@ -23,7 +23,7 @@ export interface IProformaItemsTotals {
* La lógica fiscal está en ProformaItem; aquí solo se agregan resultados.
*/
export class ProformaItemsTotalsCalculator {
constructor(private readonly items: IProformaItems) {}
constructor(private readonly items: ProformaItems) {}
public calculate(): IProformaItemsTotals {
const zero = ItemAmount.zero(this.items.currencyCode.code);
@ -47,26 +47,28 @@ export class ProformaItemsTotalsCalculator {
const amounts = item.totals();
// Subtotales
subtotalAmount = subtotalAmount.add(amounts.subtotalAmount);
subtotalAmount = subtotalAmount.add(amounts.subtotalAmount.roundUsingScale(2));
// Descuentos
itemDiscountAmount = itemDiscountAmount.add(amounts.itemDiscountAmount);
globalDiscountAmount = globalDiscountAmount.add(amounts.globalDiscountAmount);
totalDiscountAmount = totalDiscountAmount.add(amounts.totalDiscountAmount);
itemDiscountAmount = itemDiscountAmount.add(amounts.itemDiscountAmount.roundUsingScale(2));
globalDiscountAmount = globalDiscountAmount.add(
amounts.globalDiscountAmount.roundUsingScale(2)
);
totalDiscountAmount = totalDiscountAmount.add(amounts.totalDiscountAmount.roundUsingScale(2));
// Base imponible
taxableAmount = taxableAmount.add(amounts.taxableAmount);
taxableAmount = taxableAmount.add(amounts.taxableAmount.roundUsingScale(2));
// Impuestos individuales
ivaAmount = ivaAmount.add(amounts.ivaAmount);
recAmount = recAmount.add(amounts.recAmount);
retentionAmount = retentionAmount.add(amounts.retentionAmount);
ivaAmount = ivaAmount.add(amounts.ivaAmount.roundUsingScale(2));
recAmount = recAmount.add(amounts.recAmount.roundUsingScale(2));
retentionAmount = retentionAmount.add(amounts.retentionAmount.roundUsingScale(2));
// Total impuestos del ítem
taxesAmount = taxesAmount.add(amounts.taxesAmount);
taxesAmount = taxesAmount.add(amounts.taxesAmount.roundUsingScale(2));
// Total final del ítem
totalAmount = totalAmount.add(amounts.totalAmount);
totalAmount = totalAmount.add(amounts.totalAmount.roundUsingScale(2));
}
return {

View File

@ -29,8 +29,8 @@ export class ProformaTaxesCalculator {
constructor(private readonly items: IProformaItems) {}
public calculate(): Collection<IProformaTaxTotals> {
const groups = proformaComputeTaxGroups(this.items);
const currencyCode = this.items.currencyCode;
const groups = proformaComputeTaxGroups(this.items); // <- devuelve en escala 4
//const currencyCode = this.items.currencyCode;
const rows = Array.from(groups.values()).map((g) => {
const taxableAmount = this.toInvoiceAmount(g.taxableAmount);

View File

@ -67,7 +67,7 @@ export class IssueCustomerInvoiceDomainService {
...proformaProps,
isProforma: false,
proformaId: Maybe.some(proforma.id),
status: InvoiceStatus.fromIssued(),
status: InvoiceStatus.issued(),
invoiceNumber: issueNumber,
invoiceDate: issueDate,
description: proformaProps.description.isNone() ? Maybe.some(".") : proformaProps.description,

View File

@ -1,21 +1,15 @@
import type { IModuleServer } from "@erp/core/api";
import {
type IssuedInvoicePublicServices,
type IssuedInvoicesInternalDeps,
type ProformaPublicServices,
type ProformasInternalDeps,
buildIssuedInvoiceServices,
buildIssuedInvoicePublicServices,
buildIssuedInvoicesDependencies,
buildProformaServices,
buildProformaPublicServices,
buildProformasDependencies,
issuedInvoicesRouter,
models,
proformasRouter,
} from "./infrastructure";
export type { IssuedInvoicePublicServices, ProformaPublicServices };
export const customerInvoicesAPIModule: IModuleServer = {
name: "customer-invoices",
version: "1.0.0",
@ -36,14 +30,8 @@ export const customerInvoicesAPIModule: IModuleServer = {
const proformasInternal = buildProformasDependencies(params);
// 2) Servicios públicos (Application Services)
const issuedInvoicesServices: IssuedInvoicePublicServices = buildIssuedInvoiceServices(
params,
issuedInvoicesInternal
);
const proformasServices: ProformaPublicServices = buildProformaServices(
params,
proformasInternal
);
const issuedInvoicesServices = buildIssuedInvoicePublicServices(params, issuedInvoicesInternal);
const proformasServices = buildProformaPublicServices(params, proformasInternal);
logger.info("🚀 CustomerInvoices module dependencies registered", { label: this.name });
@ -73,22 +61,11 @@ export const customerInvoicesAPIModule: IModuleServer = {
* - NO construye dominio
*/
async start(params) {
const { app, baseRoutePath, logger, getInternal } = params;
// Recuperamos el dominio interno del módulo
const issuedInvoicesInternalDeps = getInternal<IssuedInvoicesInternalDeps>(
"customer-invoices",
"issuedInvoices"
);
const proformasInternalDeps = getInternal<ProformasInternalDeps>(
"customer-invoices",
"proformas"
);
const { app, baseRoutePath, logger, getInternal, getService } = params;
// Registro de rutas HTTP
issuedInvoicesRouter(params, issuedInvoicesInternalDeps);
proformasRouter(params, proformasInternalDeps);
issuedInvoicesRouter(params);
proformasRouter(params);
logger.info("🚀 CustomerInvoices module started", {
label: this.name,

View File

@ -1,5 +1,6 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import {
DiscountPercentage,
type ISequelizeDomainMapper,
type MapperParamsType,
SequelizeDomainMapper,
@ -18,18 +19,11 @@ import { Result } from "@repo/rdx-utils";
import {
type IProformaCreateProps,
IssuedInvoiceItem,
type IssuedInvoiceItemProps,
ItemAmount,
ItemDescription,
ItemDiscountPercentage,
ItemQuantity,
ItemTaxGroup,
type Proforma,
} from "../../../../../../domain";
import type {
CustomerInvoiceItemCreationAttributes,
CustomerInvoiceItemModel,
} from "../../../../sequelize";
export interface ICustomerInvoiceItemDomainMapper
extends ISequelizeDomainMapper<
@ -99,7 +93,7 @@ export class CustomerInvoiceItemDomainMapper
const discountPercentage = extractOrPushError(
maybeFromNullableResult(source.discount_percentage_value, (v) =>
ItemDiscountPercentage.create({ value: v })
DiscountPercentage.create({ value: v })
),
`items[${index}].discount_percentage`,
errors
@ -107,7 +101,7 @@ export class CustomerInvoiceItemDomainMapper
const globalDiscountPercentage = extractOrPushError(
maybeFromNullableResult(source.global_discount_percentage_value, (v) =>
ItemDiscountPercentage.create({ value: v })
DiscountPercentage.create({ value: v })
),
`items[${index}].discount_percentage`,
errors
@ -242,7 +236,7 @@ export class CustomerInvoiceItemDomainMapper
),
discount_percentage_scale:
maybeToNullable(source.itemDiscountPercentage, (v) => v.toPrimitive().scale) ??
ItemDiscountPercentage.DEFAULT_SCALE,
DiscountPercentage.DEFAULT_SCALE,
discount_amount_value: allAmounts.itemDiscountAmount.value,
discount_amount_scale: allAmounts.itemDiscountAmount.scale,
@ -255,7 +249,7 @@ export class CustomerInvoiceItemDomainMapper
global_discount_percentage_scale:
maybeToNullable(source.globalDiscountPercentage, (v) => v.toPrimitive().scale) ??
ItemDiscountPercentage.DEFAULT_SCALE,
DiscountPercentage.DEFAULT_SCALE,
global_discount_amount_value: allAmounts.globalDiscountAmount.value,
global_discount_amount_scale: allAmounts.globalDiscountAmount.scale,

View File

@ -1,2 +1,3 @@
export * from "./issued-invoice-number-generator.di";
export * from "./issued-invoice-public-services";
export * from "./issued-invoices.di";

View File

@ -0,0 +1,5 @@
import type { IIssuedInvoiceNumberGenerator } from "../../../application";
import { SequelizeIssuedInvoiceNumberGenerator } from "../persistence";
export const buildIssuedInvoiceNumberGenerator = (): IIssuedInvoiceNumberGenerator =>
new SequelizeIssuedInvoiceNumberGenerator();

View File

@ -1,26 +1,27 @@
import type { SetupParams } from "@erp/core/api";
import { buildCatalogs, buildTransactionManager } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceCreatorParams,
type IIssuedInvoicePublicServices,
type IIssuedInvoiceServicesContext,
buildIssuedInvoiceCreator,
buildIssuedInvoiceFinder,
buildIssuedInvoiceSnapshotBuilders,
} from "../../../application/issued-invoices";
import { buildIssuedInvoiceDocumentService } from "./issued-invoice-documents.di";
import { buildIssuedInvoiceNumberGenerator } from "./issued-invoice-number-generator.di";
import { buildIssuedInvoicePersistenceMappers } from "./issued-invoice-persistence-mappers.di";
import { buildIssuedInvoiceRepository } from "./issued-invoice-repositories.di";
import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di";
export type IssuedInvoicePublicServices = {
listIssuedInvoices: (filters: unknown, context: unknown) => null;
getIssuedInvoiceById: (id: unknown, context: unknown) => null;
generateIssuedInvoiceReport: (id: unknown, options: unknown, context: unknown) => null;
};
export function buildIssuedInvoiceServices(
export function buildIssuedInvoicePublicServices(
params: SetupParams,
deps: IssuedInvoicesInternalDeps
): IssuedInvoicePublicServices {
): IIssuedInvoicePublicServices {
const { database } = params;
// Infrastructure
@ -29,20 +30,29 @@ export function buildIssuedInvoiceServices(
const persistenceMappers = buildIssuedInvoicePersistenceMappers(catalogs);
const repository = buildIssuedInvoiceRepository({ database, mappers: persistenceMappers });
const numberService = buildIssuedInvoiceNumberGenerator();
// Application helpers
const creator = buildIssuedInvoiceCreator({ numberService, repository });
const finder = buildIssuedInvoiceFinder(repository);
const snapshotBuilders = buildIssuedInvoiceSnapshotBuilders();
const documentGeneratorPipeline = buildIssuedInvoiceDocumentService(params);
return {
listIssuedInvoices: (filters, context) => null,
//internal.useCases.listIssuedInvoices().execute(filters, context),
createIssuedInvoice: async (
id: UniqueID,
props: IIssuedInvoiceCreatorParams["props"],
context: IIssuedInvoiceServicesContext
) => {
const { transaction, companyId } = context;
getIssuedInvoiceById: (id, context) => null,
//internal.useCases.getIssuedInvoiceById().execute(id, context),
const createResult = await creator.create({ companyId, id, props, transaction });
generateIssuedInvoiceReport: (id, options, context) => null,
//internal.useCases.reportIssuedInvoice().execute(id, options, context),
if (createResult.isFailure) {
return Result.fail(createResult.error);
}
return Result.ok(createResult.data);
},
};
}

View File

@ -10,14 +10,9 @@ import {
import {
type CustomerInvoiceIdAlreadyExistsError,
type EntityIsNotProformaError,
type InvalidProformaTransitionError,
type ProformaCannotBeConvertedToInvoiceError,
type IssuedInvoiceItemMismatch,
isCustomerInvoiceIdAlreadyExistsError,
isEntityIsNotProformaError,
isInvalidProformaTransitionError,
isProformaCannotBeConvertedToInvoiceError,
isProformaCannotBeDeletedError,
isIssuedInvoiceItemMismatch,
} from "../../../domain";
// Crea una regla específica (prioridad alta para sobreescribir mensajes)
@ -31,47 +26,17 @@ const invoiceDuplicateRule: ErrorToApiRule = {
),
};
const entityIsNotProformaError: ErrorToApiRule = {
const issuedinvoiceItemMismatchError: ErrorToApiRule = {
priority: 120,
matches: (e) => isEntityIsNotProformaError(e),
matches: (e) => isIssuedInvoiceItemMismatch(e),
build: (e) =>
new ValidationApiError(
(e as EntityIsNotProformaError).message || "Entity with the provided id is not proforma"
),
};
const proformaTransitionRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isInvalidProformaTransitionError(e),
build: (e) =>
new ValidationApiError(
(e as InvalidProformaTransitionError).message || "Invalid transition for proforma."
),
};
const proformaConversionRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isProformaCannotBeConvertedToInvoiceError(e),
build: (e) =>
new ValidationApiError(
(e as ProformaCannotBeConvertedToInvoiceError).message ||
"Proforma cannot be converted to an Invoice."
),
};
const proformaCannotBeDeletedRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isProformaCannotBeDeletedError(e),
build: (e) =>
new ValidationApiError(
(e as ProformaCannotBeConvertedToInvoiceError).message || "Proforma cannot be deleted."
(e as IssuedInvoiceItemMismatch).message ||
"IssuedInvoice item rejected due to currency/language mismatch"
),
};
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const customerInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(invoiceDuplicateRule)
.register(entityIsNotProformaError)
.register(proformaConversionRule)
.register(proformaCannotBeDeletedRule)
.register(proformaTransitionRule);
.register(issuedinvoiceItemMismatchError);

View File

@ -1,5 +1,5 @@
import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api";
import { type ModuleParams, type RequestWithAuth, validateRequest } from "@erp/core/api";
import { type RequestWithAuth, type StartParams, validateRequest } from "@erp/core/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import {
@ -16,11 +16,15 @@ import {
ReportIssuedInvoiceController,
} from "./controllers";
export const issuedInvoicesRouter = (params: ModuleParams, deps: IssuedInvoicesInternalDeps) => {
const { app, config } = params;
export const issuedInvoicesRouter = (params: StartParams) => {
const { app, config, getService, getInternal } = params;
const deps = getInternal<IssuedInvoicesInternalDeps>("customer-invoices", "issuedInvoices");
const router: Router = Router({ mergeParams: true });
// ----------------------------------------------
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
// 🔐 Autenticación + Tenancy para TODO el router
router.use(

View File

@ -1,2 +1,3 @@
export * from "./mappers";
export * from "./repositories";
export * from "./services";

View File

@ -14,7 +14,7 @@ import {
import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import {
type IIssuedInvoiceProps,
type InternalIssuedInvoiceProps,
InvoiceAmount,
InvoiceNumber,
InvoicePaymentMethod,
@ -63,8 +63,9 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors);
// Para issued invoices, proforma_id debe estar relleno
const proformaId = extractOrPushError(
maybeFromNullableResult(raw.proforma_id, (v) => UniqueID.create(v)),
UniqueID.create(String(raw.proforma_id)),
"proforma_id",
errors
);
@ -346,7 +347,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
currencyCode: attributes.currencyCode!,
});
const invoiceProps: IIssuedInvoiceProps = {
const invoiceProps: InternalIssuedInvoiceProps = {
companyId: attributes.companyId!,
proformaId: attributes.proformaId!,
@ -383,22 +384,14 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
paymentMethod: attributes.paymentMethod!,
items,
taxes,
verifactu,
};
const createResult = IssuedInvoice.create(invoiceProps, attributes.invoiceId);
const invoiceId = attributes.invoiceId!;
const invoice = IssuedInvoice.rehydrate(invoiceProps, items, invoiceId);
if (createResult.isFailure) {
return Result.fail(
new ValidationErrorCollection("Customer invoice entity creation failed", [
{ path: "invoice", message: createResult.error.message },
])
);
}
return Result.ok(createResult.data);
return Result.ok(invoice);
} catch (err: unknown) {
return Result.fail(err as Error);
}
@ -471,7 +464,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
// Flags / estado / serie / número
is_proforma: false,
status: source.status.toPrimitive(),
proforma_id: maybeToNullable(source.proformaId, (v) => v.toPrimitive()),
proforma_id: source.proformaId.toPrimitive(),
series: maybeToNullable(source.series, (v) => v.toPrimitive()),
invoice_number: source.invoiceNumber.toPrimitive(),

View File

@ -1,5 +1,10 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
DiscountPercentage,
type MapperParamsType,
SequelizeDomainMapper,
TaxPercentage,
} from "@erp/core/api";
import {
UniqueID,
ValidationErrorCollection,
@ -13,16 +18,13 @@ import {
import { Result } from "@repo/rdx-utils";
import {
DiscountPercentage,
type IIssuedInvoiceProps,
type IIssuedInvoiceCreateProps,
type IIssuedInvoiceItemCreateProps,
type IssuedInvoice,
IssuedInvoiceItem,
type IssuedInvoiceItemProps,
ItemAmount,
ItemDescription,
ItemDiscountPercentage,
ItemQuantity,
ItemTaxPercentage,
} from "../../../../../../domain";
import type {
CustomerInvoiceItemCreationAttributes,
@ -34,7 +36,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
CustomerInvoiceItemCreationAttributes,
IssuedInvoiceItem
> {
private taxCatalog!: JsonTaxCatalogProvider;
private readonly taxCatalog!: JsonTaxCatalogProvider;
constructor(params: MapperParamsType) {
super();
@ -52,11 +54,11 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
private mapAttributesToDomain(
raw: CustomerInvoiceItemModel,
params?: MapperParamsType
): Partial<IssuedInvoiceItemProps> & { itemId?: UniqueID } {
): Partial<IIssuedInvoiceItemCreateProps> & { itemId?: UniqueID } {
const { errors, index, attributes } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<IIssuedInvoiceProps>;
attributes: Partial<IIssuedInvoiceCreateProps>;
};
const itemId = extractOrPushError(
@ -96,7 +98,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
const itemDiscountPercentage = extractOrPushError(
maybeFromNullableResult(raw.item_discount_percentage_value, (v) =>
ItemDiscountPercentage.create({ value: v })
DiscountPercentage.create({ value: v })
),
`items[${index}].item_discount_percentage_value`,
errors
@ -112,9 +114,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
);
const globalDiscountPercentage = extractOrPushError(
maybeFromNullableResult(raw.global_discount_percentage_value, (v) =>
DiscountPercentage.create({ value: v })
),
DiscountPercentage.create({ value: raw.global_discount_percentage_value }),
`items[${index}].global_discount_percentage_value`,
errors
);
@ -149,9 +149,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
const ivaCode = maybeFromNullableOrEmptyString(raw.iva_code);
const ivaPercentage = extractOrPushError(
maybeFromNullableResult(raw.iva_percentage_value, (value) =>
ItemTaxPercentage.create({ value })
),
maybeFromNullableResult(raw.iva_percentage_value, (value) => TaxPercentage.create({ value })),
`items[${index}].iva_percentage_value`,
errors
);
@ -168,9 +166,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
const recCode = maybeFromNullableOrEmptyString(raw.rec_code);
const recPercentage = extractOrPushError(
maybeFromNullableResult(raw.rec_percentage_value, (value) =>
ItemTaxPercentage.create({ value })
),
maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })),
`items[${index}].rec_percentage_value`,
errors
);
@ -188,7 +184,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
const retentionPercentage = extractOrPushError(
maybeFromNullableResult(raw.retention_percentage_value, (value) =>
ItemTaxPercentage.create({ value })
TaxPercentage.create({ value })
),
`items[${index}].retention_percentage_value`,
errors
@ -263,7 +259,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
const { errors, index } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<IIssuedInvoiceProps>;
attributes: Partial<IIssuedInvoiceCreateProps>;
};
// 1) Valores escalares (atributos generales)
@ -277,7 +273,8 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
}
// 2) Construcción del elemento de dominio
const createResult = IssuedInvoiceItem.create(
const itemId = attributes.itemId!;
const newItem = IssuedInvoiceItem.rehydrate(
{
description: attributes.description!,
@ -314,18 +311,10 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
languageCode: attributes.languageCode!,
currencyCode: attributes.currencyCode!,
},
attributes.itemId
itemId
);
if (createResult.isFailure) {
return Result.fail(
new ValidationErrorCollection("Invoice item entity creation failed", [
{ path: `items[${index}]`, message: createResult.error.message },
])
);
}
return createResult;
return Result.ok(newItem);
}
public mapToPersistence(
@ -364,18 +353,14 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
),
item_discount_percentage_scale:
maybeToNullable(source.itemDiscountPercentage, (v) => v.toPrimitive().scale) ??
ItemDiscountPercentage.DEFAULT_SCALE,
DiscountPercentage.DEFAULT_SCALE,
item_discount_amount_value: source.itemDiscountAmount.toPrimitive().value,
item_discount_amount_scale: source.itemDiscountAmount.toPrimitive().scale,
global_discount_percentage_value: maybeToNullable(
source.globalDiscountPercentage,
(v) => v.toPrimitive().value
),
global_discount_percentage_value: source.globalDiscountPercentage.toPrimitive().value,
global_discount_percentage_scale:
maybeToNullable(source.globalDiscountPercentage, (v) => v.toPrimitive().scale) ??
ItemDiscountPercentage.DEFAULT_SCALE,
source.globalDiscountPercentage.toPrimitive().scale ?? DiscountPercentage.DEFAULT_SCALE,
global_discount_amount_value: source.globalDiscountAmount.value,
global_discount_amount_scale: source.globalDiscountAmount.scale,

View File

@ -16,7 +16,7 @@ import {
import { Maybe, Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceProps,
type IIssuedInvoiceCreateProps,
InvoiceRecipient,
type IssuedInvoice,
} from "../../../../../../domain";
@ -33,7 +33,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
const { errors, attributes } = params as {
errors: ValidationErrorDetail[];
attributes: Partial<IIssuedInvoiceProps>;
attributes: Partial<IIssuedInvoiceCreateProps>;
};
const _name = source.customer_name!;

View File

@ -1,5 +1,10 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { type MapperParamsType, SequelizeDomainMapper, TaxPercentage } from "@erp/core/api";
import {
DiscountPercentage,
type MapperParamsType,
SequelizeDomainMapper,
TaxPercentage,
} from "@erp/core/api";
import {
Percentage,
UniqueID,
@ -14,7 +19,7 @@ import {
import { Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceProps,
type IIssuedInvoiceCreateProps,
InvoiceAmount,
type IssuedInvoice,
IssuedInvoiceTax,
@ -64,7 +69,7 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp
const { errors, index, attributes } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<IIssuedInvoiceProps>;
attributes: Partial<IIssuedInvoiceCreateProps>;
};
const taxableAmount = extractOrPushError(
@ -78,9 +83,10 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp
const ivaCode = raw.iva_code;
// Una issued invoice debe traer IVA
const ivaPercentage = extractOrPushError(
TaxPercentage.create({
value: raw.iva_percentage_value,
value: Number(raw.iva_percentage_value),
}),
`taxes[${index}].iva_percentage_value`,
errors
@ -210,7 +216,7 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp
rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value),
rec_percentage_scale:
maybeToNullable(source.recPercentage, (v) => v.toPrimitive().scale) ??
ItemDiscountPercentage.DEFAULT_SCALE,
DiscountPercentage.DEFAULT_SCALE,
rec_amount_value: source.recAmount.toPrimitive().value,
rec_amount_scale: source.recAmount.toPrimitive().scale ?? ItemAmount.DEFAULT_SCALE,

View File

@ -12,7 +12,7 @@ import {
import { Maybe, Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceProps,
type IIssuedInvoiceCreateProps,
type IssuedInvoice,
VerifactuRecord,
VerifactuRecordEstado,
@ -33,7 +33,7 @@ export class SequelizeIssuedInvoiceVerifactuDomainMapper extends SequelizeDomain
): Result<Maybe<VerifactuRecord>, Error> {
const { errors, attributes } = params as {
errors: ValidationErrorDetail[];
attributes: Partial<IIssuedInvoiceProps>;
attributes: Partial<IIssuedInvoiceCreateProps>;
};
if (!source) {

View File

@ -0,0 +1 @@
export * from "./sequelize-issued-invoice-number-generator.service";

View File

@ -0,0 +1,66 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { type Transaction, type WhereOptions, literal } from "sequelize";
import type { IIssuedInvoiceNumberGenerator } from "../../../../../application/issued-invoices";
import { InvoiceNumber, type InvoiceSerie } from "../../../../../domain";
import { CustomerInvoiceModel } from "../../../../common/persistence";
/**
* Generador de números de factura
*/
export class SequelizeIssuedInvoiceNumberGenerator implements IIssuedInvoiceNumberGenerator {
public async getNextForCompany(
companyId: UniqueID,
series: Maybe<InvoiceSerie>,
transaction: Transaction
): Promise<Result<InvoiceNumber, Error>> {
const where: WhereOptions = {
company_id: companyId.toString(),
is_proforma: false,
};
series.match(
(serieVO) => {
where.series = serieVO.toString();
},
() => {
where.series = null;
}
);
try {
const lastInvoice = await CustomerInvoiceModel.findOne({
attributes: ["invoice_number"],
where,
// Orden numérico real: CAST(... AS UNSIGNED)
order: [literal("CAST(invoice_number AS UNSIGNED) DESC")],
transaction,
raw: true,
// Bloqueo opcional para evitar carreras si estás dentro de una TX
lock: transaction.LOCK.UPDATE, // requiere InnoDB y TX abierta
});
let nextValue = "0001"; // valor inicial por defecto
if (lastInvoice) {
const current = Number(lastInvoice.invoice_number);
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
nextValue = String(next).padStart(4, "0");
}
const numberResult = InvoiceNumber.create(nextValue);
if (numberResult.isFailure) {
return Result.fail(numberResult.error);
}
return Result.ok(numberResult.data);
} catch (error) {
return Result.fail(
new Error(
`Error generating invoice number for company ${companyId}: ${(error as Error).message}`
)
);
}
}
}

View File

@ -23,13 +23,6 @@ type ProformaServicesContext = {
};
export type ProformaPublicServices = {
createProforma: (
id: UniqueID,
props: IProformaCreatorParams["props"],
context: ProformaServicesContext
) => Promise<Result<Proforma, Error>>;
listProformas: (filters: unknown, context: unknown) => null;
getProformaById: (
id: UniqueID,
context: ProformaServicesContext
@ -40,10 +33,14 @@ export type ProformaPublicServices = {
context: ProformaServicesContext
) => Promise<Result<IProformaFullSnapshot, Error>>;
generateProformaReport: (id: unknown, options: unknown, context: unknown) => null;
createProforma: (
id: UniqueID,
props: IProformaCreatorParams["props"],
context: ProformaServicesContext
) => Promise<Result<Proforma, Error>>;
};
export function buildProformaServices(
export function buildProformaPublicServices(
params: SetupParams,
deps: ProformasInternalDeps
): ProformaPublicServices {
@ -78,7 +75,6 @@ export function buildProformaServices(
return Result.ok(createResult.data);
},
listProformas: (filters, context) => null,
//internal.useCases.listProformas().execute(filters, context),
getProformaById: async (id: UniqueID, context: ProformaServicesContext) => {
@ -105,7 +101,7 @@ export function buildProformaServices(
return Result.ok(fullSnapshot);
},
generateProformaReport: (id, options, context) => null,
//generateProformaReport: (id, options, context) => null,
//internal.useCases.reportProforma().execute(id, options, context),
};
}

View File

@ -3,15 +3,20 @@ import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/
import {
type CreateProformaUseCase,
type GetProformaByIdUseCase,
type IIssuedInvoicePublicServices,
type IssueProformaUseCase,
type ListProformasUseCase,
type ReportProformaUseCase,
buildCreateProformaUseCase,
buildGetProformaByIdUseCase,
buildIssueProformaUseCase,
buildListProformasUseCase,
buildProformaCreator,
buildProformaFinder,
buildProformaInputMappers,
buildProformaIssuer,
buildProformaSnapshotBuilders,
buildProformaToIssuedInvoicePropsConverter,
buildReportProformaUseCase,
} from "../../../application";
@ -26,6 +31,9 @@ export type ProformasInternalDeps = {
getProformaById: () => GetProformaByIdUseCase;
reportProforma: () => ReportProformaUseCase;
createProforma: () => CreateProformaUseCase;
issueProforma: (publicServices: {
issuedInvoiceServices: IIssuedInvoicePublicServices;
}) => IssueProformaUseCase;
/*
updateProforma: () => UpdateProformaUseCase;
@ -44,12 +52,18 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const persistenceMappers = buildProformaPersistenceMappers(catalogs);
const repository = buildProformaRepository({ database, mappers: persistenceMappers });
const numberService = buildProformaNumberGenerator();
const proformaNumberService = buildProformaNumberGenerator();
// Application helpers
const inputMappers = buildProformaInputMappers(catalogs);
const finder = buildProformaFinder(repository);
const creator = buildProformaCreator({ numberService, repository });
const creator = buildProformaCreator({ numberService: proformaNumberService, repository });
const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter();
const issuer = buildProformaIssuer({
proformaConverter: proformaToIssuedInvoiceConverter,
repository,
});
const snapshotBuilders = buildProformaSnapshotBuilders();
const documentGeneratorPipeline = buildProformaDocumentService(params);
@ -87,6 +101,14 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) =>
buildIssueProformaUseCase({
publicServices,
finder,
issuer,
transactionManager,
}),
},
};
}

View File

@ -2,7 +2,7 @@
//export * from "./create-proforma.controller";
//export * from "./delete-proforma.controller";
export * from "./get-proforma.controller";
//export * from "./issue-proforma.controller";
export * from "./issue-proforma.controller";
export * from "./list-proformas.controller";
export * from "./report-proforma.controller";
//export * from "./update-proforma.controller";

View File

@ -5,7 +5,7 @@ import {
requireCompanyContextGuard,
} from "@erp/core/api";
import type { IssueProformaUseCase } from "../../../../application/index.ts";
import type { IssueProformaUseCase } from "../../../../application/proformas";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class IssueProformaController extends ExpressController {

View File

@ -1,4 +1,5 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { DiscountPercentage } from "@erp/core/api";
import {
CurrencyCode,
DomainError,
@ -57,7 +58,7 @@ export class CreateProformaRequestMapper {
try {
this.errors = [];
const defaultStatus = InvoiceStatus.fromDraft();
const defaultStatus = InvoiceStatus.draft();
const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", this.errors);
@ -209,7 +210,7 @@ export class CreateProformaRequestMapper {
const discountPercentage = extractOrPushError(
maybeFromNullableResult(item.item_discount_percentage, (value) =>
ItemDiscountPercentage.create(value)
DiscountPercentage.create(value)
),
"discount_percentage",
this.errors

View File

@ -1,9 +1,10 @@
import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api";
import { type ModuleParams, type RequestWithAuth, validateRequest } from "@erp/core/api";
import { type RequestWithAuth, type StartParams, validateRequest } from "@erp/core/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import {
GetProformaController,
IssueProformaController,
ListProformasController,
type ProformasInternalDeps,
ReportProformaController,
@ -12,18 +13,30 @@ import {
import {
CreateProformaRequestSchema,
GetProformaByIdRequestSchema,
IssueProformaByIdParamsRequestSchema,
ListProformasRequestSchema,
ReportProformaByIdParamsRequestSchema,
ReportProformaByIdQueryRequestSchema,
} from "../../../../common";
import type { IIssuedInvoicePublicServices } from "../../../application";
import { CreateProformaController } from "./controllers/create-proforma.controller";
export const proformasRouter = (params: ModuleParams, deps: ProformasInternalDeps) => {
const { app, config } = params;
export const proformasRouter = (params: StartParams) => {
const { app, config, getService, getInternal } = params;
const deps = getInternal<ProformasInternalDeps>("customer-invoices", "proformas");
const issuedInvoicesServices = getService<IIssuedInvoicePublicServices>("self:issuedInvoices");
const publicServices = {
issuedInvoiceServices: issuedInvoicesServices,
};
const router: Router = Router({ mergeParams: true });
// ----------------------------------------------
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
// 🔐 Autenticación + Tenancy para TODO el router
router.use(
@ -131,18 +144,18 @@ export const proformasRouter = (params: ModuleParams, deps: ProformasInternalDep
}
);*/
/*router.put(
router.put(
"/:proforma_id/issue",
//checkTabContext,
validateRequest(IssueProformaByIdParamsRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.issue_proforma();
const controller = new IssuedProformaController(useCase);
const useCase = deps.useCases.issueProforma(publicServices);
const controller = new IssueProformaController(useCase);
return controller.execute(req, res, next);
}
);*/
);
app.use(`${config.server.apiBasePath}/proformas`, router);
};

View File

@ -58,12 +58,6 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors);
const proformaId = extractOrPushError(
maybeFromNullableResult(raw.proforma_id, (v) => UniqueID.create(v)),
"proforma_id",
errors
);
const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors);
const series = extractOrPushError(
@ -160,7 +154,6 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
invoiceId,
companyId,
customerId,
proformaId,
status,
series,
invoiceNumber,
@ -244,8 +237,8 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
paymentMethod: attributes.paymentMethod!,
};
const invoiceId = attributes.invoiceId!;
const proforma = Proforma.rehydrate(invoiceProps, items, invoiceId);
const proformaId = attributes.invoiceId!;
const proforma = Proforma.rehydrate(invoiceProps, items, proformaId);
return Result.ok(proforma);
} catch (err: unknown) {

View File

@ -75,7 +75,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
const quantity = extractOrPushError(
maybeFromNullableResult(raw.quantity_value, (v) => ItemQuantity.create({ value: v })),
`items[${index}].quantity`,
`items[${index}].quantity_value`,
errors
);
@ -83,7 +83,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
maybeFromNullableResult(raw.unit_amount_value, (value) =>
ItemAmount.create({ value, currency_code: parent.currencyCode?.code })
),
`items[${index}].unit_amount`,
`items[${index}].unit_amount_value`,
errors
);
@ -163,26 +163,20 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
const itemId = attributes.itemId!;
const newItem = ProformaItem.rehydrate(
{
languageCode: attributes.languageCode!,
currencyCode: attributes.currencyCode!,
description: attributes.description!,
quantity: attributes.quantity!,
unitAmount: attributes.unitAmount!,
itemDiscountPercentage: attributes.itemDiscountPercentage!,
globalDiscountPercentage: attributes.globalDiscountPercentage!,
taxes: taxesResult.data,
languageCode: attributes.languageCode!,
currencyCode: attributes.currencyCode!,
},
itemId
);
/*if (createResult.isFailure) {
return Result.fail(
new ValidationErrorCollection("Invoice item entity creation failed", [
{ path: `items[${index}]`, message: createResult.error.message },
])
);
}*/
return Result.ok(newItem);
}

View File

@ -31,13 +31,6 @@ export class SequelizeProformaRecipientDomainMapper {
parent: Partial<IProformaCreateProps>;
};
/* if (!source.current_customer) {
errors.push({
path: "current_customer",
message: "Current customer not included in query (SequelizeProformaRecipientDomainMapper)",
});
}
*/
const _name = source.current_customer.name;
const _tin = source.current_customer.tin;
const _street = source.current_customer.street;

View File

@ -89,8 +89,8 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
item_discount_percentage: PercentageSchema,
item_discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema,
global_discount_amount: MoneySchema,

View File

@ -83,8 +83,8 @@ export const GetProformaByIdResponseSchema = z.object({
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
item_discount_percentage: PercentageSchema,
item_discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema,
global_discount_amount: MoneySchema,

View File

@ -19,24 +19,24 @@ import * as React from "react";
import { useTranslation } from "../../../../../i18n";
import {
PROFORMA_STATUS_TRANSITIONS,
type ProformaListRow,
type ProformaStatus,
type ProformaSummaryData,
} from "../../../../types";
} from "../../../../shared";
import { ProformaStatusBadge } from "../../components";
type GridActionHandlers = {
onEditClick?: (proforma: ProformaSummaryData) => void;
onIssueClick?: (proforma: ProformaSummaryData) => void;
onChangeStatusClick?: (proforma: ProformaSummaryData, nextStatus: string) => void;
onDeleteClick?: (proforma: ProformaSummaryData) => void;
onEditClick?: (proforma: ProformaListRow) => void;
onIssueClick?: (proforma: ProformaListRow) => void;
onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: string) => void;
onDeleteClick?: (proforma: ProformaListRow) => void;
};
export function useProformasGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<ProformaSummaryData, unknown>[] {
): ColumnDef<ProformaListRow, unknown>[] {
const { t } = useTranslation();
return React.useMemo<ColumnDef<ProformaSummaryData, unknown>[]>(
return React.useMemo<ColumnDef<ProformaListRow, unknown>[]>(
() => [
/*{
id: "select",

View File

@ -3,11 +3,11 @@ import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../../../../i18n";
import {
type ProformaStatus,
getProformaStatusButtonVariant,
getProformaStatusColor,
getProformaStatusIcon,
} from "../../../types";
} from "../../../change-status/helpers";
import type { ProformaStatus } from "../../../shared";
export type ProformaStatusBadgeProps = {
status: string | ProformaStatus; // permitir cualquier valor

View File

@ -1,165 +0,0 @@
// application/customer-application-service.ts
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import {
Customer,
type CustomerPatchProps,
type ICustomerCreateProps,
type ICustomerRepository,
} from "../domain";
import type { CustomerListDTO } from "../infrastructure";
export class CustomerApplicationService {
constructor(private readonly repository: ICustomerRepository) {}
/**
* Construye un nuevo agregado CustomerInvoice a partir de props validadas.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param props - Las propiedades ya validadas para crear el cliente.
* @param customerId - Identificador UUID del cliente (opcional).
* @returns Result<Customer, Error> - El agregado construido o un error si falla la creación.
*/
buildCustomerInCompany(
companyId: UniqueID,
props: Omit<ICustomerCreateProps, "companyId">,
customerId?: UniqueID
): Result<Customer, Error> {
return Customer.create({ ...props, companyId }, customerId);
}
/**
* Guarda un nuevo cliente y devuelve el cliente guardado.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param customer - El cliente a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - El cliente guardado o un error si falla la operación.
*/
async createCustomerInCompany(
companyId: UniqueID,
customer: Customer,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
const result = await this.repository.create(customer, transaction);
if (result.isFailure) return Result.fail(result.error);
return this.getCustomerByIdInCompany(companyId, customer.id, transaction);
}
/**
* Actualiza un cliente existente y devuelve el cliente actualizado.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param customer - El cliente a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - El cliente guardado o un error si falla la operación.
*/
async updateCustomerInCompany(
companyId: UniqueID,
customer: Customer,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
const result = await this.repository.update(customer, transaction);
if (result.isFailure) return Result.fail(result.error);
return this.getCustomerByIdInCompany(companyId, customer.id, transaction);
}
/**
* Actualiza parcialmente un cliente existente con nuevos datos.
* Solo en memoria. No lo guarda en el repositorio.
*
* @param companyId - Identificador de la empresa a la que pertenece el cliente.
* @param customerId - Identificador del cliente a actualizar.
* @param changes - Subconjunto de props válidas para aplicar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - Cliente actualizado o error.
*/
async patchCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
changes: CustomerPatchProps,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
const customerResult = await this.getCustomerByIdInCompany(companyId, customerId, transaction);
if (customerResult.isFailure) {
return Result.fail(customerResult.error);
}
const updated = customerResult.data.update(changes);
if (updated.isFailure) {
return Result.fail(updated.error);
}
return Result.ok(updated.data);
}
/**
* Elimina (o marca como eliminado) un cliente según su ID.
*
* @param companyId - Identificador de la empresa a la que pertenece el cliente.
* @param customerId - Identificador UUID del cliente.
* @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error> - Resultado de la operación.
*/
async deleteCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
return this.repository.deleteByIdInCompany(companyId, customerId, transaction);
}
/**
*
* Comprueba si existe o no en persistencia un cliente con el ID proporcionado
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param customerId - Identificador UUID del cliente
* @param transaction - Transacción activa para la operación.
* @returns Result<Boolean, Error> - Existe el cliente o no.
*/
async existsByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, customerId, transaction);
}
/**
* Recupera un cliente por su identificador único.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param customerId - Identificador UUID del cliente.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - Cliente encontrado o error.
*/
async getCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
return this.repository.getByIdInCompany(companyId, customerId, transaction);
}
/**
* Obtiene una colección de clientes que cumplen con los filtros definidos en un objeto Criteria.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Objeto con condiciones de filtro, paginación y orden.
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<Customer>, Error> - Colección de clientes o error.
*/
async findCustomerByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<CustomerListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
}
}

View File

@ -1,7 +1,6 @@
import { DuplicateEntityError } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import { Customer, type ICustomerCreateProps } from "../../domain";
import type { ICustomerRepository } from "../repositories";
@ -12,7 +11,7 @@ export interface ICustomerCreator {
companyId: UniqueID;
id: UniqueID;
props: ICustomerCreateProps;
transaction: Transaction;
unknown: unknown;
}): Promise<Result<Customer, Error>>;
}
@ -31,16 +30,12 @@ export class CustomerCreator implements ICustomerCreator {
companyId: UniqueID;
id: UniqueID;
props: ICustomerCreateProps;
transaction: Transaction;
unknown: unknown;
}): Promise<Result<Customer, Error>> {
const { companyId, id, props, transaction } = params;
const { companyId, id, props, unknown } = params;
// 1. Verificar unicidad
const spec = new CustomerNotExistsInCompanySpecification(
this.repository,
companyId,
transaction
);
const spec = new CustomerNotExistsInCompanySpecification(this.repository, companyId, unknown);
const isNew = await spec.isSatisfiedBy(id);
@ -64,7 +59,7 @@ export class CustomerCreator implements ICustomerCreator {
const newCustomer = createResult.data;
// 3. Persistir agregado
const saveResult = await this.repository.create(newCustomer, transaction);
const saveResult = await this.repository.create(newCustomer, unknown);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);

View File

@ -1,7 +1,6 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { Customer } from "../../domain";
import type { CustomerSummary } from "../models";
@ -11,25 +10,25 @@ export interface ICustomerFinder {
findCustomerById(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
unknown?: unknown
): Promise<Result<Customer, Error>>;
findCustomerByTIN(
companyId: UniqueID,
tin: TINNumber,
transaction?: Transaction
unknown?: unknown
): Promise<Result<Customer, Error>>;
customerExists(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
unknown?: unknown
): Promise<Result<boolean, Error>>;
findCustomersByCriteria(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
unknown?: unknown
): Promise<Result<Collection<CustomerSummary>, Error>>;
}
@ -39,32 +38,32 @@ export class CustomerFinder implements ICustomerFinder {
async findCustomerById(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
unknown?: unknown
): Promise<Result<Customer, Error>> {
return this.repository.getByIdInCompany(companyId, customerId, transaction);
return this.repository.getByIdInCompany(companyId, customerId, unknown);
}
findCustomerByTIN(
companyId: UniqueID,
tin: TINNumber,
transaction?: Transaction
unknown?: unknown
): Promise<Result<Customer, Error>> {
return this.repository.getByTINInCompany(companyId, tin, transaction);
return this.repository.getByTINInCompany(companyId, tin, unknown);
}
async customerExists(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
unknown?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, customerId, transaction);
return this.repository.existsByIdInCompany(companyId, customerId, unknown);
}
async findCustomersByCriteria(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
unknown?: unknown
): Promise<Result<Collection<CustomerSummary>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
return this.repository.findByCriteriaInCompany(companyId, criteria, unknown);
}
}

View File

@ -0,0 +1,22 @@
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { Customer, ICustomerCreateProps } from "../../domain";
export interface ICustomerServicesContext {
transaction: unknown;
companyId: UniqueID;
}
export interface ICustomerPublicServices {
findCustomerByTIN: (
tin: TINNumber,
context: ICustomerServicesContext
) => Promise<Result<Customer, Error>>;
createCustomer: (
id: UniqueID,
props: ICustomerCreateProps,
context: ICustomerServicesContext
) => Promise<Result<Customer, Error>>;
}

View File

@ -1,6 +1,5 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { Customer, CustomerPatchProps } from "../../domain";
import type { ICustomerRepository } from "../repositories";
@ -10,7 +9,7 @@ export interface ICustomerUpdater {
companyId: UniqueID;
id: UniqueID;
props: CustomerPatchProps;
transaction: Transaction;
transaction: unknown;
}): Promise<Result<Customer, Error>>;
}
@ -29,7 +28,7 @@ export class CustomerUpdater implements ICustomerUpdater {
companyId: UniqueID;
id: UniqueID;
props: CustomerPatchProps;
transaction: Transaction;
transaction: unknown;
}): Promise<Result<Customer, Error>> {
const { companyId, id, props, transaction } = params;

View File

@ -1,3 +1,4 @@
export * from "./customer-creator";
export * from "./customer-finder";
export * from "./customer-public-services.interface";
export * from "./customer-updater";

View File

@ -1,5 +1,4 @@
import { CompositeSpecification, type UniqueID } from "@repo/rdx-ddd";
import type { Transaction } from "sequelize";
import type { ICustomerRepository } from "../../application";
import { logger } from "../../helpers";
@ -8,7 +7,7 @@ export class CustomerNotExistsInCompanySpecification extends CompositeSpecificat
constructor(
private readonly repository: ICustomerRepository,
private readonly companyId: UniqueID,
private readonly transaction?: Transaction
private readonly transaction?: unknown
) {
super();
}

View File

@ -1,7 +1,6 @@
import type { ITransactionManager } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { CreateCustomerRequestDTO } from "../../../common";
import type { ICreateCustomerInputMapper } from "../mappers";
@ -43,7 +42,7 @@ export class CreateCustomerUseCase {
const { props, id } = mappedPropsResult.data;
return this.transactionManager.complete(async (transaction: Transaction) => {
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const createResult = await this.creator.create({ companyId, id, props, transaction });

View File

@ -2,7 +2,6 @@ import type { ITransactionManager } from "@erp/core/api";
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { ICustomerFinder } from "../services";
import type { ICustomerSummarySnapshotBuilder } from "../snapshot-builders/summary";
@ -22,7 +21,7 @@ export class ListCustomersUseCase {
public execute(params: ListCustomersUseCaseInput) {
const { criteria, companyId } = params;
return this.transactionManager.complete(async (transaction: Transaction) => {
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const result = await this.finder.findCustomersByCriteria(companyId, criteria, transaction);

View File

@ -1,7 +1,6 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
import type { CustomerPatchProps } from "../../../domain";
@ -52,7 +51,7 @@ export class UpdateCustomerUseCase {
const patchProps: CustomerPatchProps = patchPropsResult.data;
return this.transactionManager.complete(async (transaction: Transaction) => {
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const updateResult = await this.updater.update({
companyId,

View File

@ -3,7 +3,7 @@ import type { IModuleServer } from "@erp/core/api";
import { type CustomerPublicServices, customersRouter, models } from "./infrastructure";
import {
type CustomersInternalDeps,
buildCustomerServices,
buildCustomerPublicServices,
buildCustomersDependencies,
} from "./infrastructure/di";
@ -30,7 +30,7 @@ export const customersAPIModule: IModuleServer = {
const internal = buildCustomersDependencies(params);
// 2) Servicios públicos (Application Services)
const customersServices: CustomerPublicServices = buildCustomerServices(params, internal);
const customersServices: CustomerPublicServices = buildCustomerPublicServices(params, internal);
logger.info("🚀 Customers module dependencies registered", {
label: this.name,

View File

@ -1,38 +1,23 @@
import { type SetupParams, buildCatalogs } from "@erp/core/api";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import { buildCustomerCreator, buildCustomerFinder } from "../../application";
import type { Customer, ICustomerCreateProps } from "../../domain";
import {
type ICustomerPublicServices,
type ICustomerServicesContext,
buildCustomerCreator,
buildCustomerFinder,
} from "../../application";
import type { ICustomerCreateProps } from "../../domain";
import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di";
import { buildCustomerRepository } from "./customer-repositories.di";
import type { CustomersInternalDeps } from "./customers.di";
type CustomerServicesContext = {
transaction: Transaction;
companyId: UniqueID;
};
export type CustomerPublicServices = {
//listCustomers: (filters: unknown, context: unknown) => null;
findCustomerByTIN: (
tin: TINNumber,
context: CustomerServicesContext
) => Promise<Result<Customer, Error>>;
createCustomer: (
id: UniqueID,
props: ICustomerCreateProps,
context: CustomerServicesContext
) => Promise<Result<Customer, Error>>;
//generateCustomerReport: (id: unknown, options: unknown, context: unknown) => null;
};
export function buildCustomerServices(
export function buildCustomerPublicServices(
params: SetupParams,
deps: CustomersInternalDeps
): CustomerPublicServices {
): ICustomerPublicServices {
const { database } = params;
const catalogs = buildCatalogs();
@ -44,7 +29,7 @@ export function buildCustomerServices(
const creator = buildCustomerCreator({ repository });
return {
findCustomerByTIN: async (tin: TINNumber, context: CustomerServicesContext) => {
findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => {
const { companyId, transaction } = context;
const customerResult = await finder.findCustomerByTIN(companyId, tin, transaction);
@ -59,7 +44,7 @@ export function buildCustomerServices(
createCustomer: async (
id: UniqueID,
props: ICustomerCreateProps,
context: CustomerServicesContext
context: ICustomerServicesContext
) => {
const { companyId, transaction } = context;

Some files were not shown because too many files have changed in this diff Show More