This commit is contained in:
David Arranz 2025-02-24 20:00:28 +01:00
parent c8d4f4fa52
commit 1f666a1a5d
80 changed files with 1887 additions and 62 deletions

View File

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

View File

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

View File

@ -11,7 +11,7 @@ export class EmailAddress extends ValueObject<EmailAddressProps> {
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! }));
}

View File

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

View File

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

View File

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

View File

@ -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", () => {

View File

@ -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<NameProps> {
export class Name extends ValueObject<INameProps> {
private static readonly MAX_LENGTH = 255;
protected static validate(value: string) {
@ -21,9 +21,9 @@ export class Name extends ValueObject<NameProps> {
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<Maybe<Name>, Error> {

View File

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

View File

@ -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<IPercentageProps> 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<Percentage> {
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)}%`;
}
}

View File

@ -13,7 +13,7 @@ export class PhoneNumber extends ValueObject<PhoneNumberProps> {
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! }));
}

View File

@ -52,7 +52,7 @@ export class PostalAddress extends ValueObject<IPostalAddressProps> {
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!));
}

View File

@ -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<Quantity, Error>;
decrement(anotherQuantity?: Quantity): Result<Quantity, Error>;
hasSameScale(otherQuantity: Quantity): boolean;
convertScale(newScale: number): Result<Quantity, Error>;
}
export class Quantity extends ValueObject<IQuantityProps> 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<Quantity, Error> {
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<Quantity, Error> {
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<Quantity, Error> {
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 });
}
}

View File

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

View File

@ -26,7 +26,7 @@ export class Slug extends ValueObject<SlugProps> {
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! }));
}

View File

@ -28,7 +28,7 @@ export class TINNumber extends ValueObject<TINNumberProps> {
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! }));
}

View File

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

View File

@ -9,7 +9,7 @@ export class UniqueID extends ValueObject<string> {
if (!generateOnEmpty) {
return Result.fail(new Error("ID cannot be undefined or null"));
}
UniqueID.generateNewID();
return UniqueID.generateNewID();
}
const result = UniqueID.validate(id!);

View File

@ -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<ITestValueProps> {
constructor(value: string) {
super({ value });
}
getValue() {
return this.props;
}
}

View File

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

View File

@ -24,10 +24,14 @@ export class Maybe<T> {
return this.value !== undefined;
}
getValue(): T | undefined {
unwrap(): T | undefined {
return this.value;
}
getOrUndefined(): T | undefined {
return this.unwrap();
}
map<U>(fn: (value: T) => U): Maybe<U> {
return this.isSome() ? Maybe.Some(fn(this.value as T)) : Maybe.None();
}

View File

@ -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<Result<User[], Error>> {
async findUsers(transaction?: any): Promise<Result<Collection<User>, 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<Result<User>> {

View File

@ -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<User>) => IListUsersResponseDTO[];
}
export const listUsersPresenter: IListUsersPresenter = {
toDTO: (users: User[]): IListUsersResponseDTO[] =>
toDTO: (users: Collection<User>): IListUsersResponseDTO[] =>
users.map((user) => ({
id: ensureString(user.id.toString()),
email: ensureString(user.email.toString()),

View File

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

View File

@ -0,0 +1 @@
export * from "./list-customers.use-case";

View File

@ -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<Result<Collection<Customer>, Error>> {
return this.transactionManager.complete((transaction) => {
return this.customerService.findCustomers(transaction);
});
}
}

View File

@ -0,0 +1,2 @@
export * from "./customer-invoices";
export * from "./customers";

View File

@ -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<ICustomerInvoiceProps>
implements ICustomerInvoice
{
static create(props: ICustomerInvoiceProps, id?: UniqueID): Result<CustomerInvoice, Error> {
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);
}
}

View File

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

View File

@ -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<string>;
website: Maybe<string>;
fax: Maybe<PhoneNumber>;
}
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<string>;
fax: Maybe<PhoneNumber>;
website: Maybe<string>;
isCustomer: boolean;
isFreelancer: boolean;
isActive: boolean;
}
export class Customer extends AggregateRoot<ICustomerProps> implements ICustomer {
static create(props: ICustomerProps, id?: UniqueID): Result<Customer, Error> {
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<PhoneNumber> {
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";
}
}

View File

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

View File

@ -0,0 +1,2 @@
export * from "./customer";
export * from "./customer-invoice";

View File

@ -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<string>; // 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<string>;
quantity: Quantity;
unitPrice: MoneyValue;
subtotalPrice: MoneyValue;
discount: Percentage;
totalPrice: MoneyValue;
}
export class CustomerInvoiceItem
extends DomainEntity<ICustomerInvoiceItemProps>
implements ICustomerInvoiceItem
{
public static create(
props: ICustomerInvoiceItemProps,
id?: UniqueID
): Result<CustomerInvoiceItem, Error> {
return Result.ok(new CustomerInvoiceItem(props, id));
}
get description(): Maybe<string> {
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()));
}
}

View File

@ -0,0 +1,3 @@
export * from "./aggregates";
export * from "./repositories";
export * from "./services";

View File

@ -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<Result<Collection<Customer>, Error>>;
findById(id: UniqueID, transaction?: any): Promise<Result<Customer, Error>>;
findByEmail(email: EmailAddress, transaction?: any): Promise<Result<Customer, Error>>;
}

View File

@ -0,0 +1 @@
export * from "./customer-repository.interface";

View File

@ -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<Result<Collection<Customer>, Error>>;
findCustomerById(userId: UniqueID, transaction?: any): Promise<Result<Customer>>;
}

View File

@ -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<Result<Collection<Customer>, 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<Result<Customer>> {
return await this.customerRepository.findById(customerId, transaction);
}
}

View File

@ -0,0 +1,2 @@
export * from "./customer-service.interface";
export * from "./customer.service";

View File

@ -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<CustomerModel, CustomerCreationAttributes, Customer> {}
export class CustomerMapper
extends SequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer>
implements ICustomerMapper
{
public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result<Customer, Error> {
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<CustomerCreationAttributes, Error> {
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 };

View File

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

View File

@ -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<CustomerInvoiceItemModel, { omit: "invoice" }>,
InferCreationAttributes<CustomerInvoiceItemModel, { omit: "invoice" }>
> {
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<number | null>;
declare position: number;
declare description: CreationOptional<string | null>;
declare quantity: CreationOptional<number | null>;
declare unit_price: CreationOptional<number | null>;
declare subtotal_price: CreationOptional<number | null>;
declare discount: CreationOptional<number | null>;
declare total_price: CreationOptional<number | null>;
declare invoice: NonAttribute<CustomerInvoiceModel>;
}
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;
};

View File

@ -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<CustomerInvoiceModel, { omit: "items" | "customer" }>,
InferCreationAttributes<CustomerInvoiceModel, { omit: "items" | "customer" }>
> {
// To avoid table creation
/*static async sync(): Promise<any> {
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<string>;
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<number | null>;
declare discount: CreationOptional<number | null>;
declare discount_price: CreationOptional<number | null>;
declare before_tax_price: CreationOptional<number | null>;
declare tax: CreationOptional<number | null>;
declare tax_price: CreationOptional<number | null>;
declare total_price: CreationOptional<number | null>;
declare notes: CreationOptional<string>;
declare items: NonAttribute<CustomerInvoiceItemModel[]>;
declare customer: NonAttribute<CustomerModel>;
declare integrity_hash: CreationOptional<string>;
declare previous_invoice_id: CreationOptional<string>;
declare signed_at: CreationOptional<string>;
}
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;
};

View File

@ -0,0 +1,172 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Sequelize,
} from "sequelize";
export type CustomerCreationAttributes = InferCreationAttributes<CustomerModel, {}> & {};
export class CustomerModel extends Model<
InferAttributes<CustomerModel>,
InferCreationAttributes<CustomerModel>
> {
// To avoid table creation
/*static async sync(): Promise<any> {
return Promise.resolve();
}*/
declare id: string;
declare reference: CreationOptional<string>;
declare is_freelancer: boolean;
declare name: string;
declare trade_name: CreationOptional<string>;
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<string>;
declare website: CreationOptional<string>;
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;
};

View File

@ -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<Customer> 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<Result<Collection<Customer>, 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<Result<Customer, Error>> {
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<Result<Customer, Error>> {
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 };

View File

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

View File

@ -0,0 +1,2 @@
export * from "./list-customer-invoices.controller";
export * from "./list-customer-invoices.presenter";

View File

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

View File

@ -0,0 +1,38 @@
import { Collection, ensureBoolean, ensureNumber, ensureString } from "@common/helpers";
import { IListCustomerInvoicesResponseDTO } from "../../../dto";
export interface IListCustomerInvoicesPresenter {
toDTO: (customers: Collection<CustomerInvoice>) => IListCustomerInvoicesResponseDTO[];
}
export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = {
toDTO: (customers: Collection<CustomerInvoice>): 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),
})),
};

View File

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

View File

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

View File

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

View File

@ -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<Customer>) => IListCustomersResponseDTO[];
}
export const listCustomersPresenter: IListCustomersPresenter = {
toDTO: (customers: Collection<Customer>): 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),
})),
};

View File

@ -0,0 +1,2 @@
export * from "./customer-invoices";
export * from "./customers";

View File

@ -0,0 +1 @@
export interface IListCustomerInvoicesRequestDTO {}

View File

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

View File

@ -0,0 +1,3 @@
import { z } from "zod";
export const ListCustomerInvoicesSchema = z.object({});

View File

@ -0,0 +1,3 @@
export * from "./customer-invoices.request.dto";
export * from "./customer-invoices.response.dto";
export * from "./customer-invoices.validation.dto";

View File

@ -0,0 +1 @@
export interface IListCustomersRequestDTO {}

View File

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

View File

@ -0,0 +1,3 @@
import { z } from "zod";
export const ListCustomersSchema = z.object({});

View File

@ -0,0 +1,4 @@
export * from "./customer-invoices";
export * from "./customers.request.dto";
export * from "./customers.response.dto";
export * from "./customers.validation.dto";

View File

@ -0,0 +1,2 @@
export * from "./controllers/customers";
export * from "./dto";

View File

@ -1,5 +0,0 @@
export * from "./aggregates";
export * from "./entities";
export * from "./events";
export * from "./repositories";
export * from "./value-objects";

View File

@ -1,2 +0,0 @@
export * from "./controllers";
export * from "./dto";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

2
packages/rdx-verifactu/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

View File

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

View File

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

View File

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