diff --git a/modules/core/src/api/domain/index.ts b/modules/core/src/api/domain/index.ts index 49bbc161..ba0555aa 100644 --- a/modules/core/src/api/domain/index.ts +++ b/modules/core/src/api/domain/index.ts @@ -1 +1,2 @@ export * from "./errors"; +export * from "./value-objects"; diff --git a/modules/core/src/api/domain/value-objects/__tests__/tax.test.ts b/modules/core/src/api/domain/value-objects/__tests__/tax.test.ts new file mode 100644 index 00000000..8a035121 --- /dev/null +++ b/modules/core/src/api/domain/value-objects/__tests__/tax.test.ts @@ -0,0 +1,296 @@ +import { Tax } from "../tax"; + +describe("Tax Value Object", () => { + describe("Creación", () => { + test("debe crear una tasa de impuesto válida", () => { + const result = Tax.create({ + value: 2100, // 21.00% + scale: 2, + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result.isSuccess).toBe(true); + if (result.isSuccess) { + const tax = result.value; + expect(tax.value).toBe(2100); + expect(tax.scale).toBe(2); + expect(tax.name).toBe("IVA General"); + expect(tax.code).toBe("IVA_GENERAL"); + expect(tax.toNumber()).toBe(21.0); + expect(tax.toString()).toBe("21.00%"); + } + }); + + test("debe usar escala por defecto cuando no se proporciona", () => { + const result = Tax.create({ + value: 21, // 21% + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result.isSuccess).toBe(true); + if (result.isSuccess) { + const tax = result.value; + expect(tax.scale).toBe(Tax.DEFAULT_SCALE); + expect(tax.toNumber()).toBe(21.0); + } + }); + + test("debe fallar con valor negativo", () => { + const result = Tax.create({ + value: -100, + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result.isFailure).toBe(true); + if (result.isFailure) { + expect(result.error.message).toContain("no puede ser negativa"); + } + }); + + test("debe fallar con tasa mayor a 100%", () => { + const result = Tax.create({ + value: 10100, // 101.00% + scale: 2, + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result.isFailure).toBe(true); + if (result.isFailure) { + expect(result.error.message).toContain("no puede ser mayor a 100%"); + } + }); + + test("debe fallar con nombre vacío", () => { + const result = Tax.create({ + value: 2100, + name: "", + code: "IVA_GENERAL", + }); + + expect(result.isFailure).toBe(true); + if (result.isFailure) { + expect(result.error.message).toContain("El nombre del impuesto es obligatorio"); + } + }); + + test("debe fallar con código vacío", () => { + const result = Tax.create({ + value: 2100, + name: "IVA General", + code: "", + }); + + expect(result.isFailure).toBe(true); + if (result.isFailure) { + expect(result.error.message).toContain("El código del impuesto es obligatorio"); + } + }); + + test("debe fallar con código con caracteres inválidos", () => { + const result = Tax.create({ + value: 2100, + name: "IVA General", + code: "iva-general", // minúsculas no permitidas + }); + + expect(result.isFailure).toBe(true); + if (result.isFailure) { + expect(result.error.message).toContain("solo letras mayúsculas"); + } + }); + + test("debe fallar con escala fuera de rango", () => { + const result = Tax.create({ + value: 2100, + scale: 5, // Máximo permitido es 4 + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result.isFailure).toBe(true); + if (result.isFailure) { + expect(result.error.message).toContain("La escala debe estar entre"); + } + }); + }); + + describe("Métodos de cálculo", () => { + let tax: Tax; + + beforeEach(() => { + const result = Tax.create({ + value: 2100, // 21.00% + scale: 2, + name: "IVA General", + code: "IVA_GENERAL", + }); + expect(result.isSuccess).toBe(true); + if (result.isSuccess) { + tax = result.value; + } + }); + + test("debe calcular correctamente el importe del impuesto", () => { + const baseAmount = 1000; + const taxAmount = tax.calculateAmount(baseAmount); + expect(taxAmount).toBe(210); // 21% de 1000 + }); + + test("debe calcular correctamente con decimales", () => { + const baseAmount = 123.45; + const taxAmount = tax.calculateAmount(baseAmount); + expect(taxAmount).toBeCloseTo(25.9245, 4); // 21% de 123.45 + }); + }); + + describe("Métodos de comparación", () => { + let tax1: Tax; + let tax2: Tax; + let tax3: Tax; + + beforeEach(() => { + const result1 = Tax.create({ + value: 2100, // 21.00% + scale: 2, + name: "IVA General", + code: "IVA_GENERAL", + }); + const result2 = Tax.create({ + value: 1000, // 10.00% + scale: 2, + name: "IVA Reducido", + code: "IVA_REDUCIDO", + }); + const result3 = Tax.create({ + value: 2100, // 21.00% + scale: 2, + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result1.isSuccess && result2.isSuccess && result3.isSuccess).toBe(true); + if (result1.isSuccess && result2.isSuccess && result3.isSuccess) { + tax1 = result1.value; + tax2 = result2.value; + tax3 = result3.value; + } + }); + + test("debe considerar dos tasas con los mismos valores como iguales", () => { + expect(tax1.equalsTo(tax3)).toBe(true); + }); + + test("debe considerar dos tasas con valores diferentes como distintas", () => { + expect(tax1.equalsTo(tax2)).toBe(false); + }); + + test("debe comparar correctamente tasas mayores", () => { + expect(tax1.greaterThan(tax2)).toBe(true); + expect(tax2.greaterThan(tax1)).toBe(false); + }); + + test("debe comparar correctamente tasas menores", () => { + expect(tax2.lessThan(tax1)).toBe(true); + expect(tax1.lessThan(tax2)).toBe(false); + }); + }); + + describe("Métodos de verificación", () => { + test("debe identificar correctamente una tasa cero", () => { + const result = Tax.create({ + value: 0, + name: "Sin IVA", + code: "SIN_IVA", + }); + + expect(result.isSuccess).toBe(true); + if (result.isSuccess) { + const tax = result.value; + expect(tax.isZero()).toBe(true); + expect(tax.isPositive()).toBe(false); + } + }); + + test("debe identificar correctamente una tasa positiva", () => { + const result = Tax.create({ + value: 2100, + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result.isSuccess).toBe(true); + if (result.isSuccess) { + const tax = result.value; + expect(tax.isZero()).toBe(false); + expect(tax.isPositive()).toBe(true); + } + }); + }); + + describe("Representación", () => { + test("debe devolver la representación JSON correcta", () => { + const result = Tax.create({ + value: 2100, + scale: 2, + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result.isSuccess).toBe(true); + if (result.isSuccess) { + const tax = result.value; + const json = tax.toJSON(); + + expect(json).toEqual({ + value: 2100, + scale: 2, + name: "IVA General", + code: "IVA_GENERAL", + percentage: 21.0, + formatted: "21.00%", + }); + } + }); + + test("debe formatear correctamente diferentes escalas", () => { + const result = Tax.create({ + value: 21000, // 21.000% + scale: 3, + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result.isSuccess).toBe(true); + if (result.isSuccess) { + const tax = result.value; + expect(tax.toString()).toBe("21.000%"); + expect(tax.toNumber()).toBe(21.0); + } + }); + }); + + describe("Inmutabilidad", () => { + test("debe ser inmutable", () => { + const result = Tax.create({ + value: 2100, + name: "IVA General", + code: "IVA_GENERAL", + }); + + expect(result.isSuccess).toBe(true); + if (result.isSuccess) { + const tax = result.value; + const props = tax.getProps(); + + // Intentar modificar las propiedades debe fallar + expect(() => { + (props as any).value = 3000; + }).toThrow(); + } + }); + }); +}); diff --git a/modules/core/src/api/domain/value-objects/index.ts b/modules/core/src/api/domain/value-objects/index.ts new file mode 100644 index 00000000..41ca139d --- /dev/null +++ b/modules/core/src/api/domain/value-objects/index.ts @@ -0,0 +1,2 @@ +export * from "./tax"; +export * from "./taxes"; diff --git a/modules/core/src/api/domain/value-objects/tax.ts b/modules/core/src/api/domain/value-objects/tax.ts new file mode 100644 index 00000000..938e95f8 --- /dev/null +++ b/modules/core/src/api/domain/value-objects/tax.ts @@ -0,0 +1,162 @@ +import { TaxCatalogProvider } from "@erp/core"; +import { ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import * as z from "zod/v4"; + +const DEFAULT_SCALE = 2; +const DEFAULT_MIN_VALUE = 0; +const DEFAULT_MAX_VALUE = 100; + +const DEFAULT_MIN_SCALE = 0; +const DEFAULT_MAX_SCALE = 4; + +export interface TaxProps { + value: number; + scale: number; + name: string; + code: string; +} + +export class Tax extends ValueObject { + 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; + + private static CODE_REGEX = /^[a-z0-9_:-]+$/; + + protected static validate(values: TaxProps) { + const schema = z.object({ + value: z + .number() + .int() + .min(Tax.MIN_VALUE, "La tasa de impuesto no puede ser negativa.") + .max(Tax.MAX_VALUE * 10 ** Tax.MAX_SCALE, "La tasa de impuesto es demasiado alta."), + scale: z + .number() + .int() + .min(Tax.MIN_SCALE) + .max(Tax.MAX_SCALE, `La escala debe estar entre ${Tax.MIN_SCALE} y ${Tax.MAX_SCALE}.`), + name: z + .string() + .min(1, "El nombre del impuesto es obligatorio.") + .max(100, "El nombre del impuesto no puede exceder 100 caracteres."), + code: z + .string() + .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."), + }); + + return schema.safeParse(values); + } + + static create(props: TaxProps): Result { + const { value, scale = Tax.DEFAULT_SCALE, name, code } = props; + + const validationResult = Tax.validate({ value, scale, name, code }); + if (!validationResult.success) { + return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", "))); + } + + const realValue = value / 10 ** scale; + if (realValue > Tax.MAX_VALUE) { + return Result.fail(new Error("La tasa de impuesto no puede ser mayor a 100%.")); + } + + return Result.ok(new Tax({ value, scale, name, code })); + } + + /** + * Crea un Tax usando solo el 'code', resolviendo el resto de datos desde el catálogo. + * @param code Código del impuesto (p.ej. "iva_21") + * @param provider Proveedor del catálogo de impuestos + */ + static createFromCode(code: string, provider: TaxCatalogProvider): Result { + const normalized = (code ?? "").trim().toLowerCase(); + if (!normalized || !Tax.CODE_REGEX.test(normalized)) { + return Result.fail(new Error(`Código de impuesto inválido: "${code}"`)); + } + + const maybeItem = provider.findByCode(normalized); + if (maybeItem.isNone()) { + return Result.fail( + new Error(`Código de impuesto no encontrado en el catálogo: "${normalized}"`) + ); + } + + const item = maybeItem.unwrap()!; + // Delegamos en create para reusar validación y límites + return Tax.create({ + value: item.value, + scale: item.scale ?? Tax.DEFAULT_SCALE, + name: item.name, + code: item.code, // guardamos el code tal cual viene del catálogo + }); + } + + get value(): number { + return this.props.value; + } + get scale(): number { + return this.props.scale; + } + get name(): string { + return this.props.name; + } + get code(): string { + return this.props.code; + } + + getProps(): TaxProps { + return this.props; + } + + toPrimitive() { + 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)}%`; + } + + /** Calcula el importe del impuesto sobre una base imponible */ + calculateAmount(baseAmount: number): number { + return (baseAmount * this.toNumber()) / 100; + } + + isZero(): boolean { + return this.toNumber() === 0; + } + isPositive(): boolean { + return this.toNumber() > 0; + } + + equalsTo(other: Tax): boolean { + return this.equals(other); + } + greaterThan(other: Tax): boolean { + return this.toNumber() > other.toNumber(); + } + lessThan(other: Tax): boolean { + return this.toNumber() < other.toNumber(); + } + + toJSON() { + return { + value: this.value, + scale: this.scale, + name: this.name, + code: this.code, + percentage: this.toNumber(), + formatted: this.toString(), + }; + } +} diff --git a/modules/core/src/api/domain/value-objects/taxes.ts b/modules/core/src/api/domain/value-objects/taxes.ts new file mode 100644 index 00000000..721aeaa7 --- /dev/null +++ b/modules/core/src/api/domain/value-objects/taxes.ts @@ -0,0 +1,17 @@ +import { Collection } from "@repo/rdx-utils"; +import { Tax } from "./tax"; + +export interface TaxesProps { + items?: Tax[]; +} + +export class Taxes extends Collection { + constructor(props: TaxesProps) { + const { items } = props; + super(items); + } + + public static create(props: TaxesProps): Taxes { + return new Taxes(props); + } +} diff --git a/modules/core/src/common/catalogs/index.ts b/modules/core/src/common/catalogs/index.ts new file mode 100644 index 00000000..eb90d3fa --- /dev/null +++ b/modules/core/src/common/catalogs/index.ts @@ -0,0 +1 @@ +export * from "./taxes"; diff --git a/modules/core/src/common/catalogs/taxes/index.ts b/modules/core/src/common/catalogs/taxes/index.ts new file mode 100644 index 00000000..9b0eefda --- /dev/null +++ b/modules/core/src/common/catalogs/taxes/index.ts @@ -0,0 +1,3 @@ +export * from "./spain-tax-catalog-provider"; +export * from "./tax-catalog-provider"; +export * from "./tax-catalog-types"; diff --git a/modules/core/src/common/catalogs/taxes/json-tax-catalog-provider.ts b/modules/core/src/common/catalogs/taxes/json-tax-catalog-provider.ts new file mode 100644 index 00000000..e7a18311 --- /dev/null +++ b/modules/core/src/common/catalogs/taxes/json-tax-catalog-provider.ts @@ -0,0 +1,45 @@ +// --- Adaptador que carga el catálogo JSON en memoria e indexa por code --- + +import { Maybe } from "@repo/rdx-utils"; +import { TaxCatalogProvider } from "./tax-catalog-provider"; +import { TaxCatalogType, TaxItemType } from "./tax-catalog-types"; + +// Si quieres habilitar la carga desde fichero en Node: +// import * as fs from "node:fs"; +// import * as path from "node:path"; + +export class JsonTaxCatalogProvider implements TaxCatalogProvider { + // Índice por código normalizado + private readonly index: Map; + + /** + * @param catalog Catálogo ya parseado (p.ej. import JSON o fetch) + */ + constructor(catalog: TaxCatalogType) { + this.index = new Map(); + // Normalizamos códigos a minúsculas y sin espacios + for (const item of catalog) { + const normalized = JsonTaxCatalogProvider.normalizeCode(item.code); + // En caso de duplicados, el último gana (o lanza error si prefieres) + this.index.set(normalized, item); + } + } + + static normalizeCode(code: string): string { + return (code ?? "").trim().toLowerCase(); + } + + findByCode(code: string): Maybe { + const normalized = JsonTaxCatalogProvider.normalizeCode(code); + const found = this.index.get(normalized); + return found ? Maybe.some(found) : Maybe.none(); + } + + // Opcional: carga desde fichero JSON en Node (descomenta si lo necesitas) + // static fromJsonFile(filePath: string): JsonTaxCatalogProvider { + // const full = path.resolve(filePath); + // const raw = fs.readFileSync(full, "utf-8"); + // const data = JSON.parse(raw) as TaxCatalogDto; + // return new JsonTaxCatalogProvider(data); + // } +} diff --git a/modules/core/src/common/catalogs/taxes/spain-tax-catalog-provider.ts b/modules/core/src/common/catalogs/taxes/spain-tax-catalog-provider.ts new file mode 100644 index 00000000..182d5558 --- /dev/null +++ b/modules/core/src/common/catalogs/taxes/spain-tax-catalog-provider.ts @@ -0,0 +1,4 @@ +import { JsonTaxCatalogProvider } from "./json-tax-catalog-provider"; +import spainTaxCatalog from "./spain-tax-catalog.json"; + +export const spainTaxCatalogProvider = new JsonTaxCatalogProvider(spainTaxCatalog); diff --git a/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json b/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json new file mode 100644 index 00000000..15e575cf --- /dev/null +++ b/modules/core/src/common/catalogs/taxes/spain-tax-catalog.json @@ -0,0 +1,357 @@ +[ + { + "name": "IVA 21%", + "code": "iva_21", + "value": 2100, + "scale": 2, + "group": "IVA", + "description": "IVA general. Tipo estándar nacional.", + "aeat_code": "01" + }, + { + "name": "IVA 10%", + "code": "iva_10", + "value": 1000, + "scale": 2, + "group": "IVA", + "description": "IVA reducido para bienes y servicios específicos.", + "aeat_code": "03" + }, + { + "name": "IVA 7,5%", + "code": "iva_7_5", + "value": 750, + "scale": 2, + "group": "IVA", + "description": "Tipo especial de IVA.", + "aeat_code": null + }, + { + "name": "IVA 5%", + "code": "iva_5", + "value": 500, + "scale": 2, + "group": "IVA", + "description": "Tipo reducido especial.", + "aeat_code": null + }, + { + "name": "IVA 4%", + "code": "iva_4", + "value": 400, + "scale": 2, + "group": "IVA", + "description": "IVA superreducido para bienes de primera necesidad.", + "aeat_code": "02" + }, + { + "name": "IVA 2%", + "code": "iva_2", + "value": 200, + "scale": 2, + "group": "IVA", + "description": "Tipo especial de IVA.", + "aeat_code": null + }, + { + "name": "IVA 0%", + "code": "iva_0", + "value": 0, + "scale": 2, + "group": "IVA", + "description": "Operaciones sujetas pero tipo cero.", + "aeat_code": "05" + }, + { + "name": "Exenta", + "code": "iva_exenta", + "value": 0, + "scale": 2, + "group": "IVA", + "description": "Operación exenta de IVA.", + "aeat_code": "04" + }, + { + "name": "No sujeto", + "code": "iva_no_sujeto", + "value": 0, + "scale": 2, + "group": "IVA", + "description": "Operación no sujeta a IVA.", + "aeat_code": "06" + }, + { + "name": "IVA Intracomunitario Bienes", + "code": "iva_intracomunitario_bienes", + "value": 0, + "scale": 2, + "group": "IVA", + "description": "Entrega intracomunitaria de bienes, exenta de IVA.", + "aeat_code": "E5" + }, + { + "name": "IVA Intracomunitario Servicio", + "code": "iva_intracomunitario_servicio", + "value": 0, + "scale": 2, + "group": "IVA", + "description": "Prestación intracomunitaria de servicios, exenta.", + "aeat_code": "E6" + }, + { + "name": "Exportación", + "code": "iva_exportacion", + "value": 0, + "scale": 2, + "group": "IVA", + "description": "Exportaciones exentas de IVA.", + "aeat_code": "E2" + }, + { + "name": "Inv. Suj. Pasivo", + "code": "iva_inversion_sujeto_pasivo", + "value": 0, + "scale": 2, + "group": "IVA", + "description": "Inversión del sujeto pasivo.", + "aeat_code": "09" + }, + + { + "name": "Retención 35%", + "code": "retencion_35", + "value": 3500, + "scale": 2, + "group": "Retención", + "description": "Retención profesional o fiscal tipo máximo.", + "aeat_code": null + }, + { + "name": "Retención 19%", + "code": "retencion_19", + "value": 1900, + "scale": 2, + "group": "Retención", + "description": "Retención IRPF general.", + "aeat_code": "R1" + }, + { + "name": "Retención 15%", + "code": "retencion_15", + "value": 1500, + "scale": 2, + "group": "Retención", + "description": "Retención para autónomos y profesionales.", + "aeat_code": "R2" + }, + { + "name": "Retención 7%", + "code": "retencion_7", + "value": 700, + "scale": 2, + "group": "Retención", + "description": "Retención para nuevos autónomos.", + "aeat_code": null + }, + { + "name": "Retención 2%", + "code": "retencion_2", + "value": 200, + "scale": 2, + "group": "Retención", + "description": "Retención sobre arrendamientos de inmuebles urbanos.", + "aeat_code": "R3" + }, + + { + "name": "REC 5,2%", + "code": "rec_5_2", + "value": 520, + "scale": 2, + "group": "Recargo de equivalencia", + "description": "Recargo general para IVA 21%.", + "aeat_code": "51" + }, + { + "name": "REC 1,75%", + "code": "rec_1_75", + "value": 175, + "scale": 2, + "group": "Recargo de equivalencia", + "description": "Recargo para IVA 10%.", + "aeat_code": "52" + }, + { + "name": "REC 1,4%", + "code": "rec_1_4", + "value": 140, + "scale": 2, + "group": "Recargo de equivalencia", + "description": "Recargo para IVA 5%.", + "aeat_code": null + }, + { + "name": "REC 1%", + "code": "rec_1", + "value": 100, + "scale": 2, + "group": "Recargo de equivalencia", + "description": "Recargo especial.", + "aeat_code": null + }, + { + "name": "REC 0,62%", + "code": "rec_0_62", + "value": 62, + "scale": 2, + "group": "Recargo de equivalencia", + "description": "Recargo para IVA reducido especial.", + "aeat_code": null + }, + { + "name": "REC 0,5%", + "code": "rec_0_5", + "value": 50, + "scale": 2, + "group": "Recargo de equivalencia", + "description": "Recargo especial.", + "aeat_code": null + }, + { + "name": "REC 0,26%", + "code": "rec_0_26", + "value": 26, + "scale": 2, + "group": "Recargo de equivalencia", + "description": "Recargo mínimo.", + "aeat_code": null + }, + { + "name": "REC 0%", + "code": "rec_0", + "value": 0, + "scale": 2, + "group": "Recargo de equivalencia", + "description": "Sin recargo.", + "aeat_code": null + }, + + { + "name": "IGIC 7%", + "code": "igic_7", + "value": 700, + "scale": 2, + "group": "IGIC", + "description": "Tipo general IGIC Canarias.", + "aeat_code": "10" + }, + { + "name": "IGIC 3%", + "code": "igic_3", + "value": 300, + "scale": 2, + "group": "IGIC", + "description": "Tipo reducido IGIC Canarias.", + "aeat_code": "11" + }, + { + "name": "IGIC 0%", + "code": "igic_0", + "value": 0, + "scale": 2, + "group": "IGIC", + "description": "Operación exenta IGIC.", + "aeat_code": "12" + }, + { + "name": "IGIC 9,5%", + "code": "igic_9_5", + "value": 950, + "scale": 2, + "group": "IGIC", + "description": "Tipo incrementado IGIC.", + "aeat_code": "13" + }, + { + "name": "IGIC 13,5%", + "code": "igic_13_5", + "value": 1350, + "scale": 2, + "group": "IGIC", + "description": "Tipo incrementado especial IGIC.", + "aeat_code": "14" + }, + { + "name": "IGIC 20%", + "code": "igic_20", + "value": 2000, + "scale": 2, + "group": "IGIC", + "description": "Tipo incrementado IGIC.", + "aeat_code": "15" + }, + { + "name": "IGIC 1%", + "code": "igic_1", + "value": 100, + "scale": 2, + "group": "IGIC", + "description": "Tipo reducido IGIC.", + "aeat_code": "16" + }, + { + "name": "IGIC 2,75%", + "code": "igic_2_75", + "value": 275, + "scale": 2, + "group": "IGIC", + "description": "Tipo especial IGIC.", + "aeat_code": null + }, + { + "name": "IGIC Exento", + "code": "igic_exento", + "value": 0, + "scale": 2, + "group": "IGIC", + "description": "Operación exenta de IGIC.", + "aeat_code": "12" + }, + + { + "name": "IPSI 10%", + "code": "ipsi_10", + "value": 1000, + "scale": 2, + "group": "IPSI", + "description": "Tipo general IPSI Ceuta/Melilla.", + "aeat_code": null + }, + { + "name": "IPSI 4%", + "code": "ipsi_4", + "value": 400, + "scale": 2, + "group": "IPSI", + "description": "Tipo reducido IPSI.", + "aeat_code": null + }, + { + "name": "IPSI 0,5%", + "code": "ipsi_0_5", + "value": 50, + "scale": 2, + "group": "IPSI", + "description": "Tipo superreducido IPSI.", + "aeat_code": null + }, + { + "name": "IPSI Exento", + "code": "ipsi_exento", + "value": 0, + "scale": 2, + "group": "IPSI", + "description": "Operación exenta de IPSI.", + "aeat_code": null + } +] diff --git a/modules/core/src/common/catalogs/taxes/tax-catalog-provider.ts b/modules/core/src/common/catalogs/taxes/tax-catalog-provider.ts new file mode 100644 index 00000000..dc19f987 --- /dev/null +++ b/modules/core/src/common/catalogs/taxes/tax-catalog-provider.ts @@ -0,0 +1,12 @@ +// --- Puerto (interfaz) para resolver tasas desde un catálogo --- + +import { Maybe } from "@repo/rdx-utils"; // Usa tu implementación real de Maybe +import { TaxItemType } from "./tax-catalog-types"; + +export interface TaxCatalogProvider { + /** + * Busca un item del catálogo por su código. + * Debe considerar normalización del 'code' según las reglas del catálogo. + */ + findByCode(code: string): Maybe; +} diff --git a/modules/core/src/common/catalogs/taxes/tax-catalog-types.ts b/modules/core/src/common/catalogs/taxes/tax-catalog-types.ts new file mode 100644 index 00000000..4686f00a --- /dev/null +++ b/modules/core/src/common/catalogs/taxes/tax-catalog-types.ts @@ -0,0 +1,13 @@ +// --- DTOs del catálogo (comparten contrato entre frontend/backend) --- + +export type TaxItemType = { + name: string; // p.ej. "IVA 21%" + code: string; // p.ej. "iva_21" + value: number; // porcentaje * 10^scale (21% => 2100) + scale: number; // decimales de 'value' (normalmente 2) + group: string; // p.ej. "IVA", "IGIC", "IPSI", "Retención" + description?: string; // opcional + aeat_code?: string | null; // opcional +}; + +export type TaxCatalogType = TaxItemType[]; diff --git a/modules/core/src/common/index.ts b/modules/core/src/common/index.ts index bd3e74b5..6851e148 100644 --- a/modules/core/src/common/index.ts +++ b/modules/core/src/common/index.ts @@ -1,3 +1,4 @@ +export * from "./catalogs"; export * from "./dto"; export * from "./schemas"; export * from "./types"; diff --git a/modules/customer-invoices/src/api/application/create-customer-invoice/map-dto-to-create-customer-invoice-props.ts b/modules/customer-invoices/src/api/application/create-customer-invoice/map-dto-to-create-customer-invoice-props.ts index fb93a593..3f32b5ed 100644 --- a/modules/customer-invoices/src/api/application/create-customer-invoice/map-dto-to-create-customer-invoice-props.ts +++ b/modules/customer-invoices/src/api/application/create-customer-invoice/map-dto-to-create-customer-invoice-props.ts @@ -1,5 +1,8 @@ +import { spainTaxCatalogProvider } from "@erp/core"; import { DomainError, + Tax, + Taxes, ValidationErrorCollection, ValidationErrorDetail, extractOrPushError, @@ -14,17 +17,22 @@ import { maybeFromNullableVO, } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { CreateCustomerInvoiceRequestDTO } from "../../../common/dto"; import { + CreateCustomerInvoiceItemRequestDTO, + CreateCustomerInvoiceRequestDTO, +} from "../../../common/dto"; +import { + CustomerInvoiceItem, CustomerInvoiceItemDescription, CustomerInvoiceItemProps, - CustomerInvoiceItemQuantity, + CustomerInvoiceItems, CustomerInvoiceNumber, CustomerInvoiceProps, CustomerInvoiceSerie, CustomerInvoiceStatus, ItemAmount, ItemDiscount, + ItemQuantity, } from "../../domain"; /** @@ -96,7 +104,7 @@ export function mapDTOToCreateCustomerInvoiceProps(dto: CreateCustomerInvoiceReq errors ); - const items = mapDTOToCreateCustomerInvoiceItemsProps(dto, errors); + const items = _mapDTOtoInvoiceItems(dto.items, languageCode!, currencyCode!, errors); if (errors.length > 0) { return Result.fail( @@ -120,6 +128,9 @@ export function mapDTOToCreateCustomerInvoiceProps(dto: CreateCustomerInvoiceReq currencyCode: currencyCode!, discountPercentage: discountPercentage!, + + taxes: Taxes.create({ items: [] }), + items: items, }; return Result.ok({ id: customerId!, props: invoiceProps }); @@ -128,25 +139,19 @@ export function mapDTOToCreateCustomerInvoiceProps(dto: CreateCustomerInvoiceReq } } -function mapDTOToCreateCustomerInvoiceItemsProps( - dto: CreateCustomerInvoiceRequestDTO, +function _mapDTOtoInvoiceItems( + items: CreateCustomerInvoiceItemRequestDTO[], + languageCode: LanguageCode, + currencyCode: CurrencyCode, errors: ValidationErrorDetail[] -): CustomerInvoiceItemProps[] | undefined { - const items: CustomerInvoiceItemProps[] = []; +) { + const invoiceItems = CustomerInvoiceItems.create({ + currencyCode, + languageCode, + items: [], + }); - const languageCode = extractOrPushError( - LanguageCode.create(dto.language_code), - "language_code", - errors - ); - - const currencyCode = extractOrPushError( - CurrencyCode.create(dto.currency_code), - "currency_code", - errors - ); - - dto.items.forEach((item, index) => { + items.forEach((item, index) => { const description = extractOrPushError( maybeFromNullableVO(item.description, (value) => CustomerInvoiceItemDescription.create(value) @@ -156,7 +161,7 @@ function mapDTOToCreateCustomerInvoiceItemsProps( ); const quantity = extractOrPushError( - maybeFromNullableVO(item.quantity, (value) => CustomerInvoiceItemQuantity.create(value)), + maybeFromNullableVO(item.quantity, (value) => ItemQuantity.create(value)), "quantity", errors ); @@ -173,16 +178,48 @@ function mapDTOToCreateCustomerInvoiceItemsProps( errors ); - items.push({ + const taxes = _mapDTOtoTaxes(item, index, errors); + + const itemProps: CustomerInvoiceItemProps = { currencyCode: currencyCode!, languageCode: languageCode!, - description: description!, quantity: quantity!, unitAmount: unitAmount!, discountPercentage: discountPercentage!, - }); - }); + taxes, + }; - return items; + const itemResult = CustomerInvoiceItem.create(itemProps); + if (itemResult.isSuccess) { + invoiceItems.add(itemResult.data); + } else { + errors.push({ + path: `items[${index}]`, + message: itemResult.error.message, + }); + } + }); + return invoiceItems; +} + +function _mapDTOtoTaxes( + item: CreateCustomerInvoiceItemRequestDTO, + itemIndex: number, + errors: ValidationErrorDetail[] +) { + const taxes = Taxes.create({ items: [] }); + + item.taxes.split(",").every((tax_code, taxIndex) => { + const taxResult = Tax.createFromCode(tax_code, spainTaxCatalogProvider); + if (taxResult.isSuccess) { + taxes.add(taxResult.data); + } else { + errors.push({ + path: `items[${itemIndex}].taxes[${taxIndex}]`, + message: taxResult.error.message, + }); + } + }); + return taxes; } diff --git a/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts b/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts index 8f1dc518..fa2234a3 100644 --- a/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts +++ b/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts @@ -1,3 +1,4 @@ +import { Taxes } from "@erp/core/api"; import { AggregateRoot, CurrencyCode, @@ -47,8 +48,7 @@ export interface CustomerInvoiceProps { discountPercentage: Percentage; //discountAmount: MoneyValue; - //taxableAmount: MoneyValue; - //taxAmount: MoneyValue; + taxes: Taxes; //totalAmount: MoneyValue; @@ -60,7 +60,6 @@ export type CustomerInvoicePatchProps = Partial { private _items!: CustomerInvoiceItems; - //protected _status: CustomerInvoiceStatus; protected constructor(props: CustomerInvoiceProps, id?: UniqueID) { super(props, id); diff --git a/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-item.ts b/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-item.ts index 0515b83c..d7eef312 100644 --- a/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-item.ts +++ b/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-item.ts @@ -4,6 +4,7 @@ import { LanguageCode, MoneyValue, Percentage, + Taxes, UniqueID, } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; @@ -20,6 +21,8 @@ export interface CustomerInvoiceItemProps { unitAmount: Maybe; // Precio unitario en la moneda de la factura discountPercentage: Maybe; // % descuento + taxes: Taxes; + languageCode: LanguageCode; currencyCode: CurrencyCode; } diff --git a/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-items.ts b/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-items.ts index 911cbaf9..59e35e56 100644 --- a/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-items.ts +++ b/modules/customer-invoices/src/api/domain/entities/customer-invoice-items/customer-invoice-items.ts @@ -13,7 +13,7 @@ export class CustomerInvoiceItems extends Collection { private _currencyCode!: CurrencyCode; constructor(props: CustomerInvoiceItemsProps) { - const { items, languageCode, currencyCode } = props; + const { items = [], languageCode, currencyCode } = props; super(items); this._languageCode = languageCode; this._currencyCode = currencyCode; @@ -22,4 +22,15 @@ export class CustomerInvoiceItems extends Collection { public static create(props: CustomerInvoiceItemsProps): CustomerInvoiceItems { return new CustomerInvoiceItems(props); } + + add(item: CustomerInvoiceItem): boolean { + // 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. + if (!this._languageCode.equals(item.languageCode) || !this._currencyCode.equals(item.currencyCode)) { + return false; + + } + return super.add(item) + } + } diff --git a/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice-item-taxes.model.ts b/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice-item-taxes.model.ts index 132069e8..8e7431b6 100644 --- a/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice-item-taxes.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice-item-taxes.model.ts @@ -93,7 +93,7 @@ export default (database: Sequelize) => { underscored: true, - indexes: [{ name: "tax_code_idx", fields: ["tax_code"], unique: false }], + indexes: [], whereMergeStrategy: "and", // <- cómo tratar el merge de un scope diff --git a/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice-taxes.model.ts b/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice-taxes.model.ts index 2726d03e..5b09062e 100644 --- a/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice-taxes.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice-taxes.model.ts @@ -93,7 +93,7 @@ export default (database: Sequelize) => { underscored: true, - indexes: [{ name: "tax_code_idx", fields: ["tax_code"], unique: false }], + indexes: [], whereMergeStrategy: "and", // <- cómo tratar el merge de un scope diff --git a/modules/customer-invoices/src/common/dto/request/create-customer-invoice.request.dto.ts b/modules/customer-invoices/src/common/dto/request/create-customer-invoice.request.dto.ts index 5cd7b011..c07c7316 100644 --- a/modules/customer-invoices/src/common/dto/request/create-customer-invoice.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/create-customer-invoice.request.dto.ts @@ -1,6 +1,16 @@ import { NumericStringSchema, PercentageSchema } from "@erp/core"; import * as z from "zod/v4"; +export const CreateCustomerInvoiceItemRequestSchema = z.object({ + id: z.uuid(), + position: z.string(), + description: z.string().default(""), + quantity: NumericStringSchema.default(""), + unit_amount: NumericStringSchema.default(""), + discount_percentage: NumericStringSchema.default(""), + taxes: z.string().default(""), +}); + export const CreateCustomerInvoiceRequestSchema = z.object({ id: z.uuid(), company_id: z.uuid(), @@ -22,18 +32,10 @@ export const CreateCustomerInvoiceRequestSchema = z.object({ scale: "2", }), - items: z - .array( - z.object({ - id: z.uuid(), - position: z.string(), - description: z.string().default(""), - quantity: NumericStringSchema.default(""), - unit_amount: NumericStringSchema.default(""), - discount_percentage: NumericStringSchema.default(""), - }) - ) - .default([]), + items: z.array(CreateCustomerInvoiceItemRequestSchema).default([]), }); +export type CreateCustomerInvoiceItemRequestDTO = z.infer< + typeof CreateCustomerInvoiceItemRequestSchema +>; export type CreateCustomerInvoiceRequestDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/response/get-customer-invoice-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/get-customer-invoice-by-id.response.dto.ts index eaed4ae4..3c56e010 100644 --- a/modules/customer-invoices/src/common/dto/response/get-customer-invoice-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/get-customer-invoice-by-id.response.dto.ts @@ -21,7 +21,9 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({ discount_percentage: PercentageSchema, discount_amount: AmountSchema, taxable_amount: AmountSchema, - tax_amount: AmountSchema, + + taxes: z.string(), + total_amount: AmountSchema, items: z.array( @@ -32,6 +34,7 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({ quantity: QuantitySchema, unit_amount: AmountSchema, discount_percentage: PercentageSchema, + taxes: z.string(), total_amount: AmountSchema, }) ), diff --git a/packages/rdx-utils/src/helpers/collection.ts b/packages/rdx-utils/src/helpers/collection.ts index 66a3ec90..c6e81c90 100644 --- a/packages/rdx-utils/src/helpers/collection.ts +++ b/packages/rdx-utils/src/helpers/collection.ts @@ -28,11 +28,12 @@ export class Collection { * Agrega un nuevo elemento a la colección. * @param item - Elemento a agregar. */ - add(item: T): void { + add(item: T): boolean { this.items.push(item); if (this.totalItems !== null) { this.totalItems++; } + return true; } /**