This commit is contained in:
David Arranz 2026-03-03 12:05:09 +01:00
parent 821b4d3ff7
commit 941ad25401
58 changed files with 1501 additions and 223 deletions

View File

@ -47,7 +47,8 @@
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit",
"source.removeUnusedImports": "always"
"source.removeUnusedImports": "always",
"source.fixAll.eslint": "explicit"
},
// other vscode settings

View File

@ -8,7 +8,8 @@
"dev": "node --import=tsx --watch src/index.ts",
"clean": "rimraf .turbo node_modules dist",
"typecheck": "tsc --noEmit",
"lint": "biome lint --fix",
"lint": "biome check . && eslint .",
"lint:fix": "biome check --write . && eslint . --fix",
"format": "biome format --write"
},
"devDependencies": {

View File

@ -39,7 +39,9 @@
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf",
"attributePosition": "auto"
"attributePosition": "auto",
"bracketSpacing": true,
"bracketSameLine": true
},
"linter": {
"enabled": true,

59
eslint.config.mjs Normal file
View File

@ -0,0 +1,59 @@
import tseslint from "@typescript-eslint/eslint-plugin";
import parser from "@typescript-eslint/parser";
export default [
{
files: ["**/*.ts", "**/*.tsx"],
ignores: [
"**/dist/**",
"**/.turbo/**",
"**/node_modules/**"
],
languageOptions: {
parser,
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
"@typescript-eslint/member-ordering": [
"error",
{
default: [
"signature",
// Static
"public-static-field",
"protected-static-field",
"private-static-field",
"public-static-method",
"protected-static-method",
"private-static-method",
// Instance fields
"public-instance-field",
"protected-instance-field",
"private-instance-field",
"constructor",
// Accessors
"public-instance-get",
"protected-instance-get",
"private-instance-get",
"public-instance-set",
"protected-instance-set",
"private-instance-set",
// Methods
"public-instance-method",
"protected-instance-method",
"private-instance-method",
],
},
],
},
},
];

View File

@ -5,25 +5,22 @@ import { z } from "zod/v4";
import { TaxPercentage } from "./tax-percentage.vo";
const DEFAULT_SCALE = TaxPercentage.DEFAULT_SCALE;
const DEFAULT_MIN_VALUE = TaxPercentage.MIN_VALUE;
const DEFAULT_MAX_VALUE = TaxPercentage.MAX_VALUE;
const DEFAULT_MIN_SCALE = TaxPercentage.MIN_SCALE;
const DEFAULT_MAX_SCALE = TaxPercentage.MAX_SCALE;
const TAX_GROUPS = ["IVA", "IPSI", "IGIC", "retention", "rec"] as const;
type TaxGroup = (typeof TAX_GROUPS)[number];
export interface TaxProps {
code: string; // iva_21
name: string; // 21% IVA
value: number; // 2100
group: TaxGroup;
}
export class Tax extends ValueObject<TaxProps> {
static DEFAULT_SCALE = DEFAULT_SCALE;
static MIN_VALUE = DEFAULT_MIN_VALUE;
static MAX_VALUE = DEFAULT_MAX_VALUE;
static MIN_SCALE = DEFAULT_MIN_SCALE;
static MAX_SCALE = DEFAULT_MAX_SCALE;
static readonly DEFAULT_SCALE = TaxPercentage.DEFAULT_SCALE;
static readonly MIN_VALUE = TaxPercentage.MIN_VALUE;
static readonly MAX_VALUE = TaxPercentage.MAX_VALUE;
static readonly MIN_SCALE = TaxPercentage.MIN_SCALE;
static readonly MAX_SCALE = TaxPercentage.MAX_SCALE;
private static CODE_REGEX = /^[a-z0-9_:-]+$/;
@ -45,20 +42,21 @@ export class Tax extends ValueObject<TaxProps> {
.min(1, "El código del impuesto es obligatorio.")
.max(40, "El código del impuesto no puede exceder 40 caracteres.")
.regex(Tax.CODE_REGEX, "El código contiene caracteres no permitidos."),
group: z.enum(TAX_GROUPS, "El impuesto debe ser un IVA, retención o rec. equivalencia"),
});
return schema.safeParse(values);
}
static create(props: TaxProps): Result<Tax> {
const { value, name, code } = props;
const { value, name, code, group } = props;
const validationResult = Tax.validate({ value, name, code });
const validationResult = Tax.validate({ value, name, code, group });
if (!validationResult.success) {
return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", ")));
}
return Result.ok(new Tax({ value, name, code }));
return Result.ok(new Tax({ value, name, code, group }));
}
/**
@ -89,21 +87,16 @@ export class Tax extends ValueObject<TaxProps> {
}
const item = maybeItem.unwrap();
// Delegamos en create para reusar validación y límites
return Tax.create({
value: Number(item.value),
name: item.name,
code: item.code, // guardamos el code tal cual viene del catálogo
group: item.group as TaxGroup,
});
}
protected constructor(props: TaxProps) {
super(props);
this._percentage = TaxPercentage.create({
value: this.props.value,
}).data;
}
get value(): number {
return this.props.value;
}
@ -117,8 +110,24 @@ export class Tax extends ValueObject<TaxProps> {
return this.props.code;
}
get group(): string {
return this.props.group;
}
get percentage(): TaxPercentage {
return this._percentage;
return TaxPercentage.create({ value: this.value }).data;
}
isVATLike(): boolean {
return this.group === "IVA" || this.group === "IGIC" || this.group === "IPSI";
}
isRetention(): boolean {
return this.group === "retention";
}
isRec(): boolean {
return this.group === "rec";
}
getProps(): TaxProps {
@ -129,21 +138,20 @@ export class Tax extends ValueObject<TaxProps> {
return this.getProps();
}
/** Devuelve el valor real de la tasa como número decimal (ej: 21.00) */
toNumber(): number {
return this.value / 10 ** this.scale;
}
/** Devuelve la tasa formateada como porcentaje (ej: "21.00%") */
toString(): string {
return `${this.toNumber().toFixed(this.scale)}%`;
}
isZero(): boolean {
return this.toNumber() === 0;
return this.value === 0;
}
isPositive(): boolean {
return this.toNumber() > 0;
return this.value > 0;
}
equalsTo(other: Tax): boolean {

View File

@ -0,0 +1,13 @@
import { type JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "../../../common";
export interface ICatalogs {
taxCatalog: JsonTaxCatalogProvider;
}
export const buildCatalogs = (): ICatalogs => {
const taxCatalog = SpainTaxCatalogProvider();
return {
taxCatalog,
};
};

View File

@ -1,2 +1,3 @@
export * from "./catalogs.di";
export * from "./documents.di";
export * from "./transactions.di";

View File

@ -1,6 +1,6 @@
import type { Sequelize } from "sequelize";
import { SequelizeTransactionManager } from "../sequelize";
import { SequelizeTransactionManager } from "../persistence/sequelize";
export const buildTransactionManager = (database: Sequelize) =>
new SequelizeTransactionManager(database);

View File

@ -6,4 +6,4 @@ export * from "./errors";
export * from "./express";
export * from "./logger";
export * from "./mappers";
export * from "./sequelize";
export * from "./persistence";

View File

@ -0,0 +1 @@
export * from "./sequelize";

View File

@ -1,7 +1,7 @@
import { Collection, Result, ResultCollection } from "@repo/rdx-utils";
import type { Model } from "sequelize";
import type { MapperParamsType } from "../../../domain";
import type { MapperParamsType } from "../../../../domain";
import type { ISequelizeDomainMapper } from "./sequelize-mapper.interface";

View File

@ -1,4 +1,4 @@
import type { DomainMapperWithBulk, IQueryMapperWithBulk } from "../../../domain";
import type { DomainMapperWithBulk, IQueryMapperWithBulk } from "../../../../domain";
export interface ISequelizeDomainMapper<TModel, TModelAttributes, TEntity>
extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {}

View File

@ -1,7 +1,9 @@
import { Collection, Result } from "@repo/rdx-utils";
import { Model } from "sequelize";
import { MapperParamsType } from "../../../domain";
import { ISequelizeQueryMapper } from "./sequelize-mapper.interface";
import type { Model } from "sequelize";
import type { MapperParamsType } from "../../../../domain";
import type { ISequelizeQueryMapper } from "./sequelize-mapper.interface";
export abstract class SequelizeQueryMapper<TModel extends Model, TEntity>
implements ISequelizeQueryMapper<TModel, TEntity>

View File

@ -7,9 +7,9 @@ import {
UniqueConstraintError,
} from "sequelize";
import { DuplicateEntityError, EntityNotFoundError } from "../../domain";
import { InfrastructureRepositoryError } from "../errors/infrastructure-repository-error";
import { InfrastructureUnavailableError } from "../errors/infrastructure-unavailable-error";
import { DuplicateEntityError, EntityNotFoundError } from "../../../domain";
import { InfrastructureRepositoryError } from "../../errors/infrastructure-repository-error";
import { InfrastructureUnavailableError } from "../../errors/infrastructure-unavailable-error";
/**
* Traduce errores específicos de Sequelize a errores de dominio/infraestructura

View File

@ -1,9 +1,9 @@
import { Result } from "@repo/rdx-utils";
import { type Sequelize, Transaction } from "sequelize";
import { TransactionManager } from "../database";
import { InfrastructureError, InfrastructureUnavailableError } from "../errors";
import { logger } from "../logger";
import { TransactionManager } from "../../database";
import { InfrastructureError, InfrastructureUnavailableError } from "../../errors";
import { logger } from "../../logger";
export class SequelizeTransactionManager extends TransactionManager {
protected _database: Sequelize | null = null;

View File

@ -134,13 +134,12 @@
"description": "Inversión del sujeto pasivo.",
"aeat_code": "09"
},
{
"name": "Retenc. 35%",
"code": "retencion_35",
"value": "3500",
"scale": "2",
"group": "Retención",
"group": "retention",
"description": "Retenc. profesional o fiscal tipo máximo.",
"aeat_code": null
},
@ -149,7 +148,7 @@
"code": "retencion_19",
"value": "1900",
"scale": "2",
"group": "Retención",
"group": "retention",
"description": "Retenc. IRPF general.",
"aeat_code": "R1"
},
@ -158,7 +157,7 @@
"code": "retencion_15",
"value": "1500",
"scale": "2",
"group": "Retención",
"group": "retention",
"description": "Retenc. para autónomos y profesionales.",
"aeat_code": "R2"
},
@ -167,7 +166,7 @@
"code": "retencion_7",
"value": "700",
"scale": "2",
"group": "Retención",
"group": "retention",
"description": "Retenc. para nuevos autónomos.",
"aeat_code": null
},
@ -176,17 +175,16 @@
"code": "retencion_2",
"value": "200",
"scale": "2",
"group": "Retención",
"group": "retention",
"description": "Retenc. sobre arrendamientos de inmuebles urbanos.",
"aeat_code": "R3"
},
{
"name": "Rec. 5,2%",
"code": "rec_5_2",
"value": "520",
"scale": "2",
"group": "Recargo de equivalencia",
"group": "rec",
"description": "Recargo general para IVA 21%.",
"aeat_code": "51"
},
@ -195,7 +193,7 @@
"code": "rec_1_75",
"value": "175",
"scale": "2",
"group": "Recargo de equivalencia",
"group": "rec",
"description": "Recargo para IVA 10%.",
"aeat_code": "52"
},
@ -204,7 +202,7 @@
"code": "rec_1_4",
"value": "140",
"scale": "2",
"group": "Recargo de equivalencia",
"group": "rec",
"description": "Recargo para IVA 5%.",
"aeat_code": null
},
@ -213,7 +211,7 @@
"code": "rec_1",
"value": "100",
"scale": "2",
"group": "Recargo de equivalencia",
"group": "rec",
"description": "Recargo especial.",
"aeat_code": null
},
@ -222,7 +220,7 @@
"code": "rec_0_62",
"value": "62",
"scale": "2",
"group": "Recargo de equivalencia",
"group": "rec",
"description": "Recargo para IVA reducido especial.",
"aeat_code": null
},
@ -231,7 +229,7 @@
"code": "rec_0_5",
"value": "50",
"scale": "2",
"group": "Recargo de equivalencia",
"group": "rec",
"description": "Recargo especial.",
"aeat_code": null
},
@ -240,7 +238,7 @@
"code": "rec_0_26",
"value": "26",
"scale": "2",
"group": "Recargo de equivalencia",
"group": "rec",
"description": "Recargo mínimo.",
"aeat_code": null
},
@ -249,11 +247,10 @@
"code": "rec_0",
"value": "0",
"scale": "2",
"group": "Recargo de equivalencia",
"group": "rec",
"description": "Sin recargo.",
"aeat_code": null
},
{
"name": "IGIC 7%",
"code": "igic_7",
@ -335,7 +332,6 @@
"description": "Operación exenta de IGIC.",
"aeat_code": "12"
},
{
"name": "IPSI 10%",
"code": "ipsi_10",
@ -372,4 +368,4 @@
"description": "Operación exenta de IPSI.",
"aeat_code": null
}
]
]

View File

@ -1,17 +1,17 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import type { IssuedInvoiceProps, Proforma } from "../../../domain";
import type { IIssuedInvoiceProps, Proforma } from "../../../domain";
export interface IProformaToIssuedInvoiceMaterializer {
materialize(proforma: Proforma, issuedInvoiceId: UniqueID): Result<IssuedInvoiceProps, Error>;
materialize(proforma: Proforma, issuedInvoiceId: UniqueID): Result<IIssuedInvoiceProps, Error>;
}
export class ProformaToIssuedInvoiceMaterializer implements IProformaToIssuedInvoiceMaterializer {
public materialize(
proforma: Proforma,
issuedInvoiceId: UniqueID
): Result<IssuedInvoiceProps, Error> {
): Result<IIssuedInvoiceProps, Error> {
const amounts = proforma.calculateAllAmounts();
const taxGroups = proforma.getTaxes();

View File

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

View File

@ -2,13 +2,16 @@ import { ProformaFactory } from "../factories";
import type { IProformaRepository } from "../repositories";
import { type IProformaCreator, type IProformaNumberGenerator, ProformaCreator } from "../services";
export const buildProformaCreator = (
numberService: IProformaNumberGenerator,
repository: IProformaRepository
): IProformaCreator => {
export const buildProformaCreator = (params: {
numberService: IProformaNumberGenerator;
repository: IProformaRepository;
}): IProformaCreator => {
const { numberService, repository } = params;
const factory = new ProformaFactory();
return new ProformaCreator({
numberService,
factory: new ProformaFactory(),
factory,
repository,
});
};

View File

@ -0,0 +1,19 @@
import type { ICatalogs } from "@erp/core/api";
import { CreateProformaInputMapper, type ICreateProformaInputMapper } from "../mappers";
export interface IProformaInputMappers {
createInputMapper: ICreateProformaInputMapper;
}
export const buildProformaInputMappers = (catalogs: ICatalogs): IProformaInputMappers => {
const { taxCatalog } = catalogs;
// Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado
const createInputMapper = new CreateProformaInputMapper({ taxCatalog });
//const updateProformaInputMapper = new UpdateProformaInputMapper();
return {
createInputMapper,
};
};

View File

@ -1,12 +1,18 @@
import type { ITransactionManager } from "@erp/core/api";
import type { IProformaFinder, ProformaDocumentGeneratorService } from "../services";
import type { ICreateProformaInputMapper } from "../mappers";
import type {
IProformaCreator,
IProformaFinder,
ProformaDocumentGeneratorService,
} from "../services";
import type {
IProformaListItemSnapshotBuilder,
IProformaReportSnapshotBuilder,
} from "../snapshot-builders";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders/full";
import { GetProformaByIdUseCase, ListProformasUseCase, ReportProformaUseCase } from "../use-cases";
import { CreateProformaUseCase } from "../use-cases/create-proforma";
export function buildGetProformaByIdUseCase(deps: {
finder: IProformaFinder;
@ -40,18 +46,19 @@ export function buildReportProformaUseCase(deps: {
);
}
/*export function buildCreateProformaUseCase(deps: {
export function buildCreateProformaUseCase(deps: {
creator: IProformaCreator;
dtoMapper: ICreateProformaInputMapper;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new CreateProformaUseCase({
mapper: new CreateProformaPropsMapper(),
dtoMapper: deps.dtoMapper,
creator: deps.creator,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
transactionManager: deps.transactionManager,
});
}*/
}
/*export function buildUpdateProformaUseCase(deps: {
finder: IProformaFinder;

View File

@ -1,4 +1,3 @@
export * from "./create-proforma-props.mapper";
export * from "./inputs";
export * from "./proforma-domain-mapper.interface";
export * from "./proforma-list-mapper.interface";
//export * from "./update-proforma-props.mapper";

View File

@ -0,0 +1,319 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { DiscountPercentage, Tax } from "@erp/core/api";
import {
CurrencyCode,
DomainError,
LanguageCode,
Percentage,
TextValue,
UniqueID,
UtcDate,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../../common";
import {
type IProformaItemProps,
type IProformaProps,
InvoiceNumber,
InvoicePaymentMethod,
type InvoiceRecipient,
InvoiceSerie,
InvoiceStatus,
ItemAmount,
ItemDescription,
ItemQuantity,
type ProformaItemTaxesProps,
} from "../../../../domain";
/**
* CreateProformaPropsMapper
* Convierte el DTO a las props validadas (CustomerProps).
* No construye directamente el agregado.
*
* @param dto - DTO con los datos de la factura de cliente
* @returns
*
*/
export interface ICreateProformaInputMapper
extends IDTOInputToPropsMapper<
CreateProformaRequestDTO,
{ id: UniqueID; props: Omit<IProformaProps, "items"> & { items: IProformaItemProps[] } }
> {}
export class CreateProformaInputMapper implements ICreateProformaInputMapper {
private readonly taxCatalog: JsonTaxCatalogProvider;
constructor(params: { taxCatalog: JsonTaxCatalogProvider }) {
this.taxCatalog = params.taxCatalog;
}
public map(
dto: CreateProformaRequestDTO,
params: { companyId: UniqueID }
): Result<{ id: UniqueID; props: IProformaProps }> {
const errors: ValidationErrorDetail[] = [];
const { companyId } = params;
try {
const defaultStatus = InvoiceStatus.createDraft();
const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
const customerId = extractOrPushError(
UniqueID.create(dto.customer_id),
"customer_id",
errors
);
const recipient = Maybe.none<InvoiceRecipient>();
const proformaNumber = extractOrPushError(
InvoiceNumber.create(dto.invoice_number),
"invoice_number",
errors
);
const series = extractOrPushError(
maybeFromNullableResult(dto.series, (value) => InvoiceSerie.create(value)),
"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 reference = extractOrPushError(
maybeFromNullableResult(dto.reference, (value) => Result.ok(String(value))),
"reference",
errors
);
const description = extractOrPushError(
maybeFromNullableResult(dto.reference, (value) => Result.ok(String(value))),
"description",
errors
);
const notes = extractOrPushError(
maybeFromNullableResult(dto.notes, (value) => TextValue.create(value)),
"notes",
errors
);
const languageCode = extractOrPushError(
LanguageCode.create(dto.language_code),
"language_code",
errors
);
const currencyCode = extractOrPushError(
CurrencyCode.create(dto.currency_code),
"currency_code",
errors
);
const paymentMethod = extractOrPushError(
maybeFromNullableResult(dto.payment_method, (value) =>
InvoicePaymentMethod.create({ paymentDescription: value })
),
"payment_method",
errors
);
const globalDiscountPercentage = extractOrPushError(
Percentage.create({
value: Number(dto.discount_percentage.value),
scale: Number(dto.discount_percentage.scale),
}),
"discount_percentage",
errors
);
const items = this.mapItems(dto, {
languageCode: languageCode!,
currencyCode: currencyCode!,
globalDiscountPercentage: globalDiscountPercentage!,
errors,
});
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer invoice props mapping failed", errors)
);
}
const props: Omit<IProformaProps, "items"> & { items: IProformaItemProps[] } = {
companyId,
status: defaultStatus,
invoiceNumber: proformaNumber!,
series: series!,
invoiceDate: invoiceDate!,
operationDate: operationDate!,
customerId: customerId!,
recipient,
reference: reference!,
description: description!,
notes: notes!,
languageCode: languageCode!,
currencyCode: currencyCode!,
paymentMethod: paymentMethod!,
globalDiscountPercentage: globalDiscountPercentage!,
items, // ← IProformaItemProps[]
};
return Result.ok({
id: proformaId!,
props,
});
} catch (err: unknown) {
return Result.fail(new DomainError("Customer invoice props mapping failed", { cause: err }));
}
}
private mapItems(
dto: CreateProformaRequestDTO,
params: {
languageCode: LanguageCode;
currencyCode: CurrencyCode;
globalDiscountPercentage: DiscountPercentage;
errors: ValidationErrorDetail[];
}
): IProformaItemProps[] {
const itemsProps: IProformaItemProps[] = [];
dto.items.forEach((item, index) => {
const description = extractOrPushError(
maybeFromNullableResult(item.description, (v) => ItemDescription.create(v)),
`items[${index}].description`,
params.errors
);
const quantity = extractOrPushError(
maybeFromNullableResult(item.quantity, (v) => ItemQuantity.create(v)),
`items[${index}].quantity`,
params.errors
);
const unitAmount = extractOrPushError(
maybeFromNullableResult(item.unit_amount, (v) => ItemAmount.create(v)),
`items[${index}].unit_amount`,
params.errors
);
const discountPercentage = extractOrPushError(
maybeFromNullableResult(item.discount_percentage, (v) => DiscountPercentage.create(v)),
`items[${index}].discount_percentage`,
params.errors
);
const taxes = this.mapTaxes(item.taxes, {
itemIndex: index,
errors: params.errors,
});
itemsProps.push({
globalDiscountPercentage: params.globalDiscountPercentage,
languageCode: params.languageCode,
currencyCode: params.currencyCode,
description: description!,
quantity: quantity!,
unitAmount: unitAmount!,
itemDiscountPercentage: discountPercentage!,
taxes,
});
});
return itemsProps;
}
/* Devuelve las propiedades de los impustos de una línea de detalle */
private mapTaxes(
taxesDTO: Pick<CreateProformaItemRequestDTO, "taxes">["taxes"],
params: { itemIndex: number; errors: ValidationErrorDetail[] }
): ProformaItemTaxesProps {
const { itemIndex, errors } = params;
const taxesProps: ProformaItemTaxesProps = {
iva: Maybe.none(),
retention: Maybe.none(),
rec: Maybe.none(),
};
// Normaliza: "" -> []
const taxStrCodes = taxesDTO
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
taxStrCodes.forEach((strCode, taxIndex) => {
const taxResult = Tax.createFromCode(strCode, this.taxCatalog);
if (!taxResult.isSuccess) {
errors.push({
path: `items[${itemIndex}].taxes[${taxIndex}]`,
message: taxResult.error.message,
});
return;
}
const tax = taxResult.data;
if (tax.isVATLike()) {
if (taxesProps.iva.isSome()) {
errors.push({
path: `items[${itemIndex}].taxes`,
message: "Multiple taxes for group VAT are not allowed",
});
}
taxesProps.iva = Maybe.some(tax);
}
if (tax.isRetention()) {
if (taxesProps.retention.isSome()) {
errors.push({
path: `items[${itemIndex}].taxes`,
message: "Multiple taxes for group retention are not allowed",
});
}
taxesProps.retention = Maybe.some(tax);
}
if (tax.isRec()) {
if (taxesProps.rec.isSome()) {
errors.push({
path: `items[${itemIndex}].taxes`,
message: "Multiple taxes for group rec are not allowed",
});
}
taxesProps.rec = Maybe.some(tax);
}
});
return taxesProps;
}
}

