diff --git a/apps/server/.eslintrc.json b/apps/server/.eslintrc.json index f324a57e..039be3de 100644 --- a/apps/server/.eslintrc.json +++ b/apps/server/.eslintrc.json @@ -12,7 +12,11 @@ "prettier" ], "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint", "sort-class-members"], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "sort-class-members", "hexagonal-architecture"], "rules": { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-inferrable-types": "off", diff --git a/apps/server/package.json b/apps/server/package.json index 19820492..2bfe9a72 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -48,9 +48,11 @@ "typescript": "^5.7.3" }, "dependencies": { + "@types/dinero.js": "^1.9.4", "bcrypt": "^5.1.1", "cls-rtracer": "^2.6.3", "cors": "^2.8.5", + "dinero.js": "^1.9.1", "dotenv": "^16.4.7", "esbuild": "^0.24.0", "express": "^4.18.2", diff --git a/apps/server/src/common/domain/value-objects/email-address.ts b/apps/server/src/common/domain/value-objects/email-address.ts index 5d898619..f5e37634 100644 --- a/apps/server/src/common/domain/value-objects/email-address.ts +++ b/apps/server/src/common/domain/value-objects/email-address.ts @@ -11,7 +11,7 @@ export class EmailAddress extends ValueObject { const valueIsValid = EmailAddress.validate(value); if (!valueIsValid.success) { - Result.fail(new Error(valueIsValid.error.errors[0].message)); + return Result.fail(new Error(valueIsValid.error.errors[0].message)); } return Result.ok(new EmailAddress({ value: valueIsValid.data! })); } diff --git a/apps/server/src/common/domain/value-objects/index.ts b/apps/server/src/common/domain/value-objects/index.ts index 9a566d27..d3dca809 100644 --- a/apps/server/src/common/domain/value-objects/index.ts +++ b/apps/server/src/common/domain/value-objects/index.ts @@ -1,7 +1,10 @@ export * from "./email-address"; +export * from "./money-value"; export * from "./name"; +export * from "./percentage"; export * from "./phone-number"; export * from "./postal-address"; +export * from "./quantity"; export * from "./slug"; export * from "./tin-number"; export * from "./unique-id"; diff --git a/apps/server/src/common/domain/value-objects/money-value.spec.ts b/apps/server/src/common/domain/value-objects/money-value.spec.ts new file mode 100644 index 00000000..74e9b62d --- /dev/null +++ b/apps/server/src/common/domain/value-objects/money-value.spec.ts @@ -0,0 +1,53 @@ +import { MoneyValue } from "./money-value"; + +describe("MoneyValue", () => { + test("should correctly instantiate with amount, scale, and currency", () => { + const money = new MoneyValue({ amount: 10000, scale: 2, currency_code: "EUR" }); + expect(money.amount).toBe(100); + expect(money.currency).toBe("EUR"); + expect(money.scale).toBe(2); + }); + + test("should add two MoneyValue instances with the same currency", () => { + const money1 = new MoneyValue({ amount: 10000, scale: 2, currency_code: "EUR" }); + const money2 = new MoneyValue({ amount: 5000, scale: 2, currency_code: "EUR" }); + const result = money1.add(money2); + expect(result.amount).toBe(150); + }); + + test("should subtract two MoneyValue instances with the same currency", () => { + const money1 = new MoneyValue({ amount: 20000, scale: 2, currency_code: "EUR" }); + const money2 = new MoneyValue({ amount: 5000, scale: 2, currency_code: "EUR" }); + const result = money1.subtract(money2); + expect(result.amount).toBe(150); + }); + + test("should throw an error when adding different currencies", () => { + const money1 = new MoneyValue({ amount: 10000, scale: 2, currency_code: "EUR" }); + const money2 = new MoneyValue({ amount: 5000, scale: 2, currency_code: "USD" }); + expect(() => money1.add(money2)).toThrow("Currency mismatch"); + }); + + test("should correctly convert scale", () => { + const money = new MoneyValue({ amount: 10000, scale: 2, currency_code: "EUR" }); + const converted = money.convertScale(4); + expect(converted.amount).toBe(100); + expect(converted.scale).toBe(4); + }); + + test("should format correctly according to locale", () => { + const money = new MoneyValue({ amount: 123456, scale: 2, currency_code: "EUR" }); + expect(money.format("es-ES")).toBe("1.234,56 €"); + expect(money.format("en-US")).toBe("€1,234.56"); + }); + + test("should compare MoneyValue instances correctly", () => { + const money1 = new MoneyValue({ amount: 10000, scale: 2, currency_code: "EUR" }); + const money2 = new MoneyValue({ amount: 10000, scale: 2, currency_code: "EUR" }); + const money3 = new MoneyValue({ amount: 5000, scale: 2, currency_code: "EUR" }); + + expect(money1.equalsTo(money2)).toBe(true); + expect(money1.greaterThan(money3)).toBe(true); + expect(money3.lessThan(money1)).toBe(true); + }); +}); diff --git a/apps/server/src/common/domain/value-objects/money-value.ts b/apps/server/src/common/domain/value-objects/money-value.ts new file mode 100644 index 00000000..c841787c --- /dev/null +++ b/apps/server/src/common/domain/value-objects/money-value.ts @@ -0,0 +1,149 @@ +import { Result } from "@common/helpers"; +import DineroFactory, { Currency, Dinero } from "dinero.js"; +import { ValueObject } from "./value-object"; + +const DEFAULT_SCALE = 2; + +type CurrencyData = Currency; + +interface IMoneyValueProps { + amount: number; + scale: number; + currency_code: string; +} + +interface IMoneyValue { + amount: number; + scale: number; + currency: Dinero.Currency; + + getValue(): IMoneyValueProps; + convertScale(newScale: number): MoneyValue; + add(addend: MoneyValue): MoneyValue; + subtract(subtrahend: MoneyValue): MoneyValue; + multiply(multiplier: number): MoneyValue; + divide(divisor: number): MoneyValue; + equalsTo(comparator: MoneyValue): boolean; + greaterThan(comparator: MoneyValue): boolean; + lessThan(comparator: MoneyValue): boolean; + isZero(): boolean; + isPositive(): boolean; + isNegative(): boolean; + format(locale: string): string; +} + +export class MoneyValue extends ValueObject implements IMoneyValue { + private readonly value: Dinero; + + static create(props: IMoneyValueProps) { + return Result.ok(new MoneyValue(props)); + } + + constructor(props: IMoneyValueProps) { + super(props); + const { amount, scale, currency_code } = props; + this.value = Object.freeze( + DineroFactory({ + amount, + precision: scale, + currency: currency_code as Currency, + }) + ); // 🔒 Garantiza inmutabilidad + } + + get amount(): number { + return this.value.getAmount() / Math.pow(10, this.value.getPrecision()); + } + + get currency(): CurrencyData { + return this.value.getCurrency(); + } + + get scale(): number { + return this.value.getPrecision(); + } + + getValue(): IMoneyValueProps { + return this.props; + } + + convertScale(newScale: number): MoneyValue { + const factor = Math.pow(10, newScale - this.scale); + const newAmount = Math.round(this.amount * factor); + return new MoneyValue({ + amount: newAmount, + scale: newScale, + currency_code: this.currency, + }); + } + + add(addend: MoneyValue): MoneyValue { + return new MoneyValue({ + amount: this.value.add(addend.value).getAmount(), + scale: this.scale, + currency_code: this.currency, + }); + } + + subtract(subtrahend: MoneyValue): MoneyValue { + return new MoneyValue({ + amount: this.value.subtract(subtrahend.value).getAmount(), + scale: this.scale, + currency_code: this.currency, + }); + } + + multiply(multiplier: number): MoneyValue { + return new MoneyValue({ + amount: this.value.multiply(multiplier).getAmount(), + scale: this.scale, + currency_code: this.currency, + }); + } + + divide(divisor: number): MoneyValue { + return new MoneyValue({ + amount: this.value.divide(divisor).getAmount(), + scale: this.scale, + currency_code: this.currency, + }); + } + + equalsTo(comparator: MoneyValue): boolean { + return this.value.equalsTo(comparator.value); + } + + greaterThan(comparator: MoneyValue): boolean { + return this.value.greaterThan(comparator.value); + } + + lessThan(comparator: MoneyValue): boolean { + return this.value.lessThan(comparator.value); + } + + isZero(): boolean { + return this.value.isZero(); + } + + isPositive(): boolean { + return this.amount > 0; + } + + isNegative(): boolean { + return this.amount < 0; + } + + format(locale: string): string { + const amount = this.amount; + const currency = this.currency; + const scale = this.scale; + + return new Intl.NumberFormat(locale, { + style: "currency", + currency: currency, + minimumFractionDigits: scale, + maximumFractionDigits: scale, + useGrouping: true, + }).format(amount); + } +} diff --git a/apps/server/src/common/domain/value-objects/name.spec.ts b/apps/server/src/common/domain/value-objects/name.spec.ts index 846b23bb..0557f757 100644 --- a/apps/server/src/common/domain/value-objects/name.spec.ts +++ b/apps/server/src/common/domain/value-objects/name.spec.ts @@ -24,7 +24,7 @@ describe("Name Value Object", () => { const nullableNameResult = Name.createNullable("Alice"); expect(nullableNameResult.isSuccess).toBe(true); expect(nullableNameResult.data.isSome()).toBe(true); - expect(nullableNameResult.data.getValue()).toBe("Alice"); + expect(nullableNameResult.data.getOrUndefined()?.toString()).toBe("Alice"); }); test("Debe generar acrónimos correctamente", () => { diff --git a/apps/server/src/common/domain/value-objects/name.ts b/apps/server/src/common/domain/value-objects/name.ts index 1e78c8e3..8fdf0171 100644 --- a/apps/server/src/common/domain/value-objects/name.ts +++ b/apps/server/src/common/domain/value-objects/name.ts @@ -2,11 +2,11 @@ import { Maybe, Result } from "@common/helpers"; import { z } from "zod"; import { ValueObject } from "./value-object"; -interface NameProps { +interface INameProps { value: string; } -export class Name extends ValueObject { +export class Name extends ValueObject { private static readonly MAX_LENGTH = 255; protected static validate(value: string) { @@ -21,9 +21,9 @@ export class Name extends ValueObject { const valueIsValid = Name.validate(value); if (!valueIsValid.success) { - Result.fail(new Error(valueIsValid.error.errors[0].message)); + return Result.fail(new Error(valueIsValid.error.errors[0].message)); } - return Result.ok(new Name({ value: valueIsValid.data! })); + return Result.ok(new Name({ value })); } static createNullable(value?: string): Result, Error> { diff --git a/apps/server/src/common/domain/value-objects/percentage.spec.ts b/apps/server/src/common/domain/value-objects/percentage.spec.ts new file mode 100644 index 00000000..8b89ec03 --- /dev/null +++ b/apps/server/src/common/domain/value-objects/percentage.spec.ts @@ -0,0 +1,51 @@ +import { Percentage } from "./percentage"; // Ajusta la ruta según sea necesario + +describe("Percentage Value Object", () => { + test("Debe crear un porcentaje válido con escala por defecto", () => { + const result = Percentage.create({ amount: 200 }); // 2.00% + expect(result.isSuccess).toBe(true); + expect(result.data?.toString()).toBe("2.00%"); + }); + + test("Debe crear un porcentaje válido con escala definida", () => { + const result = Percentage.create({ amount: 2150, scale: 2 }); // 21.50% + expect(result.isSuccess).toBe(true); + expect(result.data?.toString()).toBe("21.50%"); + }); + + test("Debe devolver error si la cantidad supera el 100%", () => { + const result = Percentage.create({ amount: 48732000, scale: 4 }); + expect(result.isSuccess).toBe(false); + expect(result.error.message).toBe("La escala debe estar entre 0 y 2."); + }); + + test("Debe devolver error si la cantidad es negativa", () => { + const result = Percentage.create({ amount: -100, scale: 2 }); + expect(result.isSuccess).toBe(false); + expect(result.error.message).toContain("La cantidad no puede ser negativa."); + }); + + test("Debe devolver error si la escala es menor a 0", () => { + const result = Percentage.create({ amount: 100, scale: -1 }); + expect(result.isSuccess).toBe(false); + expect(result.error.message).toContain("Number must be greater than or equal to 0"); + }); + + test("Debe devolver error si la escala es mayor a 10", () => { + const result = Percentage.create({ amount: 100, scale: 11 }); + expect(result.isSuccess).toBe(false); + expect(result.error.message).toContain("La escala debe estar entre 0 y 2."); + }); + + test("Debe representar correctamente el valor como string", () => { + const result = Percentage.create({ amount: 750, scale: 2 }); // 7.50% + expect(result.isSuccess).toBe(true); + expect(result.data?.toString()).toBe("7.50%"); + }); + + test("Debe manejar correctamente el caso de 0%", () => { + const result = Percentage.create({ amount: 0, scale: 2 }); + expect(result.isSuccess).toBe(true); + expect(result.data?.toString()).toBe("0.00%"); + }); +}); diff --git a/apps/server/src/common/domain/value-objects/percentage.ts b/apps/server/src/common/domain/value-objects/percentage.ts new file mode 100644 index 00000000..94f3bb43 --- /dev/null +++ b/apps/server/src/common/domain/value-objects/percentage.ts @@ -0,0 +1,83 @@ +import { Result } from "@common/helpers"; +import { z } from "zod"; +import { ValueObject } from "./value-object"; + +const DEFAULT_SCALE = 2; + +interface IPercentageProps { + amount: number; + scale: number; +} + +interface IPercentage { + amount: number; + scale: number; + + getValue(): IPercentageProps; + toNumber(): number; + toString(): string; +} + +export class Percentage extends ValueObject implements IPercentage { + public static readonly DEFAULT_SCALE = DEFAULT_SCALE; + public static readonly MIN_VALUE = 0; + public static readonly MAX_VALUE = 100; + + public static readonly MIN_SCALE = 0; + public static readonly MAX_SCALE = 2; + + protected static validate(values: IPercentageProps) { + const schema = z.object({ + amount: z.number().int().min(Percentage.MIN_VALUE, "La cantidad no puede ser negativa."), + scale: z + .number() + .int() + .min(Percentage.MIN_SCALE) + .max( + Percentage.MAX_SCALE, + `La escala debe estar entre ${Percentage.MIN_SCALE} y ${Percentage.MAX_SCALE}.` + ), + }); + + return schema.safeParse(values); + } + + static create(props: { amount: number; scale?: number }): Result { + const { amount, scale = Percentage.DEFAULT_SCALE } = props; + + const validationResult = Percentage.validate({ amount, scale }); + if (!validationResult.success) { + return Result.fail(new Error(validationResult.error.errors.map((e) => e.message).join(", "))); + } + + // Cálculo del valor real del porcentaje + const realValue = amount / Math.pow(10, scale); + + // Validación de rango + if (realValue > Percentage.MAX_VALUE) { + return Result.fail(new Error("El porcentaje no puede ser mayor a 100%.")); + } + + return Result.ok(new Percentage({ amount, scale })); + } + + get amount(): number { + return this.props.amount; + } + + get scale(): number { + return this.props.scale; + } + + getValue(): IPercentageProps { + return this.props; + } + + toNumber(): number { + return this.amount / Math.pow(10, this.scale); + } + + toString(): string { + return `${this.toNumber().toFixed(this.scale)}%`; + } +} diff --git a/apps/server/src/common/domain/value-objects/phone-number.ts b/apps/server/src/common/domain/value-objects/phone-number.ts index 3c593fd1..ec6fa8af 100644 --- a/apps/server/src/common/domain/value-objects/phone-number.ts +++ b/apps/server/src/common/domain/value-objects/phone-number.ts @@ -13,7 +13,7 @@ export class PhoneNumber extends ValueObject { const valueIsValid = PhoneNumber.validate(value); if (!valueIsValid.success) { - Result.fail(new Error(valueIsValid.error.errors[0].message)); + return Result.fail(new Error(valueIsValid.error.errors[0].message)); } return Result.ok(new PhoneNumber({ value: valueIsValid.data! })); } diff --git a/apps/server/src/common/domain/value-objects/postal-address.ts b/apps/server/src/common/domain/value-objects/postal-address.ts index 999e5a7b..5aeba48f 100644 --- a/apps/server/src/common/domain/value-objects/postal-address.ts +++ b/apps/server/src/common/domain/value-objects/postal-address.ts @@ -52,7 +52,7 @@ export class PostalAddress extends ValueObject { const valueIsValid = PostalAddress.validate(values); if (!valueIsValid.success) { - Result.fail(new Error(valueIsValid.error.errors[0].message)); + return Result.fail(new Error(valueIsValid.error.errors[0].message)); } return Result.ok(new PostalAddress(valueIsValid.data!)); } diff --git a/apps/server/src/common/domain/value-objects/quantity.ts b/apps/server/src/common/domain/value-objects/quantity.ts new file mode 100644 index 00000000..8b6babcf --- /dev/null +++ b/apps/server/src/common/domain/value-objects/quantity.ts @@ -0,0 +1,122 @@ +import { Result } from "@common/helpers"; +import { z } from "zod"; +import { ValueObject } from "./value-object"; + +const DEFAULT_SCALE = 2; + +interface IQuantityProps { + amount: number; + scale: number; +} + +interface IQuantity { + amount: number; + scale: number; + + getValue(): IQuantityProps; + toNumber(): number; + toString(): string; + + increment(anotherQuantity?: Quantity): Result; + decrement(anotherQuantity?: Quantity): Result; + hasSameScale(otherQuantity: Quantity): boolean; + convertScale(newScale: number): Result; +} + +export class Quantity extends ValueObject implements IQuantity { + protected static validate(values: IQuantityProps) { + const schema = z.object({ + amount: z.number().int(), + scale: z.number().int().min(0), + }); + + return schema.safeParse(values); + } + + public static readonly DEFAULT_SCALE = DEFAULT_SCALE; + public static readonly MIN_SCALE = 0; + public static readonly MAX_SCALE = 2; + + static create(props: IQuantityProps) { + const checkProps = Quantity.validate(props); + + if (!checkProps.success) { + Result.fail(new Error(checkProps.error.errors[0].message)); + } + return Result.ok(new Quantity({ ...checkProps.data! })); + } + + get amount(): number { + return this.props.amount; + } + + get scale(): number { + return this.props.scale; + } + + getValue(): IQuantityProps { + return this.props; + } + + toNumber(): number { + return this.amount / Math.pow(10, this.scale); + } + + toString(): string { + return this.toNumber().toFixed(this.scale); + } + + increment(anotherQuantity?: Quantity): Result { + if (!anotherQuantity) { + return Quantity.create({ + amount: Number(this.amount) + 1, + scale: this.scale, + }); + } + + if (!this.hasSameScale(anotherQuantity)) { + return Result.fail(Error("No se pueden sumar cantidades con diferentes escalas.")); + } + + return Quantity.create({ + amount: Number(this.amount) + Number(anotherQuantity.amount), + scale: this.scale, + }); + } + + decrement(anotherQuantity?: Quantity): Result { + if (!anotherQuantity) { + return Quantity.create({ + amount: Number(this.amount) - 1, + scale: this.scale, + }); + } + + if (!this.hasSameScale(anotherQuantity)) { + return Result.fail(Error("No se pueden restar cantidades con diferentes escalas.")); + } + + return Quantity.create({ + amount: Number(this.amount) - Number(anotherQuantity.amount), + scale: this.scale, + }); + } + + hasSameScale(otherQuantity: Quantity): boolean { + return this.scale === otherQuantity.scale; + } + + convertScale(newScale: number): Result { + if (newScale < Quantity.MIN_SCALE || newScale > Quantity.MAX_SCALE) { + return Result.fail(new Error(`Scale out of range: ${newScale}`)); + } + + const oldFactor = Math.pow(10, this.scale); + const value = Number(this.amount) / oldFactor; + + const newFactor = Math.pow(10, newScale); + const newValue = Math.round(value * newFactor); + + return Quantity.create({ amount: newValue, scale: newScale }); + } +} diff --git a/apps/server/src/common/domain/value-objects/slug.spec.ts b/apps/server/src/common/domain/value-objects/slug.spec.ts index e8a5d5e9..9172ddf7 100644 --- a/apps/server/src/common/domain/value-objects/slug.spec.ts +++ b/apps/server/src/common/domain/value-objects/slug.spec.ts @@ -36,6 +36,6 @@ describe("Slug Value Object", () => { const nullableSlugResult = Slug.createNullable("my-slug"); expect(nullableSlugResult.isSuccess).toBe(true); expect(nullableSlugResult.data.isSome()).toBe(true); - expect(nullableSlugResult.data.getValue()).toBe("my-slug"); + expect(nullableSlugResult.data.getOrUndefined()?.toString()).toBe("my-slug"); }); }); diff --git a/apps/server/src/common/domain/value-objects/slug.ts b/apps/server/src/common/domain/value-objects/slug.ts index 844bea57..42d74dbb 100644 --- a/apps/server/src/common/domain/value-objects/slug.ts +++ b/apps/server/src/common/domain/value-objects/slug.ts @@ -26,7 +26,7 @@ export class Slug extends ValueObject { const valueIsValid = Slug.validate(value); if (!valueIsValid.success) { - Result.fail(new Error(valueIsValid.error.errors[0].message)); + return Result.fail(new Error(valueIsValid.error.errors[0].message)); } return Result.ok(new Slug({ value: valueIsValid.data! })); } diff --git a/apps/server/src/common/domain/value-objects/tin-number.ts b/apps/server/src/common/domain/value-objects/tin-number.ts index 4804a9d9..695fa2f6 100644 --- a/apps/server/src/common/domain/value-objects/tin-number.ts +++ b/apps/server/src/common/domain/value-objects/tin-number.ts @@ -28,7 +28,7 @@ export class TINNumber extends ValueObject { const valueIsValid = TINNumber.validate(value); if (!valueIsValid.success) { - Result.fail(new Error(valueIsValid.error.errors[0].message)); + return Result.fail(new Error(valueIsValid.error.errors[0].message)); } return Result.ok(new TINNumber({ value: valueIsValid.data! })); } diff --git a/apps/server/src/common/domain/value-objects/unique-id.spec.ts b/apps/server/src/common/domain/value-objects/unique-id.spec.ts index 96393e7f..f85f793b 100644 --- a/apps/server/src/common/domain/value-objects/unique-id.spec.ts +++ b/apps/server/src/common/domain/value-objects/unique-id.spec.ts @@ -9,7 +9,7 @@ describe("UniqueID", () => { const result = UniqueID.create(id); expect(result.isSuccess).toBe(true); - expect(result.data?.isDefined()).toBe(true); + expect(result.data.toString()).toBe(id); }); test("should fail to create UniqueID with an invalid UUID", () => { @@ -18,18 +18,17 @@ describe("UniqueID", () => { expect(result.isFailure).toBe(true); }); - test("should create an undefined UniqueID when id is undefined and generateOnEmpty is false", () => { + test("should fail when id is undefined and generateOnEmpty is false", () => { const result = UniqueID.create(undefined, false); - expect(result.isSuccess).toBe(true); - expect(result.data?.isDefined()).toBe(false); + expect(result.isFailure).toBe(true); }); test("should generate a new UUID when id is undefined and generateOnEmpty is true", () => { const result = UniqueID.create(undefined, true); expect(result.isSuccess).toBe(true); - expect(result.data?.isDefined()).toBe(true); + expect(result.data?.toString()).toBeTruthy(); }); test("should fail when id is null", () => { @@ -42,13 +41,12 @@ describe("UniqueID", () => { const result = UniqueID.create(" ", true); expect(result.isSuccess).toBe(true); - expect(result.data?.isDefined()).toBe(true); + expect(result.data?.toString()).toBeTruthy(); }); - test("should create an undefined UniqueID when id is an empty string and generateOnEmpty is false", () => { + test("should fail when id is an empty string and generateOnEmpty is false", () => { const result = UniqueID.create(" ", false); - expect(result.isSuccess).toBe(true); - expect(result.data?.isDefined()).toBe(false); + expect(result.isFailure).toBe(true); }); }); diff --git a/apps/server/src/common/domain/value-objects/unique-id.ts b/apps/server/src/common/domain/value-objects/unique-id.ts index 609f4dd8..34c24b13 100644 --- a/apps/server/src/common/domain/value-objects/unique-id.ts +++ b/apps/server/src/common/domain/value-objects/unique-id.ts @@ -9,7 +9,7 @@ export class UniqueID extends ValueObject { if (!generateOnEmpty) { return Result.fail(new Error("ID cannot be undefined or null")); } - UniqueID.generateNewID(); + return UniqueID.generateNewID(); } const result = UniqueID.validate(id!); diff --git a/apps/server/src/common/domain/value-objects/value-objects.spec.ts b/apps/server/src/common/domain/value-objects/value-objects.spec.ts index e877ef3d..6762c11a 100644 --- a/apps/server/src/common/domain/value-objects/value-objects.spec.ts +++ b/apps/server/src/common/domain/value-objects/value-objects.spec.ts @@ -1,8 +1,16 @@ import { ValueObject } from "./value-object"; -class TestValueObject extends ValueObject<{ prop: string }> { - constructor(prop: string) { - super({ prop }); +interface ITestValueProps { + value: string; +} + +class TestValueObject extends ValueObject { + constructor(value: string) { + super({ value }); + } + + getValue() { + return this.props; } } diff --git a/apps/server/src/common/helpers/maybe.spec.ts b/apps/server/src/common/helpers/maybe.spec.ts index 667e2725..b6a145a6 100644 --- a/apps/server/src/common/helpers/maybe.spec.ts +++ b/apps/server/src/common/helpers/maybe.spec.ts @@ -4,13 +4,13 @@ describe("Maybe", () => { test("debe contener un valor cuando se usa Some", () => { const maybeNumber = Maybe.Some(42); expect(maybeNumber.isSome()).toBe(true); - expect(maybeNumber.getValue()).toBe(42); + expect(maybeNumber.getOrUndefined()).toBe(42); }); test("debe estar vacío cuando se usa None", () => { const maybeEmpty = Maybe.None(); expect(maybeEmpty.isSome()).toBe(false); - expect(maybeEmpty.getValue()).toBeUndefined(); + expect(maybeEmpty.getOrUndefined()).toBeUndefined(); }); test("map debe transformar el valor si existe", () => { @@ -18,7 +18,7 @@ describe("Maybe", () => { const maybeDoubled = maybeNumber.map((n) => n * 2); expect(maybeDoubled.isSome()).toBe(true); - expect(maybeDoubled.getValue()).toBe(20); + expect(maybeDoubled.getOrUndefined()).toBe(20); }); test("map debe retornar None si el valor no existe", () => { @@ -26,6 +26,6 @@ describe("Maybe", () => { const maybeTransformed = maybeEmpty.map((n) => n * 2); expect(maybeTransformed.isSome()).toBe(false); - expect(maybeTransformed.getValue()).toBeUndefined(); + expect(maybeTransformed.getOrUndefined()).toBeUndefined(); }); }); diff --git a/apps/server/src/common/helpers/maybe.ts b/apps/server/src/common/helpers/maybe.ts index 52eb9c6e..eae6aa39 100644 --- a/apps/server/src/common/helpers/maybe.ts +++ b/apps/server/src/common/helpers/maybe.ts @@ -24,10 +24,14 @@ export class Maybe { return this.value !== undefined; } - getValue(): T | undefined { + unwrap(): T | undefined { return this.value; } + getOrUndefined(): T | undefined { + return this.unwrap(); + } + map(fn: (value: T) => U): Maybe { return this.isSome() ? Maybe.Some(fn(this.value as T)) : Maybe.None(); } diff --git a/apps/server/src/contexts/auth/domain/services/user.service.ts b/apps/server/src/contexts/auth/domain/services/user.service.ts index f3827f6f..75596501 100644 --- a/apps/server/src/contexts/auth/domain/services/user.service.ts +++ b/apps/server/src/contexts/auth/domain/services/user.service.ts @@ -1,12 +1,12 @@ import { UniqueID } from "@common/domain"; -import { Result } from "@common/helpers"; +import { Collection, Result } from "@common/helpers"; import { IUserRepository, User } from ".."; import { IUserService } from "./user-service.interface"; export class UserService implements IUserService { constructor(private readonly userRepository: IUserRepository) {} - async findUsers(transaction?: any): Promise> { + async findUsers(transaction?: any): Promise, Error>> { const usersOrError = await this.userRepository.findAll(transaction); if (usersOrError.isFailure) { return Result.fail(usersOrError.error); @@ -14,7 +14,7 @@ export class UserService implements IUserService { // Solo devolver usuarios activos const activeUsers = usersOrError.data.filter((user) => user.isActive); - return Result.ok(activeUsers); + return Result.ok(new Collection(activeUsers)); } async findUserById(userId: UniqueID, transaction?: any): Promise> { diff --git a/apps/server/src/contexts/auth/presentation/controllers/listUsers/list-users.presenter.ts b/apps/server/src/contexts/auth/presentation/controllers/listUsers/list-users.presenter.ts index ee8d20cf..38b094cc 100644 --- a/apps/server/src/contexts/auth/presentation/controllers/listUsers/list-users.presenter.ts +++ b/apps/server/src/contexts/auth/presentation/controllers/listUsers/list-users.presenter.ts @@ -1,13 +1,13 @@ -import { ensureString } from "@common/helpers"; +import { Collection, ensureString } from "@common/helpers"; import { User } from "@contexts/auth/domain"; import { IListUsersResponseDTO } from "../../dto"; export interface IListUsersPresenter { - toDTO: (users: User[]) => IListUsersResponseDTO[]; + toDTO: (users: Collection) => IListUsersResponseDTO[]; } export const listUsersPresenter: IListUsersPresenter = { - toDTO: (users: User[]): IListUsersResponseDTO[] => + toDTO: (users: Collection): IListUsersResponseDTO[] => users.map((user) => ({ id: ensureString(user.id.toString()), email: ensureString(user.email.toString()), diff --git a/apps/server/src/contexts/customer-billing/application/customer-invoices/index.ts b/apps/server/src/contexts/customer-billing/application/customer-invoices/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/server/src/contexts/customer-billing/application/customers/index.ts b/apps/server/src/contexts/customer-billing/application/customers/index.ts new file mode 100644 index 00000000..1bcf6e04 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/application/customers/index.ts @@ -0,0 +1 @@ +export * from "./list-customers"; diff --git a/apps/server/src/contexts/customer-billing/application/customers/list-customers/index.ts b/apps/server/src/contexts/customer-billing/application/customers/list-customers/index.ts new file mode 100644 index 00000000..052600c9 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/application/customers/list-customers/index.ts @@ -0,0 +1 @@ +export * from "./list-customers.use-case"; diff --git a/apps/server/src/contexts/customer-billing/application/customers/list-customers/list-customers.use-case.ts b/apps/server/src/contexts/customer-billing/application/customers/list-customers/list-customers.use-case.ts new file mode 100644 index 00000000..66fec103 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/application/customers/list-customers/list-customers.use-case.ts @@ -0,0 +1,17 @@ +import { Collection, Result } from "@common/helpers"; +import { ITransactionManager } from "@common/infrastructure/database"; +import { Customer } from "@contexts/customer-billing/domain/aggregates"; +import { ICustomerService } from "@contexts/customer-billing/domain/services"; + +export class ListCustomersUseCase { + constructor( + private readonly customerService: ICustomerService, + private readonly transactionManager: ITransactionManager + ) {} + + public execute(): Promise, Error>> { + return this.transactionManager.complete((transaction) => { + return this.customerService.findCustomers(transaction); + }); + } +} diff --git a/apps/server/src/contexts/customer-billing/application/index.ts b/apps/server/src/contexts/customer-billing/application/index.ts new file mode 100644 index 00000000..e48a5e9f --- /dev/null +++ b/apps/server/src/contexts/customer-billing/application/index.ts @@ -0,0 +1,2 @@ +export * from "./customer-invoices"; +export * from "./customers"; diff --git a/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/customer-invoice.ts b/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/customer-invoice.ts new file mode 100644 index 00000000..9266cfd3 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/customer-invoice.ts @@ -0,0 +1,25 @@ +import { AggregateRoot, UniqueID } from "@common/domain"; +import { Result } from "@common/helpers"; + +export interface ICustomerInvoiceProps {} + +export interface ICustomerInvoice {} + +export class CustomerInvoice + extends AggregateRoot + implements ICustomerInvoice +{ + static create(props: ICustomerInvoiceProps, id?: UniqueID): Result { + const invoice = new CustomerInvoice(props, id); + + // Reglas de negocio / validaciones + // ... + // ... + + // 🔹 Disparar evento de dominio "CustomerAuthenticatedEvent" + //const { customer } = props; + //user.addDomainEvent(new CustomerAuthenticatedEvent(id, customer.toString())); + + return Result.ok(invoice); + } +} diff --git a/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/index.ts b/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/index.ts new file mode 100644 index 00000000..8fdd6983 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/index.ts @@ -0,0 +1 @@ +export * from "./customer-invoice"; diff --git a/apps/server/src/contexts/customer-billing/domain/aggregates/customer/customer.ts b/apps/server/src/contexts/customer-billing/domain/aggregates/customer/customer.ts new file mode 100644 index 00000000..805c2350 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/aggregates/customer/customer.ts @@ -0,0 +1,130 @@ +import { + AggregateRoot, + EmailAddress, + PhoneNumber, + PostalAddress, + TINNumber, + UniqueID, +} from "@common/domain"; +import { Maybe, Result } from "@common/helpers"; + +export interface ICustomerProps { + reference: string; + isFreelancer: boolean; + name: string; + tin: TINNumber; + address: PostalAddress; + email: EmailAddress; + phone: PhoneNumber; + legalRecord: string; + defaultTax: number; + status: string; + langCode: string; + currencyCode: string; + + tradeName: Maybe; + website: Maybe; + fax: Maybe; +} + +export interface ICustomer { + id: UniqueID; + reference: string; + name: string; + tin: TINNumber; + address: PostalAddress; + email: EmailAddress; + phone: PhoneNumber; + legalRecord: string; + defaultTax: number; + langCode: string; + currencyCode: string; + + tradeName: Maybe; + fax: Maybe; + website: Maybe; + + isCustomer: boolean; + isFreelancer: boolean; + isActive: boolean; +} + +export class Customer extends AggregateRoot implements ICustomer { + static create(props: ICustomerProps, id?: UniqueID): Result { + const customer = new Customer(props, id); + + // Reglas de negocio / validaciones + // ... + // ... + + // 🔹 Disparar evento de dominio "CustomerAuthenticatedEvent" + //const { customer } = props; + //user.addDomainEvent(new CustomerAuthenticatedEvent(id, customer.toString())); + + return Result.ok(customer); + } + + get reference() { + return this.props.reference; + } + + get name() { + return this.props.name; + } + + get tradeName() { + return this.props.tradeName; + } + + get tin(): TINNumber { + return this.props.tin; + } + + get address(): PostalAddress { + return this.props.address; + } + + get email(): EmailAddress { + return this.props.email; + } + + get phone(): PhoneNumber { + return this.props.phone; + } + + get fax(): Maybe { + return this.props.fax; + } + + get website() { + return this.props.website; + } + + get legalRecord() { + return this.props.legalRecord; + } + + get defaultTax() { + return this.props.defaultTax; + } + + get langCode() { + return this.props.langCode; + } + + get currencyCode() { + return this.props.currencyCode; + } + + get isCustomer(): boolean { + return !this.props.isFreelancer; + } + + get isFreelancer(): boolean { + return this.props.isFreelancer; + } + + get isActive(): boolean { + return this.props.status === "active"; + } +} diff --git a/apps/server/src/contexts/customer-billing/domain/aggregates/customer/index.ts b/apps/server/src/contexts/customer-billing/domain/aggregates/customer/index.ts new file mode 100644 index 00000000..2b295031 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/aggregates/customer/index.ts @@ -0,0 +1 @@ +export * from "./customer"; diff --git a/apps/server/src/contexts/customer-billing/domain/aggregates/index.ts b/apps/server/src/contexts/customer-billing/domain/aggregates/index.ts new file mode 100644 index 00000000..b3a2d541 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/aggregates/index.ts @@ -0,0 +1,2 @@ +export * from "./customer"; +export * from "./customer-invoice"; diff --git a/apps/server/src/contexts/customer-billing/domain/events/customer-invoice-item.ts b/apps/server/src/contexts/customer-billing/domain/events/customer-invoice-item.ts new file mode 100644 index 00000000..753a5c29 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/events/customer-invoice-item.ts @@ -0,0 +1,61 @@ +import { DomainEntity, MoneyValue, Percentage, UniqueID } from "@common/domain"; +import { Quantity } from "@common/domain/value-objects/quantity"; +import { Maybe, Result } from "@common/helpers"; + +export interface ICustomerInvoiceItemProps { + description: Maybe; // Descripción del artículo o servicio + quantity: Quantity; // Cantidad de unidades + unitPrice: MoneyValue; // Precio unitario en la moneda de la factura + // subtotalPrice: MoneyValue; // Precio unitario * Cantidad + discount: Percentage; // % descuento + // totalPrice: MoneyValue; +} + +export interface ICustomerInvoiceItem { + description: Maybe; + quantity: Quantity; + unitPrice: MoneyValue; + subtotalPrice: MoneyValue; + discount: Percentage; + totalPrice: MoneyValue; +} + +export class CustomerInvoiceItem + extends DomainEntity + implements ICustomerInvoiceItem +{ + public static create( + props: ICustomerInvoiceItemProps, + id?: UniqueID + ): Result { + return Result.ok(new CustomerInvoiceItem(props, id)); + } + + get description(): Maybe { + return this.props.description; + } + + get quantity(): Quantity { + return this.props.quantity; + } + + get unitPrice(): MoneyValue { + return this.props.unitPrice; + } + + get subtotalPrice(): MoneyValue { + return this.quantity.isNull() || this.unitPrice.isNull() + ? MoneyValue.create({ amount: null, scale: 2 }).object + : this.unitPrice.multiply(this.quantity.toNumber()); + } + + get discount(): Percentage { + return this.props.discount; + } + + get totalPrice(): MoneyValue { + return this.subtotalPrice.isNull() + ? MoneyValue.create({ amount: null, scale: 2 }).object + : this.subtotalPrice.subtract(this.subtotalPrice.percentage(this.discount.toNumber())); + } +} diff --git a/apps/server/src/contexts/customer-billing/domain/index.ts b/apps/server/src/contexts/customer-billing/domain/index.ts new file mode 100644 index 00000000..ef023faa --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/index.ts @@ -0,0 +1,3 @@ +export * from "./aggregates"; +export * from "./repositories"; +export * from "./services"; diff --git a/apps/server/src/contexts/customer-billing/domain/repositories/customer-repository.interface.ts b/apps/server/src/contexts/customer-billing/domain/repositories/customer-repository.interface.ts new file mode 100644 index 00000000..c2189ec0 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/repositories/customer-repository.interface.ts @@ -0,0 +1,9 @@ +import { EmailAddress, UniqueID } from "@common/domain"; +import { Collection, Result } from "@common/helpers"; +import { Customer } from "../aggregates"; + +export interface ICustomerRepository { + findAll(transaction?: any): Promise, Error>>; + findById(id: UniqueID, transaction?: any): Promise>; + findByEmail(email: EmailAddress, transaction?: any): Promise>; +} diff --git a/apps/server/src/contexts/customer-billing/domain/repositories/index.ts b/apps/server/src/contexts/customer-billing/domain/repositories/index.ts new file mode 100644 index 00000000..2ae98271 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/repositories/index.ts @@ -0,0 +1 @@ +export * from "./customer-repository.interface"; diff --git a/apps/server/src/contexts/customer-billing/domain/services/customer-service.interface.ts b/apps/server/src/contexts/customer-billing/domain/services/customer-service.interface.ts new file mode 100644 index 00000000..a47da4f6 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/services/customer-service.interface.ts @@ -0,0 +1,8 @@ +import { UniqueID } from "@common/domain"; +import { Collection, Result } from "@common/helpers"; +import { Customer } from "../aggregates"; + +export interface ICustomerService { + findCustomers(transaction?: any): Promise, Error>>; + findCustomerById(userId: UniqueID, transaction?: any): Promise>; +} diff --git a/apps/server/src/contexts/customer-billing/domain/services/customer.service.ts b/apps/server/src/contexts/customer-billing/domain/services/customer.service.ts new file mode 100644 index 00000000..6beb6bd7 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/services/customer.service.ts @@ -0,0 +1,24 @@ +import { UniqueID } from "@common/domain"; +import { Collection, Result } from "@common/helpers"; +import { Customer } from "../aggregates"; +import { ICustomerRepository } from "../repositories"; +import { ICustomerService } from "./customer-service.interface"; + +export class CustomerService implements ICustomerService { + constructor(private readonly customerRepository: ICustomerRepository) {} + + async findCustomers(transaction?: any): Promise, Error>> { + const customersOrError = await this.customerRepository.findAll(transaction); + if (customersOrError.isFailure) { + return Result.fail(customersOrError.error); + } + + // Solo devolver usuarios activos + const activeCustomers = customersOrError.data.filter((customer) => customer.isActive); + return Result.ok(new Collection(activeCustomers)); + } + + async findCustomerById(customerId: UniqueID, transaction?: any): Promise> { + return await this.customerRepository.findById(customerId, transaction); + } +} diff --git a/apps/server/src/contexts/customer-billing/domain/services/index.ts b/apps/server/src/contexts/customer-billing/domain/services/index.ts new file mode 100644 index 00000000..fd8abcbb --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/services/index.ts @@ -0,0 +1,2 @@ +export * from "./customer-service.interface"; +export * from "./customer.service"; diff --git a/apps/server/src/contexts/sales/infraestructure/index.ts b/apps/server/src/contexts/customer-billing/infraestructure/index.ts similarity index 100% rename from apps/server/src/contexts/sales/infraestructure/index.ts rename to apps/server/src/contexts/customer-billing/infraestructure/index.ts diff --git a/apps/server/src/contexts/customer-billing/infraestructure/mappers/customer.mapper.ts b/apps/server/src/contexts/customer-billing/infraestructure/mappers/customer.mapper.ts new file mode 100644 index 00000000..f94d33c9 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/infraestructure/mappers/customer.mapper.ts @@ -0,0 +1,100 @@ +import { EmailAddress, PhoneNumber, PostalAddress, TINNumber, UniqueID } from "@common/domain"; +import { Maybe, Result } from "@common/helpers"; +import { + ISequelizeMapper, + MapperParamsType, + SequelizeMapper, +} from "@common/infrastructure/sequelize/sequelize-mapper"; +import { Customer } from "@contexts/customer-billing/domain"; +import { CustomerCreationAttributes, CustomerModel } from "../sequelize/customer.model"; + +export interface ICustomerMapper + extends ISequelizeMapper {} + +export class CustomerMapper + extends SequelizeMapper + implements ICustomerMapper +{ + public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result { + const idOrError = UniqueID.create(source.id); + const tinOrError = TINNumber.create(source.tin); + const emailOrError = EmailAddress.create(source.email); + const phoneOrError = PhoneNumber.create(source.phone); + const faxOrError = PhoneNumber.createNullable(source.fax); + const postalAddressOrError = PostalAddress.create({ + street: source.street, + city: source.city, + state: source.state, + postalCode: source.postal_code, + country: source.country, + }); + + const result = Result.combine([ + idOrError, + tinOrError, + emailOrError, + phoneOrError, + faxOrError, + postalAddressOrError, + ]); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Customer.create( + { + isFreelancer: source.is_freelancer, + reference: source.reference, + name: source.name, + tradeName: source.trade_name ? Maybe.Some(source.trade_name) : Maybe.None(), + tin: tinOrError.data, + address: postalAddressOrError.data, + email: emailOrError.data, + phone: phoneOrError.data, + fax: faxOrError.data, + website: source.website ? Maybe.Some(source.website) : Maybe.None(), + legalRecord: source.legal_record, + defaultTax: source.default_tax, + status: source.status, + langCode: source.lang_code, + currencyCode: source.currency_code, + }, + idOrError.data + ); + } + + public mapToPersistence( + source: Customer, + params?: MapperParamsType + ): Result { + return Result.ok({ + id: source.id.toString(), + reference: source.reference, + is_freelancer: source.isFreelancer, + name: source.name, + trade_name: source.tradeName.isSome() ? source.tradeName.getValue() : undefined, + tin: source.tin.toString(), + + street: source.address.street, + city: source.address.city, + state: source.address.state, + postal_code: source.address.postalCode, + country: source.address.country, + + email: source.email.toString(), + phone: source.phone.toString(), + fax: source.fax.isSome() ? source.fax.getValue()?.toString() : undefined, + website: source.website.isSome() ? source.website.getValue() : undefined, + + legal_record: source.legalRecord, + default_tax: source.defaultTax, + status: source.isActive ? "active" : "inactive", + lang_code: source.langCode, + currency_code: source.currencyCode, + }); + } +} + +const customerMapper: CustomerMapper = new CustomerMapper(); +export { customerMapper }; diff --git a/apps/server/src/contexts/customer-billing/infraestructure/mappers/index.ts b/apps/server/src/contexts/customer-billing/infraestructure/mappers/index.ts new file mode 100644 index 00000000..7f5fae75 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/infraestructure/mappers/index.ts @@ -0,0 +1 @@ +export * from "./customer.mapper"; diff --git a/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice-item.model.ts b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice-item.model.ts new file mode 100644 index 00000000..91919de0 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice-item.model.ts @@ -0,0 +1,100 @@ +import { + CreationOptional, + DataTypes, + InferAttributes, + InferCreationAttributes, + Model, + NonAttribute, + Sequelize, +} from "sequelize"; +import { CustomerInvoiceModel } from "./customer-invoice.model"; + +export type CustomerInvoiceItemCreationAttributes = InferCreationAttributes< + CustomerInvoiceItemModel, + { omit: "invoice" } +>; + +export class CustomerInvoiceItemModel extends Model< + InferAttributes, + InferCreationAttributes +> { + static associate(connection: Sequelize) { + const { CustomerInvoiceModel, CustomerInvoiceItemModel } = connection.models; + + CustomerInvoiceItemModel.belongsTo(CustomerInvoiceModel, { + as: "invoice", + foreignKey: "invoice_id", + onDelete: "CASCADE", + }); + } + + declare invoice_id: string; + declare item_id: string; + declare id_article: CreationOptional; + declare position: number; + declare description: CreationOptional; + declare quantity: CreationOptional; + declare unit_price: CreationOptional; + declare subtotal_price: CreationOptional; + declare discount: CreationOptional; + declare total_price: CreationOptional; + + declare invoice: NonAttribute; +} + +export default (sequelize: Sequelize) => { + CustomerInvoiceItemModel.init( + { + item_id: { + type: new DataTypes.UUID(), + primaryKey: true, + }, + invoice_id: { + type: new DataTypes.UUID(), + primaryKey: true, + }, + id_article: { + type: DataTypes.BIGINT().UNSIGNED, + allowNull: true, + }, + position: { + type: new DataTypes.MEDIUMINT(), + autoIncrement: false, + allowNull: false, + }, + description: { + type: new DataTypes.TEXT(), + allowNull: true, + }, + quantity: { + type: DataTypes.BIGINT(), + allowNull: true, + }, + unit_price: { + type: new DataTypes.BIGINT(), + allowNull: true, + }, + subtotal_price: { + type: new DataTypes.BIGINT(), + allowNull: true, + }, + discount: { + type: new DataTypes.SMALLINT(), + allowNull: true, + }, + total_price: { + type: new DataTypes.BIGINT(), + allowNull: true, + }, + }, + { + sequelize, + tableName: "invoice_items", + timestamps: false, + + indexes: [], + } + ); + + return CustomerInvoiceItemModel; +}; diff --git a/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice.model.ts b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice.model.ts new file mode 100644 index 00000000..7e0f7f2a --- /dev/null +++ b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice.model.ts @@ -0,0 +1,251 @@ +import { + CreationOptional, + DataTypes, + InferAttributes, + InferCreationAttributes, + Model, + NonAttribute, + Sequelize, +} from "sequelize"; +import { CustomerInvoiceItemModel } from "./customer-invoice-item.model"; +import { CustomerModel } from "./customer.model"; + +export type CustomerInvoiceCreationAttributes = InferCreationAttributes< + CustomerInvoiceModel, + { omit: "items" | "customer" } +> & { + //items: CustomerInvoiceItemCreationAttributes[]; + customer_id: string; +}; + +export class CustomerInvoiceModel extends Model< + InferAttributes, + InferCreationAttributes +> { + // To avoid table creation + /*static async sync(): Promise { + return Promise.resolve(); + }*/ + + static associate(connection: Sequelize) { + const { CustomerInvoiceModel, CustomerInvoiceItemModel, CustomerModel } = connection.models; + + CustomerInvoiceModel.hasMany(CustomerInvoiceItemModel, { + as: "items", + foreignKey: "quote_id", + onDelete: "CASCADE", + }); + + CustomerInvoiceModel.belongsTo(CustomerModel, { + foreignKey: "dealer_id", + as: "dealer", + onDelete: "RESTRICT", + }); + } + + declare id: string; + declare status: string; + + declare issue_date: string; + declare invoice_number: string; + declare invoide_type: string; + + declare lang_code: string; + declare currency_code: string; + + declare customer_id: string; + declare customer_tin: string; + declare customer_name: string; + + declare customer_reference: CreationOptional; + + declare customer_street: string; + declare customer_city: string; + declare customer_state: string; + declare customer_postal_code: string; + declare customer_country: string; + + declare subtotal_price: CreationOptional; + + declare discount: CreationOptional; + declare discount_price: CreationOptional; + + declare before_tax_price: CreationOptional; + + declare tax: CreationOptional; + declare tax_price: CreationOptional; + + declare total_price: CreationOptional; + + declare notes: CreationOptional; + + declare items: NonAttribute; + declare customer: NonAttribute; + + declare integrity_hash: CreationOptional; + declare previous_invoice_id: CreationOptional; + declare signed_at: CreationOptional; +} + +export default (sequelize: Sequelize) => { + CustomerInvoiceModel.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + }, + + status: { + type: new DataTypes.STRING(), + allowNull: false, + }, + + issue_date: { + type: new DataTypes.DATEONLY(), + allowNull: false, + }, + + invoice_number: { + type: DataTypes.STRING(), + allowNull: false, + }, + + invoide_type: { + type: new DataTypes.STRING(), + allowNull: false, + }, + + lang_code: { + type: DataTypes.STRING(2), + allowNull: false, + defaultValue: "es", + }, + + currency_code: { + type: new DataTypes.STRING(3), + allowNull: false, + defaultValue: "EUR", + }, + + customer_id: { + type: new DataTypes.UUID(), + primaryKey: true, + }, + + customer_name: { + type: DataTypes.STRING, + allowNull: false, + }, + + customer_tin: { + type: DataTypes.STRING, + allowNull: false, + }, + + customer_reference: { + type: new DataTypes.STRING(), + }, + + customer_street: { + type: DataTypes.STRING, + allowNull: false, + }, + customer_city: { + type: DataTypes.STRING, + allowNull: false, + }, + customer_state: { + type: DataTypes.STRING, + allowNull: false, + }, + customer_postal_code: { + type: DataTypes.STRING, + allowNull: false, + }, + customer_country: { + type: DataTypes.STRING, + allowNull: false, + }, + + subtotal_price: { + type: new DataTypes.BIGINT(), + allowNull: true, + }, + + discount: { + type: new DataTypes.SMALLINT(), + allowNull: true, + }, + + discount_price: { + type: new DataTypes.BIGINT(), + allowNull: true, + }, + + before_tax_price: { + type: new DataTypes.BIGINT(), + allowNull: true, + }, + + tax: { + type: new DataTypes.SMALLINT(), + allowNull: true, + }, + + tax_price: { + type: new DataTypes.BIGINT(), + allowNull: true, + }, + + total_price: { + type: new DataTypes.BIGINT(), + allowNull: true, + }, + + notes: { + type: DataTypes.TEXT, + }, + + integrity_hash: { + type: DataTypes.STRING, + allowNull: false, + comment: "Hash criptográfico para asegurar integridad", + }, + previous_invoice_id: { + type: DataTypes.UUID, + allowNull: true, + comment: "Referencia a la factura anterior (si aplica)", + }, + signed_at: { + type: DataTypes.DATE, + allowNull: false, + comment: "Fecha en que la factura fue firmada digitalmente", + }, + }, + { + sequelize, + tableName: "customer_invoices", + + paranoid: true, // softs deletes + timestamps: true, + + createdAt: "created_at", + updatedAt: "updated_at", + deletedAt: "deleted_at", + + indexes: [ + { name: "status_idx", fields: ["status"] }, + { name: "reference_idx", fields: ["reference"] }, + { name: "deleted_at_idx", fields: ["deleted_at"] }, + { name: "signed_at_idx", fields: ["signed_at"] }, + ], + + whereMergeStrategy: "and", // <- cómo tratar el merge de un scope + + defaultScope: {}, + + scopes: {}, + } + ); + return CustomerInvoiceModel; +}; diff --git a/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer.model.ts b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer.model.ts new file mode 100644 index 00000000..bc63a03b --- /dev/null +++ b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer.model.ts @@ -0,0 +1,172 @@ +import { + CreationOptional, + DataTypes, + InferAttributes, + InferCreationAttributes, + Model, + Sequelize, +} from "sequelize"; + +export type CustomerCreationAttributes = InferCreationAttributes & {}; + +export class CustomerModel extends Model< + InferAttributes, + InferCreationAttributes +> { + // To avoid table creation + /*static async sync(): Promise { + return Promise.resolve(); + }*/ + + declare id: string; + declare reference: CreationOptional; + + declare is_freelancer: boolean; + declare name: string; + declare trade_name: CreationOptional; + declare tin: string; + + declare street: string; + declare city: string; + declare state: string; + declare postal_code: string; + declare country: string; + + declare email: string; + declare phone: string; + declare fax: CreationOptional; + declare website: CreationOptional; + + declare legal_record: string; + + declare default_tax: number; + declare status: string; + declare lang_code: string; + declare currency_code: string; +} + +export default (sequelize: Sequelize) => { + CustomerModel.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + }, + reference: { + type: DataTypes.STRING, + allowNull: false, + }, + is_freelancer: { + type: DataTypes.BOOLEAN, + allowNull: false, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + trade_name: { + type: DataTypes.STRING, + allowNull: true, + }, + tin: { + type: DataTypes.STRING, + allowNull: false, + }, + + street: { + type: DataTypes.STRING, + allowNull: false, + }, + city: { + type: DataTypes.STRING, + allowNull: false, + }, + state: { + type: DataTypes.STRING, + allowNull: false, + }, + postal_code: { + type: DataTypes.STRING, + allowNull: false, + }, + country: { + type: DataTypes.STRING, + allowNull: false, + }, + + email: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isEmail: true, + }, + }, + phone: { + type: DataTypes.STRING, + allowNull: false, + }, + fax: { + type: DataTypes.STRING, + allowNull: true, + }, + website: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isUrl: true, + }, + }, + legal_record: { + type: DataTypes.TEXT, + allowNull: false, + }, + + default_tax: { + type: new DataTypes.SMALLINT(), + allowNull: false, + defaultValue: 2100, + }, + + lang_code: { + type: DataTypes.STRING(2), + allowNull: false, + defaultValue: "es", + }, + + currency_code: { + type: new DataTypes.STRING(3), + allowNull: false, + defaultValue: "EUR", + }, + + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "active", + }, + }, + { + sequelize, + tableName: "customers", + + paranoid: true, // softs deletes + timestamps: true, + + createdAt: "created_at", + updatedAt: "updated_at", + deletedAt: "deleted_at", + + indexes: [ + { name: "email_idx", fields: ["email"], unique: true }, + { name: "reference_idx", fields: ["reference"], unique: true }, + ], + + whereMergeStrategy: "and", // <- cómo tratar el merge de un scope + + defaultScope: {}, + + scopes: {}, + } + ); + return CustomerModel; +}; diff --git a/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer.repository.ts b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer.repository.ts new file mode 100644 index 00000000..db511f73 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer.repository.ts @@ -0,0 +1,81 @@ +import { EmailAddress, UniqueID } from "@common/domain"; +import { Collection, Result } from "@common/helpers"; +import { SequelizeRepository } from "@common/infrastructure"; +import { Customer, ICustomerRepository } from "@contexts/customer-billing/domain"; +import { Transaction } from "sequelize"; +import { customerMapper, ICustomerMapper } from "../mappers"; +import { CustomerModel } from "./customer.model"; + +class CustomerRepository extends SequelizeRepository implements ICustomerRepository { + private readonly _mapper!: ICustomerMapper; + + /** + * 🔹 Función personalizada para mapear errores de unicidad en autenticación + */ + private _customErrorMapper(error: Error): string | null { + if (error.name === "SequelizeUniqueConstraintError") { + return "Customer with this email already exists"; + } + + return null; + } + + constructor(mapper: ICustomerMapper) { + super(); + this._mapper = mapper; + } + + async findAll(transaction?: Transaction): Promise, Error>> { + try { + const rawCustomers: any = await this._findAll(CustomerModel, {}, transaction); + + if (!rawCustomers === true) { + return Result.fail(new Error("Customer with email not exists")); + } + + return this._mapper.mapArrayToDomain(rawCustomers); + } catch (error: any) { + return this._handleDatabaseError(error, this._customErrorMapper); + } + } + + async findById(id: UniqueID, transaction?: Transaction): Promise> { + try { + const rawCustomer: any = await this._getById(CustomerModel, id, {}, transaction); + + if (!rawCustomer === true) { + return Result.fail(new Error(`Customer with id ${id.toString()} not exists`)); + } + + return this._mapper.mapToDomain(rawCustomer); + } catch (error: any) { + return this._handleDatabaseError(error, this._customErrorMapper); + } + } + + async findByEmail( + email: EmailAddress, + transaction?: Transaction + ): Promise> { + try { + const rawCustomer: any = await this._getBy( + CustomerModel, + "email", + email.toString(), + {}, + transaction + ); + + if (!rawCustomer === true) { + return Result.fail(new Error(`Customer with email ${email.toString()} not exists`)); + } + + return this._mapper.mapToDomain(rawCustomer); + } catch (error: any) { + return this._handleDatabaseError(error, this._customErrorMapper); + } + } +} + +const customerRepository = new CustomerRepository(customerMapper); +export { customerRepository }; diff --git a/apps/server/src/contexts/customer-billing/infraestructure/sequelize/index.ts b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/index.ts new file mode 100644 index 00000000..38e74d82 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/index.ts @@ -0,0 +1,9 @@ +import { ICustomerRepository } from "@contexts/customer-billing/domain"; +import { customerRepository } from "./customer.repository"; + +export * from "./customer.model"; +export * from "./customer.repository"; + +export const createCustomerRepository = (): ICustomerRepository => { + return customerRepository; +}; diff --git a/apps/server/src/contexts/customer-billing/presentation/controllers/customer-invoices/list/index.ts b/apps/server/src/contexts/customer-billing/presentation/controllers/customer-invoices/list/index.ts new file mode 100644 index 00000000..1758ef67 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/controllers/customer-invoices/list/index.ts @@ -0,0 +1,2 @@ +export * from "./list-customer-invoices.controller"; +export * from "./list-customer-invoices.presenter"; diff --git a/apps/server/src/contexts/customer-billing/presentation/controllers/customer-invoices/list/list-customer-invoices.controller.ts b/apps/server/src/contexts/customer-billing/presentation/controllers/customer-invoices/list/list-customer-invoices.controller.ts new file mode 100644 index 00000000..f45280f1 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/controllers/customer-invoices/list/list-customer-invoices.controller.ts @@ -0,0 +1,37 @@ +import { ExpressController } from "@common/presentation"; +import { ListCustomerInvoicesUseCase } from "@contexts/customer-billing/application"; +import { IListCustomerInvoicesPresenter } from "./list-customer-invoices.presenter"; + +export class ListCustomerInvoicesController extends ExpressController { + public constructor( + private readonly listCustomerInvoices: ListCustomerInvoicesUseCase, + private readonly presenter: IListCustomerInvoicesPresenter + ) { + super(); + } + + protected async executeImpl() { + const customersOrError = await this.listCustomerInvoices.execute(); + + if (customersOrError.isFailure) { + return this.handleError(customersOrError.error); + } + + return this.ok(this.presenter.toDTO(customersOrError.data)); + } + + private handleError(error: Error) { + const message = error.message; + + if ( + message.includes("Database connection lost") || + message.includes("Database request timed out") + ) { + return this.unavailableError( + "Database service is currently unavailable. Please try again later." + ); + } + + return this.conflictError(message); + } +} diff --git a/apps/server/src/contexts/customer-billing/presentation/controllers/customer-invoices/list/list-customer-invoices.presenter.ts b/apps/server/src/contexts/customer-billing/presentation/controllers/customer-invoices/list/list-customer-invoices.presenter.ts new file mode 100644 index 00000000..fa95e929 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/controllers/customer-invoices/list/list-customer-invoices.presenter.ts @@ -0,0 +1,38 @@ +import { Collection, ensureBoolean, ensureNumber, ensureString } from "@common/helpers"; + +import { IListCustomerInvoicesResponseDTO } from "../../../dto"; + +export interface IListCustomerInvoicesPresenter { + toDTO: (customers: Collection) => IListCustomerInvoicesResponseDTO[]; +} + +export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = { + toDTO: (customers: Collection): IListCustomerInvoicesResponseDTO[] => + customers.map((customer) => ({ + id: ensureString(customer.id.toString()), + reference: ensureString(customer.reference), + + is_freelancer: ensureBoolean(customer.isFreelancer), + name: ensureString(customer.name), + trade_name: ensureString(customer.tradeName.getValue()), + tin: ensureString(customer.tin.toString()), + + street: ensureString(customer.address.street), + city: ensureString(customer.address.city), + state: ensureString(customer.address.state), + postal_code: ensureString(customer.address.postalCode), + country: ensureString(customer.address.country), + + email: ensureString(customer.email.toString()), + phone: ensureString(customer.phone.toString()), + fax: ensureString(customer.fax.getValue()?.toString()), + website: ensureString(customer.website.getValue()), + + legal_record: ensureString(customer.legalRecord), + + default_tax: ensureNumber(customer.defaultTax), + status: ensureString(customer.isActive ? "active" : "inactive"), + lang_code: ensureString(customer.langCode), + currency_code: ensureString(customer.currencyCode), + })), +}; diff --git a/apps/server/src/contexts/customer-billing/presentation/controllers/customers/index.ts b/apps/server/src/contexts/customer-billing/presentation/controllers/customers/index.ts new file mode 100644 index 00000000..491ccf0c --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/controllers/customers/index.ts @@ -0,0 +1 @@ +export * from "./list"; diff --git a/apps/server/src/contexts/customer-billing/presentation/controllers/customers/list/index.ts b/apps/server/src/contexts/customer-billing/presentation/controllers/customers/list/index.ts new file mode 100644 index 00000000..33c7e19f --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/controllers/customers/list/index.ts @@ -0,0 +1,16 @@ +import { SequelizeTransactionManager } from "@common/infrastructure"; +import { ListCustomersUseCase } from "@contexts/customer-billing/application/customers/list-customers"; +import { CustomerService } from "@contexts/customer-billing/domain"; +import { customerRepository } from "@contexts/customer-billing/infraestructure"; +import { ListCustomersController } from "./list-customers.controller"; +import { listCustomersPresenter } from "./list-customers.presenter"; + +export const listCustomersController = () => { + const transactionManager = new SequelizeTransactionManager(); + const customerService = new CustomerService(customerRepository); + + const useCase = new ListCustomersUseCase(customerService, transactionManager); + const presenter = listCustomersPresenter; + + return new ListCustomersController(useCase, presenter); +}; diff --git a/apps/server/src/contexts/customer-billing/presentation/controllers/customers/list/list-customers.controller.ts b/apps/server/src/contexts/customer-billing/presentation/controllers/customers/list/list-customers.controller.ts new file mode 100644 index 00000000..8dc424fd --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/controllers/customers/list/list-customers.controller.ts @@ -0,0 +1,37 @@ +import { ExpressController } from "@common/presentation"; +import { ListCustomersUseCase } from "@contexts/customer-billing/application"; +import { IListCustomersPresenter } from "./list-customers.presenter"; + +export class ListCustomersController extends ExpressController { + public constructor( + private readonly listCustomers: ListCustomersUseCase, + private readonly presenter: IListCustomersPresenter + ) { + super(); + } + + protected async executeImpl() { + const customersOrError = await this.listCustomers.execute(); + + if (customersOrError.isFailure) { + return this.handleError(customersOrError.error); + } + + return this.ok(this.presenter.toDTO(customersOrError.data)); + } + + private handleError(error: Error) { + const message = error.message; + + if ( + message.includes("Database connection lost") || + message.includes("Database request timed out") + ) { + return this.unavailableError( + "Database service is currently unavailable. Please try again later." + ); + } + + return this.conflictError(message); + } +} diff --git a/apps/server/src/contexts/customer-billing/presentation/controllers/customers/list/list-customers.presenter.ts b/apps/server/src/contexts/customer-billing/presentation/controllers/customers/list/list-customers.presenter.ts new file mode 100644 index 00000000..dc3e7934 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/controllers/customers/list/list-customers.presenter.ts @@ -0,0 +1,38 @@ +import { Collection, ensureBoolean, ensureNumber, ensureString } from "@common/helpers"; +import { Customer } from "@contexts/customer-billing/domain"; +import { IListCustomersResponseDTO } from "../../../dto"; + +export interface IListCustomersPresenter { + toDTO: (customers: Collection) => IListCustomersResponseDTO[]; +} + +export const listCustomersPresenter: IListCustomersPresenter = { + toDTO: (customers: Collection): IListCustomersResponseDTO[] => + customers.map((customer) => ({ + id: ensureString(customer.id.toString()), + reference: ensureString(customer.reference), + + is_freelancer: ensureBoolean(customer.isFreelancer), + name: ensureString(customer.name), + trade_name: ensureString(customer.tradeName.getValue()), + tin: ensureString(customer.tin.toString()), + + street: ensureString(customer.address.street), + city: ensureString(customer.address.city), + state: ensureString(customer.address.state), + postal_code: ensureString(customer.address.postalCode), + country: ensureString(customer.address.country), + + email: ensureString(customer.email.toString()), + phone: ensureString(customer.phone.toString()), + fax: ensureString(customer.fax.getValue()?.toString()), + website: ensureString(customer.website.getValue()), + + legal_record: ensureString(customer.legalRecord), + + default_tax: ensureNumber(customer.defaultTax), + status: ensureString(customer.isActive ? "active" : "inactive"), + lang_code: ensureString(customer.langCode), + currency_code: ensureString(customer.currencyCode), + })), +}; diff --git a/apps/server/src/contexts/customer-billing/presentation/controllers/index.ts b/apps/server/src/contexts/customer-billing/presentation/controllers/index.ts new file mode 100644 index 00000000..e48a5e9f --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/controllers/index.ts @@ -0,0 +1,2 @@ +export * from "./customer-invoices"; +export * from "./customers"; diff --git a/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.request.dto.ts b/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.request.dto.ts new file mode 100644 index 00000000..a5c9a563 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.request.dto.ts @@ -0,0 +1 @@ +export interface IListCustomerInvoicesRequestDTO {} diff --git a/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.response.dto.ts b/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.response.dto.ts new file mode 100644 index 00000000..c19599c1 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.response.dto.ts @@ -0,0 +1,27 @@ +export interface IListCustomerInvoicesResponseDTO { + id: string; + reference: string; + + is_freelancer: boolean; + name: string; + trade_name: string; + tin: string; + + street: string; + city: string; + state: string; + postal_code: string; + country: string; + + email: string; + phone: string; + fax: string; + website: string; + + legal_record: string; + + default_tax: number; + status: string; + lang_code: string; + currency_code: string; +} diff --git a/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts b/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts new file mode 100644 index 00000000..a4bf02d4 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const ListCustomerInvoicesSchema = z.object({}); diff --git a/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/index.ts b/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/index.ts new file mode 100644 index 00000000..08586dd7 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/dto/customer-invoices/index.ts @@ -0,0 +1,3 @@ +export * from "./customer-invoices.request.dto"; +export * from "./customer-invoices.response.dto"; +export * from "./customer-invoices.validation.dto"; diff --git a/apps/server/src/contexts/customer-billing/presentation/dto/customers.request.dto.ts b/apps/server/src/contexts/customer-billing/presentation/dto/customers.request.dto.ts new file mode 100644 index 00000000..06640216 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/dto/customers.request.dto.ts @@ -0,0 +1 @@ +export interface IListCustomersRequestDTO {} diff --git a/apps/server/src/contexts/customer-billing/presentation/dto/customers.response.dto.ts b/apps/server/src/contexts/customer-billing/presentation/dto/customers.response.dto.ts new file mode 100644 index 00000000..4ba91903 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/dto/customers.response.dto.ts @@ -0,0 +1,27 @@ +export interface IListCustomersResponseDTO { + id: string; + reference: string; + + is_freelancer: boolean; + name: string; + trade_name: string; + tin: string; + + street: string; + city: string; + state: string; + postal_code: string; + country: string; + + email: string; + phone: string; + fax: string; + website: string; + + legal_record: string; + + default_tax: number; + status: string; + lang_code: string; + currency_code: string; +} diff --git a/apps/server/src/contexts/customer-billing/presentation/dto/customers.validation.dto.ts b/apps/server/src/contexts/customer-billing/presentation/dto/customers.validation.dto.ts new file mode 100644 index 00000000..ba8d4eec --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/dto/customers.validation.dto.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const ListCustomersSchema = z.object({}); diff --git a/apps/server/src/contexts/customer-billing/presentation/dto/index.ts b/apps/server/src/contexts/customer-billing/presentation/dto/index.ts new file mode 100644 index 00000000..9ae48163 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/dto/index.ts @@ -0,0 +1,4 @@ +export * from "./customer-invoices"; +export * from "./customers.request.dto"; +export * from "./customers.response.dto"; +export * from "./customers.validation.dto"; diff --git a/apps/server/src/contexts/customer-billing/presentation/index.ts b/apps/server/src/contexts/customer-billing/presentation/index.ts new file mode 100644 index 00000000..77c53837 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/presentation/index.ts @@ -0,0 +1,2 @@ +export * from "./controllers/customers"; +export * from "./dto"; diff --git a/apps/server/src/contexts/sales/domain/index.ts b/apps/server/src/contexts/sales/domain/index.ts deleted file mode 100644 index 4a16e729..00000000 --- a/apps/server/src/contexts/sales/domain/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./aggregates"; -export * from "./entities"; -export * from "./events"; -export * from "./repositories"; -export * from "./value-objects"; diff --git a/apps/server/src/contexts/sales/presentation/index.ts b/apps/server/src/contexts/sales/presentation/index.ts deleted file mode 100644 index a123289d..00000000 --- a/apps/server/src/contexts/sales/presentation/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./controllers"; -export * from "./dto"; diff --git a/apps/server/src/routes/auth.routes.ts b/apps/server/src/routes/auth.routes.ts index cc417fb8..fbb50677 100644 --- a/apps/server/src/routes/auth.routes.ts +++ b/apps/server/src/routes/auth.routes.ts @@ -14,7 +14,7 @@ import { import { NextFunction, Request, Response, Router } from "express"; export const authRouter = (appRouter: Router) => { - const authRoutes: Router = Router({ mergeParams: true }); + const routes: Router = Router({ mergeParams: true }); /** * @api {post} /api/auth/register Register a new user * @apiName RegisterUser @@ -29,7 +29,7 @@ export const authRouter = (appRouter: Router) => { * * @apiError (400) {String} message Error message. */ - authRoutes.post("/register", validateRequestDTO(RegisterUserSchema), (req, res, next) => { + routes.post("/register", validateRequestDTO(RegisterUserSchema), (req, res, next) => { registerController().execute(req, res, next); }); @@ -48,7 +48,7 @@ export const authRouter = (appRouter: Router) => { * * @apiError (401) {String} message Invalid email or password. */ - authRoutes.post( + routes.post( "/login", validateRequestDTO(LoginUserSchema), checkTabContext, @@ -68,7 +68,7 @@ export const authRouter = (appRouter: Router) => { * * @apiSuccess (200) {String} message Success message. */ - authRoutes.post( + routes.post( "/logout", checkTabContext, checkUser, @@ -77,7 +77,7 @@ export const authRouter = (appRouter: Router) => { } ); - authRoutes.post( + routes.post( "/refresh", validateRequestDTO(RefreshTokenSchema), checkTabContext, @@ -86,5 +86,5 @@ export const authRouter = (appRouter: Router) => { } ); - appRouter.use("/auth", authRoutes); + appRouter.use("/auth", routes); }; diff --git a/apps/server/src/routes/companies.routes.ts b/apps/server/src/routes/companies.routes.ts index a31fd194..6d8149e0 100644 --- a/apps/server/src/routes/companies.routes.ts +++ b/apps/server/src/routes/companies.routes.ts @@ -4,9 +4,9 @@ import { listCompaniesController, ListCompaniesSchema } from "@contexts/companie import { NextFunction, Request, Response, Router } from "express"; export const companiesRouter = (appRouter: Router) => { - const companiesRoutes: Router = Router({ mergeParams: true }); + const routes: Router = Router({ mergeParams: true }); - companiesRoutes.get( + routes.get( "/", validateRequestDTO(ListCompaniesSchema), checkTabContext, @@ -16,5 +16,5 @@ export const companiesRouter = (appRouter: Router) => { } ); - appRouter.use("/companies", companiesRoutes); + appRouter.use("/companies", routes); }; diff --git a/apps/server/src/routes/customer-invoices.routes.ts b/apps/server/src/routes/customer-invoices.routes.ts new file mode 100644 index 00000000..3690e005 --- /dev/null +++ b/apps/server/src/routes/customer-invoices.routes.ts @@ -0,0 +1,20 @@ +import { validateRequestDTO } from "@common/presentation"; +import { checkTabContext, checkUser } from "@contexts/auth/infraestructure"; +import { ListCustomerInvoicesSchema } from "@contexts/customer-billing/presentation"; +import { NextFunction, Request, Response, Router } from "express"; + +export const customerInvoicesRouter = (appRouter: Router) => { + const routes: Router = Router({ mergeParams: true }); + + routes.get( + "/", + validateRequestDTO(ListCustomerInvoicesSchema), + checkTabContext, + checkUser, + (req: Request, res: Response, next: NextFunction) => { + listCustomerInvoicesController().execute(req, res, next); + } + ); + + appRouter.use("/customer-invoices", routes); +}; diff --git a/apps/server/src/routes/customers.routes.ts b/apps/server/src/routes/customers.routes.ts index ab2b0561..6407c543 100644 --- a/apps/server/src/routes/customers.routes.ts +++ b/apps/server/src/routes/customers.routes.ts @@ -1,20 +1,23 @@ import { validateRequestDTO } from "@common/presentation"; import { checkTabContext } from "@contexts/auth/infraestructure"; -import { listUsersController, ListUsersSchema } from "@contexts/auth/presentation"; +import { + listCustomersController, + ListCustomersSchema, +} from "@contexts/customer-billing/presentation"; import { NextFunction, Request, Response, Router } from "express"; -export const userRouter = (appRouter: Router) => { - const authRoutes: Router = Router({ mergeParams: true }); +export const customersRouter = (appRouter: Router) => { + const routes: Router = Router({ mergeParams: true }); - authRoutes.get( + routes.get( "/", - validateRequestDTO(ListUsersSchema), + validateRequestDTO(ListCustomersSchema), checkTabContext, //checkUserIsAdmin, (req: Request, res: Response, next: NextFunction) => { - listUsersController().execute(req, res, next); + listCustomersController().execute(req, res, next); } ); - appRouter.use("/customers", authRoutes); + appRouter.use("/customers", routes); }; diff --git a/apps/server/src/routes/users.routes.ts b/apps/server/src/routes/users.routes.ts index 95795e50..ecdce4bf 100644 --- a/apps/server/src/routes/users.routes.ts +++ b/apps/server/src/routes/users.routes.ts @@ -4,9 +4,9 @@ import { listUsersController, ListUsersSchema } from "@contexts/auth/presentatio import { NextFunction, Request, Response, Router } from "express"; export const usersRouter = (appRouter: Router) => { - const usersRoutes: Router = Router({ mergeParams: true }); + const routes: Router = Router({ mergeParams: true }); - usersRoutes.get( + routes.get( "/", validateRequestDTO(ListUsersSchema), checkTabContext, @@ -16,5 +16,5 @@ export const usersRouter = (appRouter: Router) => { } ); - appRouter.use("/users", usersRoutes); + appRouter.use("/users", routes); }; diff --git a/apps/server/src/routes/v1.routes.ts b/apps/server/src/routes/v1.routes.ts index 0cded0fd..2677ebc6 100644 --- a/apps/server/src/routes/v1.routes.ts +++ b/apps/server/src/routes/v1.routes.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { authRouter } from "./auth.routes"; import { companiesRouter } from "./companies.routes"; +import { customersRouter } from "./customers.routes"; import { usersRouter } from "./users.routes"; export const v1Routes = () => { @@ -14,5 +15,8 @@ export const v1Routes = () => { usersRouter(routes); companiesRouter(routes); + // Sales + customersRouter(routes); + return routes; }; diff --git a/doc/DsRegistroVeriFactu.xlsx b/doc/DsRegistroVeriFactu.xlsx new file mode 100644 index 00000000..b1a2e453 Binary files /dev/null and b/doc/DsRegistroVeriFactu.xlsx differ diff --git a/packages/rdx-ddd/package.json b/packages/rdx-ddd/package.json new file mode 100644 index 00000000..83f346c1 --- /dev/null +++ b/packages/rdx-ddd/package.json @@ -0,0 +1,12 @@ +{ + "name": "rdx-ddd", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/rdx-verifactu/.eslintrc.json b/packages/rdx-verifactu/.eslintrc.json new file mode 100644 index 00000000..f4e1b0c2 --- /dev/null +++ b/packages/rdx-verifactu/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["eslint-config-codely/typescript"], + "overrides": [ + { + "files": ["*.ts"], + "parserOptions": { + "project": [ + "./tsconfig.json", + "./packages/criteria/tsconfig.json", + "./packages/criteria-mysql/tsconfig.json" + ] + }, + "rules": { + "@typescript-eslint/no-floating-promises": ["off"] + } + } + ] +} diff --git a/packages/rdx-verifactu/.gitignore b/packages/rdx-verifactu/.gitignore new file mode 100644 index 00000000..76add878 --- /dev/null +++ b/packages/rdx-verifactu/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/packages/rdx-verifactu/package.json b/packages/rdx-verifactu/package.json new file mode 100644 index 00000000..89f23c44 --- /dev/null +++ b/packages/rdx-verifactu/package.json @@ -0,0 +1,12 @@ +{ + "name": "rdx-verifactu", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/rdx-verifactu/tsconfig.json b/packages/rdx-verifactu/tsconfig.json new file mode 100644 index 00000000..5f65a300 --- /dev/null +++ b/packages/rdx-verifactu/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "allowJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "noEmit": false, + "skipLibCheck": true, + "strict": true, + "incremental": false, + "declaration": true, + "exactOptionalPropertyTypes": true, + "target": "es2020" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c163635..6fde587c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: apps/server: dependencies: + '@types/dinero.js': + specifier: ^1.9.4 + version: 1.9.4 bcrypt: specifier: ^5.1.1 version: 5.1.1 @@ -29,6 +32,9 @@ importers: cors: specifier: ^2.8.5 version: 2.8.5 + dinero.js: + specifier: ^1.9.1 + version: 1.9.1 dotenv: specifier: ^16.4.7 version: 16.4.7 @@ -296,6 +302,10 @@ importers: specifier: ^5.7.3 version: 5.7.3 + packages/rdx-ddd: {} + + packages/rdx-verifactu: {} + packages/shared: {} packages/typescript-config: {} @@ -1267,6 +1277,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dinero.js@1.9.4': + resolution: {integrity: sha512-mtJnan4ajy9MqvoJGVXu0tC9EAAzFjeoKc3d+8AW+H/Od9+8IiC59ymjrZF+JdTToyDvkLReacTsc50Z8eYr6Q==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -2011,6 +2024,9 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dinero.js@1.9.1: + resolution: {integrity: sha512-1HXiF2vv3ZeRQ23yr+9lFxj/PbZqutuYWJnE0qfCB9xYBPnuaJ8lXtli1cJM0TvUXW1JTOaePldmqN5JVNxKSA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -5504,6 +5520,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/dinero.js@1.9.4': {} + '@types/estree@1.0.6': {} '@types/express-serve-static-core@4.19.6': @@ -6430,6 +6448,8 @@ snapshots: diff@4.0.2: {} + dinero.js@1.9.1: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0