Facturas de cliente

This commit is contained in:
David Arranz 2025-09-07 21:55:12 +02:00
parent d40c2279bd
commit d17a22dc9f
22 changed files with 1016 additions and 46 deletions

View File

@ -1 +1,2 @@
export * from "./errors";
export * from "./value-objects";

View File

@ -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();
}
});
});
});

View File

@ -0,0 +1,2 @@
export * from "./tax";
export * from "./taxes";

View File

@ -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<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;
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<Tax> {
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<Tax, Error> {
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(),
};
}
}

View File

@ -0,0 +1,17 @@
import { Collection } from "@repo/rdx-utils";
import { Tax } from "./tax";
export interface TaxesProps {
items?: Tax[];
}
export class Taxes extends Collection<Tax> {
constructor(props: TaxesProps) {
const { items } = props;
super(items);
}
public static create(props: TaxesProps): Taxes {
return new Taxes(props);
}
}

View File

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

View File

@ -0,0 +1,3 @@
export * from "./spain-tax-catalog-provider";
export * from "./tax-catalog-provider";
export * from "./tax-catalog-types";

View File

@ -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<string, TaxItemType>;
/**
* @param catalog Catálogo ya parseado (p.ej. import JSON o fetch)
*/
constructor(catalog: TaxCatalogType) {
this.index = new Map<string, TaxItemType>();
// 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<TaxItemType> {
const normalized = JsonTaxCatalogProvider.normalizeCode(code);
const found = this.index.get(normalized);
return found ? Maybe.some(found) : Maybe.none<TaxItemType>();
}
// 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);
// }
}

View File

@ -0,0 +1,4 @@
import { JsonTaxCatalogProvider } from "./json-tax-catalog-provider";
import spainTaxCatalog from "./spain-tax-catalog.json";
export const spainTaxCatalogProvider = new JsonTaxCatalogProvider(spainTaxCatalog);

View File

@ -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
}
]

View File

@ -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<TaxItemType>;
}

View File

@ -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[];

View File

@ -1,3 +1,4 @@
export * from "./catalogs";
export * from "./dto";
export * from "./schemas";
export * from "./types";

View File

@ -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;
}

View File

@ -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<Omit<CustomerInvoiceProps, "comp
export class CustomerInvoice extends AggregateRoot<CustomerInvoiceProps> {
private _items!: CustomerInvoiceItems;
//protected _status: CustomerInvoiceStatus;
protected constructor(props: CustomerInvoiceProps, id?: UniqueID) {
super(props, id);

View File

@ -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<ItemAmount>; // Precio unitario en la moneda de la factura
discountPercentage: Maybe<ItemDiscount>; // % descuento
taxes: Taxes;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
}

View File

@ -13,7 +13,7 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
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<CustomerInvoiceItem> {
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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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<typeof CreateCustomerInvoiceRequestSchema>;

View File

@ -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,
})
),

View File

@ -28,11 +28,12 @@ export class Collection<T> {
* 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;
}
/**