View File

@ -0,0 +1,2 @@
export * from "./create-proforma-input.mapper";
export * from "./update-proforma-input.mapper";

View File

@ -1,3 +1,4 @@
import { InvoiceSerie, type ProformaPatchProps } from "@erp/customer-invoices/api/domain";
import {
CurrencyCode,
DomainError,
@ -13,7 +14,6 @@ import {
import { Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils";
import type { UpdateProformaByIdRequestDTO } from "../../../../../common/dto";
import { type CustomerInvoicePatchProps, CustomerInvoiceSerie } from "../../../../domain";
/**
* UpdateProformaPropsMapper
@ -29,14 +29,14 @@ import { type CustomerInvoicePatchProps, CustomerInvoiceSerie } from "../../../.
*
*/
export function UpdateProformaPropsMapper(dto: UpdateProformaByIdRequestDTO) {
export function UpdateProformaInputMapper(dto: UpdateProformaByIdRequestDTO) {
try {
const errors: ValidationErrorDetail[] = [];
const props: CustomerInvoicePatchProps = {};
const props: ProformaPatchProps = {};
toPatchField(dto.series).ifSet((series) => {
props.series = extractOrPushError(
maybeFromNullableResult(series, (value) => CustomerInvoiceSerie.create(value)),
maybeFromNullableResult(series, (value) => InvoiceSerie.create(value)),
"reference",
errors
);

View File

@ -3,7 +3,7 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CreateProformaRequestDTO } from "../../../../../common";
import type { CreateProformaPropsMapper } from "../../mappers";
import type { ICreateProformaInputMapper } from "../../mappers";
import type { IProformaCreator } from "../../services";
import type { IProformaFullSnapshotBuilder } from "../../snapshot-builders";
@ -13,20 +13,20 @@ type CreateProformaUseCaseInput = {
};
type CreateProformaUseCaseDeps = {
mapper: CreateProformaPropsMapper;
dtoMapper: ICreateProformaInputMapper;
creator: IProformaCreator;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
transactionManager: ITransactionManager;
};
export class CreateProformaUseCase {
private readonly mapper: CreateProformaPropsMapper;
private readonly dtoMapper: ICreateProformaInputMapper;
private readonly creator: IProformaCreator;
private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder;
private readonly transactionManager: ITransactionManager;
constructor(deps: CreateProformaUseCaseDeps) {
this.mapper = deps.mapper;
this.dtoMapper = deps.dtoMapper;
this.creator = deps.creator;
this.fullSnapshotBuilder = deps.fullSnapshotBuilder;
this.transactionManager = deps.transactionManager;
@ -36,7 +36,7 @@ export class CreateProformaUseCase {
const { dto, companyId } = params;
// 1) Mapear DTO → props de dominio
const mappedResult = this.mapper.map(dto, companyId);
const mappedResult = this.dtoMapper.map(dto, companyId);
if (mappedResult.isFailure) {
return Result.fail(mappedResult.error);
}

View File

@ -1,5 +1,5 @@
//export * from "./change-status-proforma.use-case";
//export * from "./create-proforma";
export * from "./create-proforma";
//export * from "./delete-proforma.use-case";
export * from "./get-proforma-by-id.use-case";
//export * from "./issue-proforma.use-case";

View File

@ -21,7 +21,7 @@ import type {
} from "../../common";
import { IssuedInvoiceItems, type IssuedInvoiceTaxes, type VerifactuRecord } from "../entities";
export type IssuedInvoiceProps = {
export interface IIssuedInvoiceProps {
companyId: UniqueID;
status: InvoiceStatus;
@ -65,12 +65,12 @@ export type IssuedInvoiceProps = {
totalAmount: InvoiceAmount;
verifactu: Maybe<VerifactuRecord>;
};
}
export class IssuedInvoice extends AggregateRoot<IssuedInvoiceProps> {
export class IssuedInvoice extends AggregateRoot<IIssuedInvoiceProps> {
private _items!: IssuedInvoiceItems;
protected constructor(props: IssuedInvoiceProps, id?: UniqueID) {
protected constructor(props: IIssuedInvoiceProps, id?: UniqueID) {
super(props, id);
this._items =
props.items ||
@ -81,7 +81,7 @@ export class IssuedInvoice extends AggregateRoot<IssuedInvoiceProps> {
});
}
static create(props: IssuedInvoiceProps, id?: UniqueID): Result<IssuedInvoice, Error> {
static create(props: IIssuedInvoiceProps, id?: UniqueID): Result<IssuedInvoice, Error> {
if (!props.recipient) {
return Result.fail(
new DomainValidationError(
@ -231,7 +231,7 @@ export class IssuedInvoice extends AggregateRoot<IssuedInvoiceProps> {
return this.paymentMethod.isSome();
}
public getProps(): IssuedInvoiceProps {
public getProps(): IIssuedInvoiceProps {
return this.props;
}
}

View File

@ -20,10 +20,18 @@ import {
type InvoiceStatus,
type ItemAmount,
} from "../../common/value-objects";
import { ProformaItems } from "../entities/proforma-items";
import {
type IProformaItemProps,
type IProformaItems,
type IProformaItemsProps,
ProformaItem,
ProformaItems,
} from "../entities/proforma-items";
import { ProformaItemMismatch } from "../errors";
import { type IProformaTaxTotals, ProformaTaxesCalculator } from "../services";
import { ProformaItemTaxes } from "../value-objects";
export type ProformaProps = {
export interface IProformaProps {
companyId: UniqueID;
status: InvoiceStatus;
@ -45,9 +53,9 @@ export type ProformaProps = {
paymentMethod: Maybe<InvoicePaymentMethod>;
items: ProformaItems;
items: IProformaItemsProps[];
globalDiscountPercentage: DiscountPercentage;
};
}
export interface IProformaTotals {
subtotalAmount: InvoiceAmount;
@ -88,31 +96,31 @@ export interface IProforma {
paymentMethod: Maybe<InvoicePaymentMethod>;
items: ProformaItems;
items: IProformaItems;
taxes(): Collection<IProformaTaxTotals>;
totals(): IProformaTotals;
}
export type ProformaPatchProps = Partial<Omit<ProformaProps, "companyId" | "items">> & {
export type ProformaPatchProps = Partial<Omit<IProformaProps, "companyId" | "items">> & {
items?: ProformaItems;
};
export class Proforma extends AggregateRoot<ProformaProps> implements IProforma {
private _items!: ProformaItems;
type CreateProformaProps = IProformaProps;
type InternalProformaProps = Omit<IProformaProps, "items">;
protected constructor(props: ProformaProps, id?: UniqueID) {
super(props, id);
this._items =
props.items ||
ProformaItems.create({
languageCode: props.languageCode,
currencyCode: props.currencyCode,
globalDiscountPercentage: props.globalDiscountPercentage,
});
}
export class Proforma extends AggregateRoot<InternalProformaProps> implements IProforma {
private readonly _items: ProformaItems;
static create(props: ProformaProps, id?: UniqueID): Result<Proforma, Error> {
const proforma = new Proforma(props, id);
// Creación funcional
static create(props: CreateProformaProps, id?: UniqueID): Result<Proforma, Error> {
const { items, ...internalProps } = props;
const proforma = new Proforma(internalProps, id);
const addItemsResult = proforma.initializeItems(items);
if (addItemsResult.isFailure) {
return Result.fail(addItemsResult.error);
}
// Reglas de negocio / validaciones
@ -123,15 +131,30 @@ export class Proforma extends AggregateRoot<ProformaProps> implements IProforma
return Result.ok(proforma);
}
// Mutabilidad
// Rehidratación desde persistencia
static rehydrate(props: InternalProformaProps, id: UniqueID): Proforma {
return new Proforma(props, id);
}
protected constructor(props: InternalProformaProps, id?: UniqueID) {
super(props, id);
this._items = ProformaItems.create({
languageCode: props.languageCode,
currencyCode: props.currencyCode,
globalDiscountPercentage: props.globalDiscountPercentage,
items: [],
});
}
// Mutabilidad
public update(
partialProforma: Partial<Omit<ProformaProps, "companyId">>
partialProforma: Partial<Omit<IProformaProps, "companyId">>
): Result<Proforma, Error> {
const updatedProps = {
...this.props,
...partialProforma,
} as ProformaProps;
} as IProformaProps;
return Proforma.create(updatedProps, this.id);
}
@ -214,8 +237,7 @@ export class Proforma extends AggregateRoot<ProformaProps> implements IProforma
return this.props.globalDiscountPercentage;
}
// Method to get the complete list of line items
public get items(): ProformaItems {
public get items(): IProformaItems {
return this._items;
}
@ -258,12 +280,64 @@ export class Proforma extends AggregateRoot<ProformaProps> implements IProforma
return new ProformaTaxesCalculator(this.items).calculate();
}
public getProps(): ProformaProps {
return this.props;
public addItem(props: IProformaItemProps): Result<void, Error> {
const taxesResult = ProformaItemTaxes.create(props.taxes);
if (taxesResult.isFailure) return Result.fail(taxesResult.error);
const itemResult = ProformaItem.create({
...props,
taxes: taxesResult.data,
});
if (itemResult.isFailure) return Result.fail(itemResult.error);
const added = this._items.add(itemResult.data);
if (!added) {
return Result.fail(new Error("Item rejected due to currency/language mismatch"));
}
return Result.ok();
}
/*public updateItem(itemId: UniqueID, props: IProformaItemProps): Result<void, Error> {
const item = this._items.find((i) => i.id.equals(itemId));
if (!item) {
return Result.fail(new Error("Item not found"));
}
return item.update(props);
}*/
/*public removeItem(itemId: UniqueID): Result<void, Error> {
const removed = this._items.removeWhere(i => i.id.equals(itemId));
if (!removed) {
return Result.fail(new Error("Item not found"));
}
return Result.ok();
}*/
// Helpers
private initializeItems(itemsProps: IProformaItemProps[]): Result<void, Error> {
for (const [index, itemProps] of itemsProps.entries()) {
const itemResult = ProformaItem.create(itemProps);
if (itemResult.isFailure) {
return Result.fail(itemResult.error);
}
const added = this._items.add(itemResult.data);
if (!added) {
return Result.fail(new ProformaItemMismatch(index));
}
}
return Result.ok();
}
/**
* @summary Convierte un ItemAmount a InvoiceAmount (mantiene moneda y escala homogénea).
*/

View File

@ -3,7 +3,10 @@ import { type CurrencyCode, DomainEntity, type LanguageCode, type UniqueID } fro
import { type Maybe, Result } from "@repo/rdx-utils";
import { ItemAmount, type ItemDescription, type ItemQuantity } from "../../../common";
import type { ProformaItemTaxes } from "../../value-objects/proforma-item-taxes.vo";
import {
ProformaItemTaxes,
type ProformaItemTaxesProps,
} from "../../value-objects/proforma-item-taxes.vo";
/**
*
@ -22,7 +25,7 @@ import type { ProformaItemTaxes } from "../../value-objects/proforma-item-taxes.
*
*/
export type ProformaItemProps = {
export interface IProformaItemProps {
description: Maybe<ItemDescription>;
quantity: Maybe<ItemQuantity>; // Cantidad de unidades
@ -30,14 +33,14 @@ export type ProformaItemProps = {
itemDiscountPercentage: Maybe<DiscountPercentage>; // % descuento de línea
taxes: ProformaItemTaxes;
taxes: ProformaItemTaxesProps;
// Estos campos vienen de la cabecera,
// pero se necesitan para cálculos y representaciones de la línea.
globalDiscountPercentage: DiscountPercentage; // % descuento de la cabecera
languageCode: LanguageCode; // Para formateos específicos de idioma
currencyCode: CurrencyCode; // Para cálculos y formateos de moneda
};
}
export interface IProformaItemTotals {
subtotalAmount: ItemAmount;
@ -84,9 +87,26 @@ export interface IProformaItem {
isValued(): boolean; // Indica si el item tiene cantidad o precio (o ambos) para ser considerado "valorizado"
}
export class ProformaItem extends DomainEntity<ProformaItemProps> implements IProformaItem {
public static create(props: ProformaItemProps, id?: UniqueID): Result<ProformaItem, Error> {
const item = new ProformaItem(props, id);
type CreateProformaItemProps = IProformaItemProps;
type InternalProformaItemProps = Omit<IProformaItemProps, "taxes"> & {
taxes: ProformaItemTaxes;
};
export class ProformaItem extends DomainEntity<InternalProformaItemProps> implements IProformaItem {
public static create(props: CreateProformaItemProps, id?: UniqueID): Result<ProformaItem, Error> {
const taxesResult = ProformaItemTaxes.create(props.taxes);
if (taxesResult.isFailure) {
return Result.fail(taxesResult.error);
}
const item = new ProformaItem(
{
...props,
taxes: taxesResult.data,
},
id
);
// Reglas de negocio / validaciones
// ...
@ -95,7 +115,11 @@ export class ProformaItem extends DomainEntity<ProformaItemProps> implements IPr
return Result.ok(item);
}
protected constructor(props: ProformaItemProps, id?: UniqueID) {
static rehydrate(props: InternalProformaItemProps, id: UniqueID): ProformaItem {
return new ProformaItem(props, id);
}
protected constructor(props: InternalProformaItemProps, id?: UniqueID) {
super(props, id);
}
@ -131,7 +155,7 @@ export class ProformaItem extends DomainEntity<ProformaItemProps> implements IPr
return this.props.taxes;
}
getProps(): ProformaItemProps {
getProps(): IProformaItemProps {
return this.props;
}

View File

@ -1,28 +1,37 @@
import type { DiscountPercentage } from "@erp/core/api";
import type { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import { Collection } from "@repo/rdx-utils";
import { Collection, Result } from "@repo/rdx-utils";
import { ProformaItemMismatch } from "../../errors";
import { ProformaItemsTotalsCalculator } from "../../services/proforma-items-totals-calculator";
import type { IProformaItem, IProformaItemTotals, ProformaItem } from "./proforma-item.entity";
import type {
ICreateProformaItemProps,
IProformaItem,
IProformaItemTotals,
ProformaItem,
} from "./proforma-item.entity";
export type ProformaItemsProps = {
items?: ProformaItem[];
export interface IProformaItemsProps {
items?: ICreateProformaItemProps[];
// Estos campos vienen de la cabecera,
// pero se necesitan para cálculos y representaciones de la línea.
globalDiscountPercentage: DiscountPercentage; // % descuento de la cabecera
languageCode: LanguageCode; // Para formateos específicos de idioma
currencyCode: CurrencyCode; // Para cálculos y formateos de moneda
};
}
export interface IProformaItems {
// OJO, no extendemos de Collection<IProformaItem> para no exponer
// públicamente métodos para manipular la colección.
export interface IProformaItems extends Collection<IProformaItem> {
valued(): IProformaItem[]; // Devuelve solo las líneas valoradas.
totals(): IProformaItemTotals;
globalDiscountPercentage: DiscountPercentage; // % descuento de la cabecera
languageCode: LanguageCode; // Para formateos específicos de idioma
currencyCode: CurrencyCode; // Para cálculos y formateos de moneda
readonly globalDiscountPercentage: DiscountPercentage; // % descuento de la cabecera
readonly languageCode: LanguageCode; // Para formateos específicos de idioma
readonly currencyCode: CurrencyCode; // Para cálculos y formateos de moneda
}
export class ProformaItems extends Collection<ProformaItem> implements IProformaItems {
@ -30,7 +39,7 @@ export class ProformaItems extends Collection<ProformaItem> implements IProforma
public readonly currencyCode!: CurrencyCode;
public readonly globalDiscountPercentage!: DiscountPercentage;
constructor(props: ProformaItemsProps) {
constructor(props: IProformaItemsProps) {
super(props.items ?? []);
this.languageCode = props.languageCode;
this.currencyCode = props.currencyCode;
@ -39,7 +48,7 @@ export class ProformaItems extends Collection<ProformaItem> implements IProforma
this.ensureSameCurrencyAndLanguage(this.items);
}
public static create(props: ProformaItemsProps): ProformaItems {
public static create(props: IProformaItemsProps): ProformaItems {
return new ProformaItems(props);
}
@ -53,10 +62,10 @@ export class ProformaItems extends Collection<ProformaItem> implements IProforma
* @returns `true` si el ítem fue añadido correctamente; `false` si fue rechazado.
* @remarks
* Sólo se aceptan ítems cuyo `LanguageCode` y `CurrencyCode` coincidan con
* los de la colección. Si no coinciden, el método devuelve `false` sin modificar
* los de la colección. Si no coinciden, el método devuelve un resultado fallido sin modificar
* la colección.
*/
public add(item: ProformaItem): boolean {
public addItem(item: ProformaItem): Result<void, Error> {
// Antes de añadir un nuevo item, debo comprobar que el item a añadir
// tiene el mismo "currencyCode" y "languageCode" que la colección de items.
const same =
@ -64,9 +73,11 @@ export class ProformaItems extends Collection<ProformaItem> implements IProforma
this.currencyCode.equals(item.currencyCode) &&
this.globalDiscountPercentage.equals(item.globalDiscountPercentage);
if (!same) return false;
return super.add(item);
if (!same) {
return Result.fail(new ProformaItemMismatch(this.size()));
}
super.add(item);
return Result.ok();
}
// Cálculos

View File

@ -3,3 +3,4 @@ export * from "./entity-is-not-proforma-error";
export * from "./invalid-proforma-transition-error";
export * from "./proforma-cannot-be-converted-to-invoice-error";
export * from "./proforma-cannot-be-deleted-error";
export * from "./proforma-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 ProformaItemMismatch 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. Proforma item with position '${position}' rejected due to currency/language mismatch.`,
options
);
this.name = "ProformaItemMismatch";
}
}
/**
* *Type guard* para `ProformaItemNotValid`.
*
* @param e - Error desconocido
* @returns `true` si `e` es `ProformaItemNotValid`
*/
export const isProformaItemMismatch = (e: unknown): e is ProformaItemMismatch =>
e instanceof ProformaItemMismatch;

View File

@ -2,14 +2,14 @@ import type { IModuleServer } from "@erp/core/api";
import {
type IssuedInvoicesInternalDeps,
type ProformasInternalDeps,
buildIssuedInvoiceServices,
buildIssuedInvoicesDependencies,
buildProformaServices,
buildProformasDependencies,
models,
} from "./infrastructure";
import { issuedInvoicesRouter } from "./infrastructure/express";
import { proformasRouter } from './infrastructure/express';
import { issuedInvoicesRouter, proformasRouter } from "./infrastructure/express";
export const customerInvoicesAPIModule: IModuleServer = {
name: "customer-invoices",
@ -43,13 +43,13 @@ export const customerInvoicesAPIModule: IModuleServer = {
// Servicios expuestos a otros módulos
services: {
issuedInvoices: issuedInvoicesServices,
proformas: proformasServices
proformas: proformasServices,
},
// Implementación privada del módulo
internal: {
issuedInvoices: issuedInvoicesInternalDeps,
proformas: proformasInternalDeps
proformas: proformasInternalDeps,
},
};
},
@ -69,8 +69,11 @@ export const customerInvoicesAPIModule: IModuleServer = {
"customer-invoices",
"issuedInvoices"
);
const proformasInternalDeps = getInternal("customer-invoices", "proformas");
const proformasInternalDeps = getInternal<ProformasInternalDeps>(
"customer-invoices",
"proformas"
);
// Registro de rutas HTTP
issuedInvoicesRouter(params, issuedInvoicesInternalDeps);

View File

@ -13,11 +13,13 @@ import {
type EntityIsNotProformaError,
type InvalidProformaTransitionError,
type ProformaCannotBeConvertedToInvoiceError,
type ProformaItemMismatch,
isCustomerInvoiceIdAlreadyExistsError,
isEntityIsNotProformaError,
isInvalidProformaTransitionError,
isProformaCannotBeConvertedToInvoiceError,
isProformaCannotBeDeletedError,
isProformaItemMismatch,
} from "../../../domain";
// Crea una regla específica (prioridad alta para sobreescribir mensajes)
@ -40,6 +42,16 @@ const entityIsNotProformaError: ErrorToApiRule = {
),
};
const proformaItemMismatchError: ErrorToApiRule = {
priority: 120,
matches: (e) => isProformaItemMismatch(e),
build: (e) =>
new ValidationApiError(
(e as ProformaItemMismatch).message ||
"Proforma item rejected due to currency/language mismatch"
),
};
const proformaTransitionRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isInvalidProformaTransitionError(e),
@ -71,6 +83,7 @@ const proformaCannotBeDeletedRule: ErrorToApiRule = {
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const customerInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(invoiceDuplicateRule)
.register(proformaItemMismatchError)
.register(entityIsNotProformaError)
.register(proformaConversionRule)
.register(proformaCannotBeDeletedRule)

View File

@ -16,6 +16,7 @@ import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import type { IIssuedInvoiceDomainMapper } from "../../../../../../application";
import {
DiscountPercentage,
type IIssuedInvoiceProps,
InvoiceAmount,
InvoiceNumber,
InvoicePaymentMethod,
@ -23,7 +24,6 @@ import {
InvoiceStatus,
IssuedInvoice,
IssuedInvoiceItems,
type IssuedInvoiceProps,
IssuedInvoiceTaxes,
} from "../../../../../../domain";
import type {
@ -351,7 +351,7 @@ export class SequelizeIssuedInvoiceDomainMapper
currencyCode: attributes.currencyCode!,
});
const invoiceProps: IssuedInvoiceProps = {
const invoiceProps: IIssuedInvoiceProps = {
companyId: attributes.companyId!,
proformaId: attributes.proformaId!,

View File

@ -14,10 +14,10 @@ import { Result } from "@repo/rdx-utils";
import {
DiscountPercentage,
type IIssuedInvoiceProps,
type IssuedInvoice,
IssuedInvoiceItem,
type IssuedInvoiceItemProps,
type IssuedInvoiceProps,
ItemAmount,
ItemDescription,
ItemDiscountPercentage,
@ -56,7 +56,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
const { errors, index, attributes } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<IssuedInvoiceProps>;
attributes: Partial<IIssuedInvoiceProps>;
};
const itemId = extractOrPushError(
@ -263,7 +263,7 @@ export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMappe
const { errors, index } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<IssuedInvoiceProps>;
attributes: Partial<IIssuedInvoiceProps>;
};
// 1) Valores escalares (atributos generales)

View File

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

View File

@ -14,9 +14,9 @@ import {
import { Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceProps,
InvoiceAmount,
type IssuedInvoice,
type IssuedInvoiceProps,
IssuedInvoiceTax,
ItemAmount,
ItemDiscountPercentage,
@ -66,7 +66,7 @@ export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapp
const { errors, index, attributes } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<IssuedInvoiceProps>;
attributes: Partial<IIssuedInvoiceProps>;
};
const taxableAmount = extractOrPushError(

View File

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

View File

@ -5,7 +5,7 @@ import {
type ProformaDocumentPipelineFactoryDeps,
} from "../documents";
export const buildproformaDocumentService = (params: ModuleParams) => {
export const buildProformaDocumentService = (params: ModuleParams) => {
const { documentRenderers, documentSigning, documentStorage } = buildCoreDocumentsDI(params);
const pipelineDeps: ProformaDocumentPipelineFactoryDeps = {

View File

@ -0,0 +1,31 @@
import type { ICatalogs, IProformaDomainMapper, IProformaListMapper } from "../../../application";
import { SequelizeProformaDomainMapper, SequelizeProformaListMapper } from "../persistence";
export interface IProformaPersistenceMappers {
domainMapper: IProformaDomainMapper;
listMapper: IProformaListMapper;
createMapper: CreateProformaInputMapper;
}
export const buildProformaPersistenceMappers = (
catalogs: ICatalogs
): IProformaPersistenceMappers => {
const { taxCatalog } = catalogs;
// Mappers para el repositorio
const domainMapper = new SequelizeProformaDomainMapper({
taxCatalog,
});
const listMapper = new SequelizeProformaListMapper();
// Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado
const createMapper = new CreateProformaInputMapper({ taxCatalog });
return {
domainMapper,
listMapper,
createMapper,
};
};

View File

@ -1,19 +1,14 @@
import { SpainTaxCatalogProvider } from "@erp/core";
import type { Sequelize } from "sequelize";
import {
ProformaRepository,
SequelizeProformaDomainMapper,
SequelizeProformaListMapper,
} from "../persistence";
import { ProformaRepository } from "../persistence";
export const buildProformaRepository = (database: Sequelize) => {
const taxCatalog = SpainTaxCatalogProvider();
import type { IProformaPersistenceMappers } from "./proforma-persistence-mappers.di";
const domainMapper = new SequelizeProformaDomainMapper({
taxCatalog,
});
const listMapper = new SequelizeProformaListMapper();
export const buildProformaRepository = (params: {
database: Sequelize;
mappers: IProformaPersistenceMappers;
}) => {
const { database, mappers } = params;
return new ProformaRepository(domainMapper, listMapper, database);
return new ProformaRepository(mappers.domainMapper, mappers.listMapper, database);
};

View File

@ -1,17 +1,23 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api";
import {
type CreateProformaUseCase,
type GetProformaByIdUseCase,
type ListProformasUseCase,
type ReportProformaUseCase,
buildCreateProformaUseCase,
buildGetProformaByIdUseCase,
buildListProformasUseCase,
buildProformaCreator,
buildProformaFinder,
buildProformaInputMappers,
buildProformaSnapshotBuilders,
buildReportProformaUseCase,
} from "../../../application";
import { buildproformaDocumentService } from "./proforma-documents.di";
import { buildProformaDocumentService } from "./proforma-documents.di";
import { buildProformaNumberGenerator } from "./proforma-number-generator.di";
import { buildProformaPersistenceMappers } from "./proforma-persistence-mappers.di";
import { buildProformaRepository } from "./proforma-repositories.di";
export type ProformasInternalDeps = {
@ -19,8 +25,9 @@ export type ProformasInternalDeps = {
listProformas: () => ListProformasUseCase;
getProformaById: () => GetProformaByIdUseCase;
reportProforma: () => ReportProformaUseCase;
createProforma: () => CreateProformaUseCase;
/*createProforma: () => CreateProformaUseCase;
/*
updateProforma: () => UpdateProformaUseCase;
deleteProforma: () => DeleteProformaUseCase;
issueProforma: () => IssueProformaUseCase;
@ -33,14 +40,19 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
// Infrastructure
const transactionManager = buildTransactionManager(database);
const repository = buildProformaRepository(database);
//const numberService = buildProformaNumberGenerator();
const catalogs = buildCatalogs();
const persistenceMappers = buildProformaPersistenceMappers(catalogs);
const repository = buildProformaRepository({ database, mappers: persistenceMappers });
const numberService = buildProformaNumberGenerator();
// Application helpers
const inputMappers = buildProformaInputMappers(catalogs);
const finder = buildProformaFinder(repository);
//const creator = buildProformaCreator(numberService, repository);
const creator = buildProformaCreator({ numberService, repository });
const snapshotBuilders = buildProformaSnapshotBuilders();
const documentGeneratorPipeline = buildproformaDocumentService(params);
const documentGeneratorPipeline = buildProformaDocumentService(params);
// Internal use cases (factories)
return {
@ -68,12 +80,13 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
transactionManager,
}),
/*createProforma: () =>
createProforma: () =>
buildCreateProformaUseCase({
creator,
dtoMapper: inputMappers.createInputMapper,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),*/
}),
},
};
}

View File

@ -1,5 +1,4 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { Tax } from "@erp/core/api";
import {
CurrencyCode,
DomainError,
@ -15,8 +14,10 @@ import {
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../common";
import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../../common";
import {
type IProformaItemProps,
type IProformaProps,
InvoiceNumber,
InvoicePaymentMethod,
type InvoiceRecipient,
@ -26,9 +27,8 @@ import {
type IssuedInvoiceItemProps,
ItemAmount,
ItemDescription,
ItemDiscountPercentage,
ItemQuantity,
} from "../../../domain";
} from "../../../../domain";
/**
* CreateProformaPropsMapper
@ -41,7 +41,8 @@ import {
*
*/
export class CreateProformaPropsMapper {
export class CreateProformaRequestMapper {
private readonly taxCatalog: JsonTaxCatalogProvider;
private errors: ValidationErrorDetail[] = [];
private languageCode?: LanguageCode;
@ -52,7 +53,8 @@ export class CreateProformaPropsMapper {
this.errors = [];
}
public map(dto: CreateProformaRequestDTO, companyId: UniqueID) {
public map(dto: CreateProformaRequestDTO, params: { companyId: UniqueID }) {
const { companyId } = params;
try {
this.errors = [];
@ -60,8 +62,6 @@ export class CreateProformaPropsMapper {
const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", this.errors);
const isProforma = true;
const customerId = extractOrPushError(
UniqueID.create(dto.customer_id),
"customer_id",
@ -132,7 +132,7 @@ export class CreateProformaPropsMapper {
this.errors
);
const discountPercentage = extractOrPushError(
const globalDiscountPercentage = extractOrPushError(
Percentage.create({
value: Number(dto.discount_percentage.value),
scale: Number(dto.discount_percentage.scale),
@ -149,10 +149,8 @@ export class CreateProformaPropsMapper {
);
}
const proformaProps: IProformaProps = {
const proformaProps: Omit<IProformaProps, "items"> & { items: IProformaItemProps[] } = {
companyId,
isProforma,
proformaId: Maybe.none(),
status: defaultStatus!,
invoiceNumber: proformaNumber!,
@ -169,13 +167,13 @@ export class CreateProformaPropsMapper {
notes: notes!,
languageCode: this.languageCode!,
currencyCode: this.currencyCode!,
items: items,
currencyCode: this.currencyCode!,
paymentMethod: paymentMethod!,
discountPercentage: discountPercentage!,
globalDiscountPercentage: globalDiscountPercentage!,
items:
};
return Result.ok({ id: proformaId!, props: proformaProps });
@ -184,8 +182,8 @@ export class CreateProformaPropsMapper {
}
}
private mapItems(items: CreateProformaItemRequestDTO[]) {
const invoiceItems = CustomerInvoiceItems.create({
private mapItems(items: CreateProformaItemRequestDTO[]): IProformaItemProps[] {
const proformaItems = CustomerInvoiceItems.create({
currencyCode: this.currencyCode!,
languageCode: this.languageCode!,
items: [],
@ -232,7 +230,7 @@ export class CreateProformaPropsMapper {
const itemResult = IssuedInvoiceItem.create(itemProps);
if (itemResult.isSuccess) {
invoiceItems.add(itemResult.data);
proformaItems.add(itemResult.data);
} else {
this.errors.push({
path: `items[${index}]`,
@ -240,7 +238,7 @@ export class CreateProformaPropsMapper {
});
}
});
return invoiceItems;
return proformaItems;
}
private mapTaxes(item: CreateProformaItemRequestDTO, itemIndex: number) {

View File

@ -15,13 +15,13 @@ import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import type { IProformaDomainMapper } from "../../../../../../application";
import {
type IProformaProps,
InvoiceNumber,
InvoicePaymentMethod,
InvoiceSerie,
InvoiceStatus,
Proforma,
ProformaItems,
type ProformaProps,
} from "../../../../../../domain";
import type {
CustomerInvoiceCreationAttributes,
@ -217,7 +217,7 @@ export class SequelizeProformaDomainMapper
items: itemsResults.data.getAll(),
});
const invoiceProps: ProformaProps = {
const invoiceProps: IProformaProps = {
companyId: attributes.companyId!,
status: attributes.status!,

View File

@ -16,15 +16,15 @@ import {
import { Result } from "@repo/rdx-utils";
import {
type IProformaItemProps,
type IProformaProps,
ItemAmount,
ItemDescription,
ItemQuantity,
type Proforma,
ProformaItem,
type ProformaItemProps,
ProformaItemTaxes,
type ProformaItemTaxesProps,
type ProformaProps,
} from "../../../../../../domain";
import type {
CustomerInvoiceItemCreationAttributes,
@ -54,11 +54,11 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
private mapAttributesToDomain(
raw: CustomerInvoiceItemModel,
params?: MapperParamsType
): Partial<ProformaItemProps & ProformaItemTaxesProps> & { itemId?: UniqueID } {
): Partial<IProformaItemProps & ProformaItemTaxesProps> & { itemId?: UniqueID } {
const { errors, index, parent } = params as {
index: number;
errors: ValidationErrorDetail[];
parent: Partial<ProformaProps>;
parent: Partial<IProformaProps>;
};
const itemId = extractOrPushError(
@ -139,7 +139,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
const { errors, index } = params as {
index: number;
errors: ValidationErrorDetail[];
parent: Partial<ProformaProps>;
parent: Partial<IProformaProps>;
};
// 1) Valores escalares (atributos generales)

View File

@ -14,7 +14,7 @@ import {
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InvoiceRecipient, type ProformaProps } from "../../../../../../domain";
import { type IProformaProps, InvoiceRecipient } from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../../common";
export class SequelizeProformaRecipientDomainMapper {
@ -28,7 +28,7 @@ export class SequelizeProformaRecipientDomainMapper {
const { errors, parent } = params as {
errors: ValidationErrorDetail[];
parent: Partial<ProformaProps>;
parent: Partial<IProformaProps>;
};
/* if (!source.current_customer) {

View File

@ -9,22 +9,26 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize";
import type { IProformaRepository, ProformaListDTO } from "../../../../../application";
import type {
IProformaDomainMapper,
IProformaListMapper,
IProformaRepository,
ProformaListDTO,
} from "../../../../../application";
import type { InvoiceStatus, Proforma } from "../../../../../domain";
import {
CustomerInvoiceItemModel,
CustomerInvoiceModel,
CustomerInvoiceTaxModel,
} from "../../../../common";
import type { SequelizeProformaDomainMapper, SequelizeProformaListMapper } from "../mappers";
export class ProformaRepository
extends SequelizeRepository<Proforma>
implements IProformaRepository
{
constructor(
private readonly domainMapper: SequelizeProformaDomainMapper,
private readonly listMapper: SequelizeProformaListMapper,
private readonly domainMapper: IProformaDomainMapper,
private readonly listMapper: IProformaListMapper,
database: Sequelize
) {
super({ database });

View File

@ -31,7 +31,8 @@
"include": [
"src",
"../core/src/api/domain/value-objects/tax-percentage.vo.ts",
"../core/src/api/domain/value-objects/discount-percentage.vo.ts"
"../core/src/api/domain/value-objects/discount-percentage.vo.ts",
"../core/src/api/infrastructure/di/catalogs.di.ts"
],
"exclude": ["node_modules"]
}

View File

@ -13,6 +13,8 @@
"dev": "turbo dev",
"dev:server": "turbo dev --filter=server",
"dev:client": "turbo dev --filter=client",
"lint": "turbo run lint",
"lint:fix": "turbo run lint:fix",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write",
"ui:add": "pnpm --filter @repo/shadcn-ui ui:add",
@ -23,7 +25,10 @@
"devDependencies": {
"@biomejs/biome": "2.3.1",
"@repo/typescript-config": "workspace:*",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"change-case": "^5.4.4",
"eslint": "^10.0.2",
"inquirer": "^12.10.0",
"plop": "^4.0.4",
"rimraf": "^5.0.5",

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,16 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"globalDependencies": ["**/.env.*local"],
"globalDependencies": [
"**/.env.*local"
],
"tasks": {
"lint": {
"outputs": []
},
"lint:fix": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
@ -13,16 +21,25 @@
},
"build": {
"cache": false,
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
"dependsOn": [
"^build"
],
"inputs": [
"$TURBO_DEFAULT$",
".env*"
],
"outputs": [
"dist/**"
]
},
"build:templates": {
"dependsOn": [],
"outputs": ["dist/templates/**"]
"outputs": [
"dist/templates/**"
]
},
"clean": {
"cache": false
}
}
}
}