Facturas de cliente
This commit is contained in:
parent
d40c2279bd
commit
d17a22dc9f
@ -1 +1,2 @@
|
||||
export * from "./errors";
|
||||
export * from "./value-objects";
|
||||
|
||||
296
modules/core/src/api/domain/value-objects/__tests__/tax.test.ts
Normal file
296
modules/core/src/api/domain/value-objects/__tests__/tax.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
2
modules/core/src/api/domain/value-objects/index.ts
Normal file
2
modules/core/src/api/domain/value-objects/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./tax";
|
||||
export * from "./taxes";
|
||||
162
modules/core/src/api/domain/value-objects/tax.ts
Normal file
162
modules/core/src/api/domain/value-objects/tax.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
17
modules/core/src/api/domain/value-objects/taxes.ts
Normal file
17
modules/core/src/api/domain/value-objects/taxes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
modules/core/src/common/catalogs/index.ts
Normal file
1
modules/core/src/common/catalogs/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./taxes";
|
||||
3
modules/core/src/common/catalogs/taxes/index.ts
Normal file
3
modules/core/src/common/catalogs/taxes/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./spain-tax-catalog-provider";
|
||||
export * from "./tax-catalog-provider";
|
||||
export * from "./tax-catalog-types";
|
||||
@ -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);
|
||||
// }
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
import { JsonTaxCatalogProvider } from "./json-tax-catalog-provider";
|
||||
import spainTaxCatalog from "./spain-tax-catalog.json";
|
||||
|
||||
export const spainTaxCatalogProvider = new JsonTaxCatalogProvider(spainTaxCatalog);
|
||||
357
modules/core/src/common/catalogs/taxes/spain-tax-catalog.json
Normal file
357
modules/core/src/common/catalogs/taxes/spain-tax-catalog.json
Normal 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
|
||||
}
|
||||
]
|
||||
@ -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>;
|
||||
}
|
||||
13
modules/core/src/common/catalogs/taxes/tax-catalog-types.ts
Normal file
13
modules/core/src/common/catalogs/taxes/tax-catalog-types.ts
Normal 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[];
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./catalogs";
|
||||
export * from "./dto";
|
||||
export * from "./schemas";
|
||||
export * from "./types";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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,
|
||||
})
|
||||
),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user