This commit is contained in:
David Arranz 2025-03-04 18:08:33 +01:00
parent 42987d688f
commit 2e397900e8
72 changed files with 1298 additions and 348 deletions

7
.vscode/launch.json vendored
View File

@ -14,7 +14,6 @@
"name": "Launch Chrome localhost",
"type": "chrome",
"request": "launch",
"reAttach": true,
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/client"
},
@ -30,10 +29,12 @@
{
"type": "node",
"request": "attach",
"name": "SERVER: Attach to dev:debug",
"name": "Attach to ts-node-dev",
"port": 4321,
"restart": true,
"cwd": "${workspaceRoot}"
"timeout": 10000,
"sourceMaps": true,
"resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"]
},
{

View File

@ -2,7 +2,6 @@ DB_HOST=localhost
DB_USER=rodax
DB_PASSWORD=rodax
DB_NAME=uecko_erp
DB_DIALECT=mariadb
DB_PORT=3306
PORT=3002

View File

@ -4,8 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"dev": "ts-node-dev -r tsconfig-paths/register ./src/index.ts",
"dev:debug": "ts-node-dev --transpile-only --respawn --inspect=4321 -r tsconfig-paths/register ./src/index.ts",
"dev:nodebug": "ts-node-dev -r tsconfig-paths/register ./src/index.ts",
"dev": "ts-node-dev --transpile-only --respawn --inspect=4321 -r tsconfig-paths/register ./src/index.ts",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit",
"build": "npm run clean && npm run typecheck && esbuild src/index.ts --platform=node --format=cjs --bundle --sourcemap --minify --outdir=dist",
@ -45,6 +45,7 @@
"nodemon": "^3.1.9",
"ts-jest": "^29.2.5",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3"
},
"dependencies": {
@ -63,7 +64,6 @@
"jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.11.20",
"luxon": "^3.5.0",
"mariadb": "^3.4.0",
"module-alias": "^2.2.3",
"mysql2": "^3.12.0",
"passport": "^0.7.0",
@ -75,7 +75,6 @@
"sequelize": "^6.37.5",
"shallow-equal-object": "^1.1.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"uuid": "^11.0.5",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",

View File

@ -11,14 +11,11 @@ const postalCodeSchema = z
message: "Invalid postal code format",
});
const countrySchema = z.string().min(2).max(56);
const provinceSchema = z.string().min(2).max(50);
const citySchema = z.string().min(2).max(50);
const streetSchema = z.string().min(2).max(255);
const street2Schema = z.string().optional();
const citySchema = z.string().min(2).max(50);
const stateSchema = z.string().min(2).max(50);
const countrySchema = z.string().min(2).max(56);
interface IPostalAddressProps {
street: string;
@ -37,7 +34,7 @@ export class PostalAddress extends ValueObject<IPostalAddressProps> {
street2: street2Schema,
city: citySchema,
postalCode: postalCodeSchema,
state: provinceSchema,
state: stateSchema,
country: countrySchema,
})
.safeParse(values);
@ -60,6 +57,20 @@ export class PostalAddress extends ValueObject<IPostalAddressProps> {
return PostalAddress.create(values!).map((value) => Maybe.some(value));
}
static update(
oldAddress: PostalAddress,
data: Partial<PostalAddress>
): Result<PostalAddress, Error> {
return PostalAddress.create({
street: data.street ?? oldAddress.street,
street2: data.street2?.getOrUndefined() ?? oldAddress.street2.getOrUndefined(),
city: data.city ?? oldAddress.city,
postalCode: data.postalCode ?? oldAddress.postalCode,
state: data.state ?? oldAddress.state,
country: data.country ?? oldAddress.country,
}).getOrElse(this);
}
get street(): string {
return this.props.street;
}

View File

@ -15,11 +15,11 @@ interface IDomainMapper<TModel extends Model, TEntity extends DomainEntity<any>>
}
interface IPersistenceMapper<TModelAttributes, TEntity extends DomainEntity<any>> {
mapToPersistence(source: TEntity, params?: MapperParamsType): Result<TModelAttributes, Error>;
mapToPersistence(source: TEntity, params?: MapperParamsType): TModelAttributes;
mapCollectionToPersistence(
source: Collection<TEntity>,
params?: MapperParamsType
): Result<TModelAttributes[], Error>;
): TModelAttributes[];
}
export interface ISequelizeMapper<
@ -59,23 +59,13 @@ export abstract class SequelizeMapper<
}
}
public abstract mapToPersistence(
source: TEntity,
params?: MapperParamsType
): Result<TModelAttributes, Error>;
public abstract mapToPersistence(source: TEntity, params?: MapperParamsType): TModelAttributes;
public mapCollectionToPersistence(
source: Collection<TEntity>,
params?: MapperParamsType
): Result<TModelAttributes[], Error> {
try {
const result = source.map(
(value, index) => this.mapToPersistence(value, { index, ...params }).data
);
return Result.ok(result);
} catch (error) {
return Result.fail(error as Error);
}
): TModelAttributes[] {
return source.map((value, index) => this.mapToPersistence(value, { index, ...params }));
}
protected safeMap<T>(operation: () => T, key: string): Result<T, Error> {

View File

@ -11,7 +11,7 @@ export const sequelize = new Sequelize(
process.env.DB_PASSWORD as string, // password
{
host: process.env.DB_HOST as string,
dialect: "mariadb",
dialect: "mysql",
port: parseInt(process.env.DB_PORT || "3306", 10),
dialectOptions: {
multipleStatements: true,

View File

@ -0,0 +1,88 @@
import { EmailAddress, PhoneNumber, PostalAddress, TINNumber, UniqueID } from "@common/domain";
import { Maybe, Result } from "@common/helpers";
import { ITransactionManager } from "@common/infrastructure/database";
import { logger } from "@common/infrastructure/logger";
import { Account, AccountStatus, IAccountProps, IAccountService } from "@contexts/accounts/domain";
import { ICreateAccountRequestDTO } from "../presentation";
export class CreateAccountUseCase {
constructor(
private readonly accountService: IAccountService,
private readonly transactionManager: ITransactionManager
) {}
public execute(
accountID: UniqueID,
dto: ICreateAccountRequestDTO
): Promise<Result<Account, Error>> {
return this.transactionManager.complete(async (transaction) => {
try {
const validOrErrors = this.validateAccountData(dto);
if (validOrErrors.isFailure) {
return Result.fail(validOrErrors.error);
}
const data = validOrErrors.data;
// Update account with dto
return await this.accountService.createAccount(accountID, data, transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
});
}
private validateAccountData(dto: ICreateAccountRequestDTO): Result<IAccountProps, Error> {
const errors: Error[] = [];
const tinOrError = TINNumber.create(dto.tin);
const emailOrError = EmailAddress.create(dto.email);
const phoneOrError = PhoneNumber.create(dto.phone);
const faxOrError = PhoneNumber.createNullable(dto.fax);
const postalAddressOrError = PostalAddress.create({
street: dto.street,
city: dto.city,
state: dto.state,
postalCode: dto.postal_code,
country: dto.country,
});
const result = Result.combine([
tinOrError,
emailOrError,
phoneOrError,
faxOrError,
postalAddressOrError,
]);
if (result.isFailure) {
return Result.fail(result.error);
}
const validatedData: IAccountProps = {
status: AccountStatus.createInactive(),
isFreelancer: dto.is_freelancer,
name: dto.name,
tradeName: dto.trade_name ? Maybe.some(dto.trade_name) : Maybe.none(),
tin: tinOrError.data,
address: postalAddressOrError.data,
email: emailOrError.data,
phone: phoneOrError.data,
fax: faxOrError.data,
website: dto.website ? Maybe.some(dto.website) : Maybe.none(),
legalRecord: dto.legal_record,
defaultTax: dto.default_tax,
langCode: dto.lang_code,
currencyCode: dto.currency_code,
logo: dto.logo ? Maybe.some(dto.logo) : Maybe.none(),
};
if (errors.length > 0) {
const message = errors.map((err) => err.message).toString();
return Result.fail(new Error(message));
}
return Result.ok(validatedData);
}
}

View File

@ -0,0 +1,23 @@
import { UniqueID } from "@common/domain";
import { Result } from "@common/helpers";
import { ITransactionManager } from "@common/infrastructure/database";
import { logger } from "@common/infrastructure/logger";
import { Account, IAccountService } from "@contexts/accounts/domain";
export class GetAccountsUseCase {
constructor(
private readonly accountService: IAccountService,
private readonly transactionManager: ITransactionManager
) {}
public execute(accountID: UniqueID): Promise<Result<Account, Error>> {
return this.transactionManager.complete(async (transaction) => {
try {
return await this.accountService.findAccountById(accountID, transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
});
}
}

View File

@ -1 +1,4 @@
export * from "./list-accounts";
export * from "./create-account.use-case";
export * from "./get-account.use-case";
export * from "./list-accounts.use-case";
export * from "./update-account.use-case";

View File

@ -0,0 +1,22 @@
import { Collection, Result } from "@common/helpers";
import { ITransactionManager } from "@common/infrastructure/database";
import { logger } from "@common/infrastructure/logger";
import { Account, IAccountService } from "@contexts/accounts/domain";
export class ListAccountsUseCase {
constructor(
private readonly accountService: IAccountService,
private readonly transactionManager: ITransactionManager
) {}
public execute(): Promise<Result<Collection<Account>, Error>> {
return this.transactionManager.complete(async (transaction) => {
try {
return await this.accountService.findAccounts(transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
});
}
}

View File

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

View File

@ -1,17 +0,0 @@
import { Collection, Result } from "@common/helpers";
import { ITransactionManager } from "@common/infrastructure/database";
import { Account } from "@contexts/accounts/domain";
import { IAccountService } from "@contexts/accounts/domain/services/account-service.interface";
export class ListAccountsUseCase {
constructor(
private readonly accountService: IAccountService,
private readonly transactionManager: ITransactionManager
) {}
public execute(): Promise<Result<Collection<Account>, Error>> {
return this.transactionManager.complete((transaction) => {
return this.accountService.findAccounts(transaction);
});
}
}

View File

@ -0,0 +1,46 @@
import { UniqueID } from "@common/domain";
import { Result } from "@common/helpers";
import { TransactionManager } from "@common/infrastructure/database";
import { AccountService } from "../domain";
import { UpdateAccountUseCase } from "./update-account.use-case";
const mockAccountService: AccountService = {
updateAccountById: jest.fn(),
} as unknown as AccountService;
const mockTransactionManager: TransactionManager = {
complete(work: (transaction: any) => Promise<any>): void {
jest.fn();
},
} as unknown as TransactionManager;
const id = UniqueID.create("", true).data;
describe("UpdateAccountUseCase", () => {
let updateAccountUseCase: UpdateAccountUseCase;
beforeEach(() => {
updateAccountUseCase = new UpdateAccountUseCase(mockAccountService, mockTransactionManager);
});
it("debería actualizar una cuenta y retornar un DTO", async () => {
const mockUpdatedAccount = { id: "123", name: "Nuevo Nombre" };
(mockAccountService.updateAccountById as jest.Mock).mockResolvedValue(
Result.ok(mockUpdatedAccount)
);
const result = await updateAccountUseCase.execute(id, { name: "Nuevo Nombre" });
expect(result.isSuccess).toBe(true);
expect(result.data.name).toBe("Nuevo Nombre");
});
it("debería retornar error si la actualización falla", async () => {
(mockAccountService.updateAccountById as jest.Mock).mockResolvedValue(
Result.fail(new Error("Account not found"))
);
const result = await updateAccountUseCase.execute(id, { name: "Nuevo Nombre" });
expect(result.isFailure).toBe(true);
expect(result.error.message).toBe("Account not found");
});
});

View File

@ -0,0 +1,121 @@
import { EmailAddress, PhoneNumber, PostalAddress, TINNumber, UniqueID } from "@common/domain";
import { Maybe, Result } from "@common/helpers";
import { ITransactionManager } from "@common/infrastructure/database";
import { logger } from "@common/infrastructure/logger";
import { Account, IAccountProps, IAccountService } from "@contexts/accounts/domain";
import { IUpdateAccountRequestDTO } from "../presentation";
export class UpdateAccountUseCase {
constructor(
private readonly accountService: IAccountService,
private readonly transactionManager: ITransactionManager
) {}
public execute(
accountID: UniqueID,
dto: Partial<IUpdateAccountRequestDTO>
): Promise<Result<Account, Error>> {
return this.transactionManager.complete(async (transaction) => {
try {
const validOrErrors = this.validateAccountData(dto);
if (validOrErrors.isFailure) {
return Result.fail(validOrErrors.error);
}
const data = validOrErrors.data;
// Update account with dto
return await this.accountService.updateAccountById(accountID, data, transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
});
}
private validateAccountData(
dto: Partial<IUpdateAccountRequestDTO>
): Result<Partial<IAccountProps>, Error> {
const errors: Error[] = [];
const validatedData: Partial<IAccountProps> = {};
if (dto.is_freelancer) {
validatedData.isFreelancer = dto.is_freelancer;
}
if (dto.name) {
validatedData.name = dto.name;
}
if (dto.trade_name) {
validatedData.tradeName = Maybe.some(dto.trade_name);
}
if (dto.tin) {
const tinOrError = TINNumber.create(dto.tin);
if (tinOrError.isFailure) errors.push(tinOrError.error);
else validatedData.tin = tinOrError.data;
}
if (dto.email) {
const emailOrError = EmailAddress.create(dto.email);
if (emailOrError.isFailure) errors.push(emailOrError.error);
else validatedData.email = emailOrError.data;
}
if (dto.phone) {
const phoneOrError = PhoneNumber.create(dto.phone);
if (phoneOrError.isFailure) errors.push(phoneOrError.error);
else validatedData.phone = phoneOrError.data;
}
if (dto.fax) {
const faxOrError = PhoneNumber.create(dto.fax);
if (faxOrError.isFailure) errors.push(faxOrError.error);
else validatedData.fax = Maybe.some(faxOrError.data);
}
if (dto.street || dto.city || dto.state || dto.postal_code || dto.country) {
const postalAddressOrError = PostalAddress.create({
street: dto.street ?? "",
city: dto.city ?? "",
state: dto.state ?? "",
postalCode: dto.postal_code ?? "",
country: dto.country ?? "",
});
if (postalAddressOrError.isFailure) errors.push(postalAddressOrError.error);
else validatedData.address = postalAddressOrError.data;
}
if (dto.website) {
validatedData.website = Maybe.some(dto.website);
}
if (dto.legal_record) {
validatedData.legalRecord = dto.legal_record;
}
if (dto.default_tax) {
validatedData.defaultTax = dto.default_tax;
}
if (dto.lang_code) {
validatedData.langCode = dto.lang_code;
}
if (dto.currency_code) {
validatedData.currencyCode = dto.currency_code;
}
if (dto.logo) {
validatedData.logo = Maybe.some(dto.logo);
}
if (errors.length > 0) {
const message = errors.map((err) => err.message).toString();
return Result.fail(new Error(message));
}
return Result.ok(validatedData);
}
}

View File

@ -7,8 +7,11 @@ import {
UniqueID,
} from "@common/domain";
import { Maybe, Result } from "@common/helpers";
import { AccountStatus } from "../value-objects";
export interface IAccountProps {
status: AccountStatus;
isFreelancer: boolean;
name: string;
tin: TINNumber;
@ -17,7 +20,7 @@ export interface IAccountProps {
phone: PhoneNumber;
legalRecord: string;
defaultTax: number;
status: string;
langCode: string;
currencyCode: string;
@ -29,6 +32,7 @@ export interface IAccountProps {
export interface IAccount {
id: UniqueID;
status: AccountStatus;
name: string;
tin: TINNumber;
address: PostalAddress;
@ -36,6 +40,7 @@ export interface IAccount {
phone: PhoneNumber;
legalRecord: string;
defaultTax: number;
langCode: string;
currencyCode: string;
@ -47,6 +52,9 @@ export interface IAccount {
isAccount: boolean;
isFreelancer: boolean;
isActive: boolean;
activate(): boolean;
deactivate(): boolean;
}
export class Account extends AggregateRoot<IAccountProps> implements IAccount {
@ -64,6 +72,55 @@ export class Account extends AggregateRoot<IAccountProps> implements IAccount {
return Result.ok(account);
}
static update(oldAccount: Account, data: Partial<IAccountProps>): Result<Account, Error> {
const updatedPostalAddress = PostalAddress.update(oldAccount.address, data.address ?? {}).data;
return Account.create(
{
isFreelancer: data.isFreelancer ?? oldAccount.isFreelancer,
name: data.name ?? oldAccount.name,
tin: data.tin ?? oldAccount.tin,
address: updatedPostalAddress,
email: data.email ?? oldAccount.email,
phone: data.phone ?? oldAccount.phone,
legalRecord: data.legalRecord ?? oldAccount.legalRecord,
defaultTax: data.defaultTax ?? oldAccount.defaultTax,
status: oldAccount.props.status,
langCode: data.langCode ?? oldAccount.langCode,
currencyCode: data.currencyCode ?? oldAccount.currencyCode,
tradeName: data.tradeName ?? oldAccount.tradeName,
website: data.website ?? oldAccount.website,
fax: data.fax ?? oldAccount.fax,
logo: data.logo ?? oldAccount.logo,
},
oldAccount.id
).getOrElse(this);
}
activate() {
if (!this.props.status.canTransitionTo("active")) {
return false;
}
this.props.status = AccountStatus.createActive();
return true;
}
deactivate() {
if (!this.props.status.canTransitionTo("inactive")) {
return false;
}
this.props.status = AccountStatus.createInactive();
return true;
}
get status() {
return this.props.status;
}
get name() {
return this.props.name;
}
@ -125,6 +182,6 @@ export class Account extends AggregateRoot<IAccountProps> implements IAccount {
}
get isActive(): boolean {
return this.props.status === "active";
return this.props.status.equals(AccountStatus.createActive());
}
}

View File

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

View File

@ -3,7 +3,11 @@ import { Collection, Result } from "@common/helpers";
import { Account } from "../aggregates";
export interface IAccountRepository {
accountExists(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
findAll(transaction?: any): Promise<Result<Collection<Account>, Error>>;
findById(id: UniqueID, transaction?: any): Promise<Result<Account, Error>>;
findByEmail(email: EmailAddress, transaction?: any): Promise<Result<Account, Error>>;
create(account: Account, transaction?: any): Promise<void>;
update(account: Account, transaction?: any): Promise<void>;
}

View File

@ -0,0 +1,20 @@
import { UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Account, IAccountProps } from "../aggregates";
export interface IAccountService {
findAccounts(transaction?: any): Promise<Result<Collection<Account>, Error>>;
findAccountById(accountId: UniqueID, transaction?: any): Promise<Result<Account>>;
updateAccountById(
accountId: UniqueID,
data: Partial<IAccountProps>,
transaction?: any
): Promise<Result<Account, Error>>;
createAccount(
accountId: UniqueID,
data: IAccountProps,
transaction?: any
): Promise<Result<Account, Error>>;
}

View File

@ -0,0 +1,41 @@
// Pruebas unitarias: AccountService.test.ts
import { Account } from "../domain/Account";
import { IAccountRepository } from "../repositories/AccountRepository";
import { AccountService } from "../services/AccountService";
const mockAccountRepository: IAccountRepository = {
findById: jest.fn(),
save: jest.fn(),
};
describe("AccountService", () => {
let accountService: AccountService;
beforeEach(() => {
accountService = new AccountService(mockAccountRepository);
});
it("debería actualizar una cuenta existente", async () => {
const existingAccount = new Account(
{
/* datos simulados */
},
"123"
);
(mockAccountRepository.findById as jest.Mock).mockResolvedValue(existingAccount);
(mockAccountRepository.save as jest.Mock).mockResolvedValue(undefined);
const result = await accountService.updateAccountById("123", { name: "Nuevo Nombre" });
expect(result.isSuccess).toBe(true);
expect(result.data.name).toBe("Nuevo Nombre");
expect(mockAccountRepository.save).toHaveBeenCalled();
});
it("debería retornar error si la cuenta no existe", async () => {
(mockAccountRepository.findById as jest.Mock).mockResolvedValue(null);
const result = await accountService.updateAccountById("123", { name: "Nuevo Nombre" });
expect(result.isFailure).toBe(true);
expect(result.error.message).toBe("Account not found");
});
});

View File

@ -0,0 +1,104 @@
import { UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Transaction } from "sequelize";
import { Account, IAccountProps } from "../aggregates";
import { IAccountRepository } from "../repositories";
import { IAccountService } from "./account-service.interface";
export class AccountService implements IAccountService {
constructor(private readonly repo: IAccountRepository) {}
async findAccounts(transaction?: Transaction): Promise<Result<Collection<Account>, Error>> {
const accountsOrError = await this.repo.findAll(transaction);
if (accountsOrError.isFailure) {
return Result.fail(accountsOrError.error);
}
// Solo devolver usuarios activos
//const allAccounts = accountsOrError.data.filter((account) => account.isActive);
//return Result.ok(new Collection(allAccounts));
return accountsOrError;
}
async findAccountById(accountId: UniqueID, transaction?: Transaction): Promise<Result<Account>> {
return await this.repo.findById(accountId, transaction);
}
async updateAccountById(
accountId: UniqueID,
data: Partial<IAccountProps>,
transaction?: Transaction
): Promise<Result<Account, Error>> {
// Verificar si la cuenta existe
const accountOrError = await this.repo.findById(accountId, transaction);
if (accountOrError.isFailure) {
return Result.fail(new Error("Account not found"));
}
const updatedAccountOrError = Account.update(accountOrError.data, data);
if (updatedAccountOrError.isFailure) {
return Result.fail(
new Error(`Error updating account: ${updatedAccountOrError.error.message}`)
);
}
const updateAccount = updatedAccountOrError.data;
await this.repo.update(updateAccount, transaction);
return Result.ok(updateAccount);
}
async createAccount(
accountId: UniqueID,
data: IAccountProps,
transaction?: Transaction
): Promise<Result<Account, Error>> {
// Verificar si la cuenta existe
const accountOrError = await this.repo.findById(accountId, transaction);
if (accountOrError.isSuccess) {
return Result.fail(new Error("Account exists"));
}
const newAccountOrError = Account.create(data, accountId);
if (newAccountOrError.isFailure) {
return Result.fail(new Error(`Error creating account: ${newAccountOrError.error.message}`));
}
const newAccount = newAccountOrError.data;
await this.repo.create(newAccount, transaction);
return Result.ok(newAccount);
}
async activateAccount(id: UniqueID, transaction?: Transaction): Promise<Result<Account, Error>> {
const accountOrError = await this.repo.findById(id, transaction);
if (accountOrError.isFailure) {
return Result.fail(new Error("Account not found"));
}
const account = accountOrError.data;
if (account.activate()) {
await this.repo.update(account, transaction);
return Result.ok();
}
return Result.fail(new Error("Error activating account"));
}
async deactivateAccount(
id: UniqueID,
transaction?: Transaction
): Promise<Result<Account, Error>> {
const accountOrError = await this.repo.findById(id, transaction);
if (accountOrError.isFailure) {
return Result.fail(new Error("Account not found"));
}
const account = accountOrError.data;
if (account.deactivate()) {
await this.repo.update(account, transaction);
return Result.ok();
}
return Result.fail(new Error("Error deactivating account"));
}
}

View File

@ -1,8 +0,0 @@
import { UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Account } from "../aggregates";
export interface IAccountService {
findAccounts(transaction?: any): Promise<Result<Collection<Account>, Error>>;
findAccountById(userId: UniqueID, transaction?: any): Promise<Result<Account>>;
}

View File

@ -1,23 +0,0 @@
import { UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Account, IAccountRepository } from "..";
import { IAccountService } from "./account-service.interface";
export class AccountService implements IAccountService {
constructor(private readonly accountRepository: IAccountRepository) {}
async findAccounts(transaction?: any): Promise<Result<Collection<Account>, Error>> {
const accountsOrError = await this.accountRepository.findAll(transaction);
if (accountsOrError.isFailure) {
return Result.fail(accountsOrError.error);
}
// Solo devolver usuarios activos
const activeAccounts = accountsOrError.data.filter((account) => account.isActive);
return Result.ok(new Collection(activeAccounts));
}
async findAccountById(accountId: UniqueID, transaction?: any): Promise<Result<Account>> {
return await this.accountRepository.findById(accountId, transaction);
}
}

View File

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

View File

@ -0,0 +1,59 @@
import { ValueObject } from "@common/domain";
import { Result } from "@common/helpers";
interface IAccountStatusProps {
value: string;
}
export enum ACCOUNT_STATUS {
INACTIVE = "inactive",
ACTIVE = "active",
}
export class AccountStatus extends ValueObject<IAccountStatusProps> {
private static readonly ALLOWED_STATUSES = ["inactive", "active"];
private static readonly TRANSITIONS: Record<string, string[]> = {
inactive: [ACCOUNT_STATUS.ACTIVE],
active: [ACCOUNT_STATUS.INACTIVE],
};
static create(value: string): Result<AccountStatus, Error> {
if (!this.ALLOWED_STATUSES.includes(value)) {
return Result.fail(new Error(`Estado de la cuenta no válido: ${value}`));
}
return Result.ok(
value === "active" ? AccountStatus.createActive() : AccountStatus.createInactive()
);
}
public static createInactive(): AccountStatus {
return new AccountStatus({ value: ACCOUNT_STATUS.INACTIVE });
}
public static createActive(): AccountStatus {
return new AccountStatus({ value: ACCOUNT_STATUS.ACTIVE });
}
getValue(): string {
return this.props.value;
}
canTransitionTo(nextStatus: string): boolean {
return AccountStatus.TRANSITIONS[this.props.value].includes(nextStatus);
}
transitionTo(nextStatus: string): Result<AccountStatus, Error> {
if (!this.canTransitionTo(nextStatus)) {
return Result.fail(
new Error(`Transición no permitida de ${this.props.value} a ${nextStatus}`)
);
}
return AccountStatus.create(nextStatus);
}
toString(): string {
return this.getValue();
}
}

View File

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

View File

@ -5,7 +5,7 @@ import {
MapperParamsType,
SequelizeMapper,
} from "@common/infrastructure/sequelize/sequelize-mapper";
import { Account } from "@contexts/accounts/domain/";
import { Account, AccountStatus } from "@contexts/accounts/domain/";
import { AccountCreationAttributes, AccountModel } from "../sequelize/account.model";
export interface IAccountMapper
@ -17,6 +17,7 @@ export class AccountMapper
{
public mapToDomain(source: AccountModel, params?: MapperParamsType): Result<Account, Error> {
const idOrError = UniqueID.create(source.id);
const statusOrError = AccountStatus.create(source.status);
const tinOrError = TINNumber.create(source.tin);
const emailOrError = EmailAddress.create(source.email);
const phoneOrError = PhoneNumber.create(source.phone);
@ -31,6 +32,7 @@ export class AccountMapper
const result = Result.combine([
idOrError,
statusOrError,
tinOrError,
emailOrError,
phoneOrError,
@ -44,6 +46,7 @@ export class AccountMapper
return Account.create(
{
status: statusOrError.data,
isFreelancer: source.is_freelancer,
name: source.name,
tradeName: source.trade_name ? Maybe.some(source.trade_name) : Maybe.none(),
@ -55,7 +58,6 @@ export class AccountMapper
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,
logo: source.logo ? Maybe.some(source.logo) : Maybe.none(),
@ -64,11 +66,8 @@ export class AccountMapper
);
}
public mapToPersistence(
source: Account,
params?: MapperParamsType
): Result<AccountCreationAttributes, Error> {
return Result.ok({
public mapToPersistence(source: Account, params?: MapperParamsType): AccountCreationAttributes {
return {
id: source.id.toString(),
is_freelancer: source.isFreelancer,
name: source.name,
@ -92,7 +91,7 @@ export class AccountMapper
lang_code: source.langCode,
currency_code: source.currencyCode,
logo: source.logo.getOrUndefined(),
});
};
}
}

View File

@ -63,6 +63,7 @@ export default (sequelize: Sequelize) => {
trade_name: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
tin: {
type: DataTypes.STRING,
@ -104,10 +105,12 @@ export default (sequelize: Sequelize) => {
fax: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
website: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
validate: {
isUrl: true,
},
@ -126,6 +129,7 @@ export default (sequelize: Sequelize) => {
logo: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
lang_code: {

View File

@ -2,7 +2,7 @@ import { EmailAddress, UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { SequelizeRepository } from "@common/infrastructure";
import { Account } from "@contexts/accounts/domain";
import { IAccountRepository } from "@contexts/accounts/domain/repositories/company-repository.interface";
import { IAccountRepository } from "@contexts/accounts/domain/repositories/account-repository.interface";
import { Transaction } from "sequelize";
import { accountMapper, IAccountMapper } from "../mappers/account.mapper";
import { AccountModel } from "./account.model";
@ -26,6 +26,16 @@ class AccountRepository extends SequelizeRepository<Account> implements IAccount
this._mapper = mapper;
}
async accountExists(id: UniqueID, transaction?: Transaction): Promise<Result<boolean, Error>> {
try {
const _account = await this._getById(AccountModel, id, {}, transaction);
return Result.ok(Boolean(id.equals(_account.id)));
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async findAll(transaction?: Transaction): Promise<Result<Collection<Account>, Error>> {
try {
const rawAccounts: any = await this._findAll(AccountModel, {}, transaction);
@ -76,6 +86,16 @@ class AccountRepository extends SequelizeRepository<Account> implements IAccount
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async create(account: Account, transaction?: Transaction): Promise<void> {
const accountData = this._mapper.mapToPersistence(account);
await this._save(AccountModel, account.id, accountData, {}, transaction);
}
async update(account: Account, transaction?: Transaction): Promise<void> {
const accountData = this._mapper.mapToPersistence(account);
await this._save(AccountModel, account.id, accountData, {}, transaction);
}
}
const accountRepository = new AccountRepository(accountMapper);

View File

@ -1,8 +1,10 @@
import { IAccountRepository } from "@contexts/accounts/domain/repositories/company-repository.interface";
import { IAccountRepository } from "@contexts/accounts/domain/repositories/account-repository.interface";
import { accountRepository } from "./account.repository";
export * from "./account.model";
export * from "./account.repository";
export const createAccountRepository = (): IAccountRepository => {
return accountRepository;
};

View File

@ -0,0 +1,45 @@
import { UniqueID } from "@common/domain";
import { ExpressController } from "@common/presentation";
import { CreateAccountUseCase } from "../../../application";
import { ICreateAccountRequestDTO } from "../../dto";
import { ICreateAccountPresenter } from "./create-account.presenter";
export class CreateAccountController extends ExpressController {
public constructor(
private readonly createAccount: CreateAccountUseCase,
private readonly presenter: ICreateAccountPresenter
) {
super();
}
protected async executeImpl() {
const createDTO: ICreateAccountRequestDTO = this.req.body;
// Validar ID
const accountIdOrError = UniqueID.create(createDTO.id);
if (accountIdOrError.isFailure) return this.invalidInputError("Account ID not valid");
const accountOrError = await this.createAccount.execute(accountIdOrError.data, createDTO);
if (accountOrError.isFailure) {
return this.handleError(accountOrError.error);
}
return this.ok(this.presenter.toDTO(accountOrError.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,37 @@
import { ensureBoolean, ensureNumber, ensureString } from "@common/helpers";
import { Account } from "@contexts/accounts/domain";
import { ICreateAccountResponseDTO } from "../../dto";
export interface ICreateAccountPresenter {
toDTO: (account: Account) => ICreateAccountResponseDTO;
}
export const createAccountPresenter: ICreateAccountPresenter = {
toDTO: (account: Account): ICreateAccountResponseDTO => ({
id: ensureString(account.id.toString()),
is_freelancer: ensureBoolean(account.isFreelancer),
name: ensureString(account.name),
trade_name: ensureString(account.tradeName.getOrUndefined()),
tin: ensureString(account.tin.toString()),
street: ensureString(account.address.street),
city: ensureString(account.address.city),
state: ensureString(account.address.state),
postal_code: ensureString(account.address.postalCode),
country: ensureString(account.address.country),
email: ensureString(account.email.toString()),
phone: ensureString(account.phone.toString()),
fax: ensureString(account.fax.getOrUndefined()?.toString()),
website: ensureString(account.website.getOrUndefined()),
legal_record: ensureString(account.legalRecord),
default_tax: ensureNumber(account.defaultTax),
status: ensureString(account.isActive ? "active" : "inactive"),
lang_code: ensureString(account.langCode),
currency_code: ensureString(account.currencyCode),
logo: ensureString(account.logo.getOrUndefined()),
}),
};

View File

@ -0,0 +1,16 @@
import { SequelizeTransactionManager } from "@common/infrastructure";
import { CreateAccountUseCase } from "@contexts/accounts/application/create-account.use-case";
import { AccountService } from "@contexts/accounts/domain";
import { accountRepository } from "@contexts/accounts/infraestructure";
import { CreateAccountController } from "./create-account.controller";
import { createAccountPresenter } from "./create-account.presenter";
export const createAccountController = () => {
const transactionManager = new SequelizeTransactionManager();
const accountService = new AccountService(accountRepository);
const useCase = new CreateAccountUseCase(accountService, transactionManager);
const presenter = createAccountPresenter;
return new CreateAccountController(useCase, presenter);
};

View File

@ -0,0 +1,44 @@
import { UniqueID } from "@common/domain";
import { ExpressController } from "@common/presentation";
import { GetAccountsUseCase } from "@contexts/accounts/application";
import { IGetAccountPresenter } from "./get-account.presenter";
export class GetAccountController extends ExpressController {
public constructor(
private readonly getAccount: GetAccountsUseCase,
private readonly presenter: IGetAccountPresenter
) {
super();
}
protected async executeImpl() {
const { accountId } = this.req.params;
// Validar ID
const accountIdOrError = UniqueID.create(accountId);
if (accountIdOrError.isFailure) return this.invalidInputError("Account ID not valid");
const accountOrError = await this.getAccount.execute(accountIdOrError.data);
if (accountOrError.isFailure) {
return this.handleError(accountOrError.error);
}
return this.ok(this.presenter.toDTO(accountOrError.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,37 @@
import { ensureBoolean, ensureNumber, ensureString } from "@common/helpers";
import { Account } from "@contexts/accounts/domain";
import { IGetAccountResponseDTO } from "../../dto";
export interface IGetAccountPresenter {
toDTO: (account: Account) => IGetAccountResponseDTO;
}
export const getAccountPresenter: IGetAccountPresenter = {
toDTO: (account: Account): IGetAccountResponseDTO => ({
id: ensureString(account.id.toString()),
is_freelancer: ensureBoolean(account.isFreelancer),
name: ensureString(account.name),
trade_name: ensureString(account.tradeName.getOrUndefined()),
tin: ensureString(account.tin.toString()),
street: ensureString(account.address.street),
city: ensureString(account.address.city),
state: ensureString(account.address.state),
postal_code: ensureString(account.address.postalCode),
country: ensureString(account.address.country),
email: ensureString(account.email.toString()),
phone: ensureString(account.phone.toString()),
fax: ensureString(account.fax.getOrUndefined()?.toString()),
website: ensureString(account.website.getOrUndefined()),
legal_record: ensureString(account.legalRecord),
default_tax: ensureNumber(account.defaultTax),
status: ensureString(account.isActive ? "active" : "inactive"),
lang_code: ensureString(account.langCode),
currency_code: ensureString(account.currencyCode),
logo: ensureString(account.logo.getOrUndefined()),
}),
};

View File

@ -0,0 +1,16 @@
import { SequelizeTransactionManager } from "@common/infrastructure";
import { GetAccountsUseCase } from "@contexts/accounts/application";
import { AccountService } from "@contexts/accounts/domain";
import { accountRepository } from "@contexts/accounts/infraestructure";
import { GetAccountController } from "./get-account.controller";
import { getAccountPresenter } from "./get-account.presenter";
export const getAccountController = () => {
const transactionManager = new SequelizeTransactionManager();
const accountService = new AccountService(accountRepository);
const useCase = new GetAccountsUseCase(accountService, transactionManager);
const presenter = getAccountPresenter;
return new GetAccountController(useCase, presenter);
};

View File

@ -1 +1,4 @@
export * from "./create-account";
export * from "./get-account";
export * from "./list-accounts";
export * from "./update-account";

View File

@ -1,7 +1,7 @@
import { SequelizeTransactionManager } from "@common/infrastructure";
import { ListAccountsUseCase } from "@contexts/accounts/application/list-accounts/list-accounts.use-case";
import { AccountService } from "@contexts/accounts/domain/services/company.service";
import { accountRepository } from "@contexts/accounts/infraestructure/sequelize/account.repository";
import { ListAccountsUseCase } from "@contexts/accounts/application";
import { AccountService } from "@contexts/accounts/domain";
import { accountRepository } from "@contexts/accounts/infraestructure";
import { ListAccountsController } from "./list-accounts.controller";
import { listAccountsPresenter } from "./list-accounts.presenter";

View File

@ -1,5 +1,5 @@
import { ExpressController } from "@common/presentation";
import { ListAccountsUseCase } from "@contexts/accounts/application/list-accounts/list-accounts.use-case";
import { ListAccountsUseCase } from "@contexts/accounts/application";
import { IListAccountsPresenter } from "./list-accounts.presenter";
export class ListAccountsController extends ExpressController {

View File

@ -0,0 +1,16 @@
import { SequelizeTransactionManager } from "@common/infrastructure";
import { UpdateAccountUseCase } from "@contexts/accounts/application";
import { AccountService } from "@contexts/accounts/domain";
import { accountRepository } from "@contexts/accounts/infraestructure";
import { UpdateAccountController } from "./update-account.controller";
import { updateAccountPresenter } from "./update-account.presenter";
export const updateAccountController = () => {
const transactionManager = new SequelizeTransactionManager();
const accountService = new AccountService(accountRepository);
const useCase = new UpdateAccountUseCase(accountService, transactionManager);
const presenter = updateAccountPresenter;
return new UpdateAccountController(useCase, presenter);
};

View File

@ -0,0 +1,46 @@
import { UniqueID } from "@common/domain";
import { ExpressController } from "@common/presentation";
import { UpdateAccountUseCase } from "@contexts/accounts/application/update-account.use-case";
import { IUpdateAccountRequestDTO } from "../../dto";
import { IUpdateAccountPresenter } from "./update-account.presenter";
export class UpdateAccountController extends ExpressController {
public constructor(
private readonly updateAccount: UpdateAccountUseCase,
private readonly presenter: IUpdateAccountPresenter
) {
super();
}
protected async executeImpl() {
const { accountId } = this.req.params;
const updateDTO: IUpdateAccountRequestDTO = this.req.body;
// Validar ID
const accountIdOrError = UniqueID.create(accountId);
if (accountIdOrError.isFailure) return this.invalidInputError("Account ID not valid");
const accountOrError = await this.updateAccount.execute(accountIdOrError.data, updateDTO);
if (accountOrError.isFailure) {
return this.handleError(accountOrError.error);
}
return this.ok(this.presenter.toDTO(accountOrError.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,37 @@
import { ensureBoolean, ensureNumber, ensureString } from "@common/helpers";
import { Account } from "@contexts/accounts/domain";
import { IUpdateAccountResponseDTO } from "../../dto";
export interface IUpdateAccountPresenter {
toDTO: (account: Account) => IUpdateAccountResponseDTO;
}
export const updateAccountPresenter: IUpdateAccountPresenter = {
toDTO: (account: Account): IUpdateAccountResponseDTO => ({
id: ensureString(account.id.toString()),
is_freelancer: ensureBoolean(account.isFreelancer),
name: ensureString(account.name),
trade_name: ensureString(account.tradeName.getOrUndefined()),
tin: ensureString(account.tin.toString()),
street: ensureString(account.address.street),
city: ensureString(account.address.city),
state: ensureString(account.address.state),
postal_code: ensureString(account.address.postalCode),
country: ensureString(account.address.country),
email: ensureString(account.email.toString()),
phone: ensureString(account.phone.toString()),
fax: ensureString(account.fax.getOrUndefined()?.toString()),
website: ensureString(account.website.getOrUndefined()),
legal_record: ensureString(account.legalRecord),
default_tax: ensureNumber(account.defaultTax),
status: ensureString(account.isActive ? "active" : "inactive"),
lang_code: ensureString(account.langCode),
currency_code: ensureString(account.currencyCode),
logo: ensureString(account.logo.getOrUndefined()),
}),
};

View File

@ -1 +1,52 @@
export interface IListAccountsRequestDTO {}
export interface ICreateAccountRequestDTO {
id: 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;
lang_code: string;
currency_code: string;
logo: string;
}
export interface IUpdateAccountRequestDTO {
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;
lang_code: string;
currency_code: string;
logo: string;
}

View File

@ -25,3 +25,90 @@ export interface IListAccountsResponseDTO {
currency_code: string;
logo: string;
}
export interface IGetAccountResponseDTO {
id: 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;
logo: string;
}
export interface ICreateAccountResponseDTO {
id: 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;
logo: string;
}
// Inferir el tipo en TypeScript desde el esquema Zod
//export type IUpdateAcccountResponseDTO = z.infer<typeof IUpdateAcccountResponseDTOSchema>;
export interface IUpdateAccountResponseDTO {
id: 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;
logo: string;
}

View File

@ -1,3 +1,87 @@
import { z } from "zod";
export const ListAccountsSchema = z.object({});
export const IGetAcccountResponseDTOSchema = z.object({
id: z.string(),
is_freelancer: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),
street: z.string(),
city: z.string(),
state: z.string(),
postal_code: z.string(),
country: z.string(),
email: z.string().email(), // Validación específica para email
phone: z.string(),
fax: z.string(),
website: z.string().url(), // Validación específica para URL
legal_record: z.string(),
default_tax: z.number(),
status: z.string(),
lang_code: z.string(),
currency_code: z.string(),
logo: z.string(),
});
export const ICreateAcccountResponseDTOSchema = z.object({
id: z.string(),
is_freelancer: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),
street: z.string(),
city: z.string(),
state: z.string(),
postal_code: z.string(),
country: z.string(),
email: z.string().email(), // Validación específica para email
phone: z.string(),
fax: z.string(),
website: z.string().url(), // Validación específica para URL
legal_record: z.string(),
default_tax: z.number(),
status: z.string(),
lang_code: z.string(),
currency_code: z.string(),
logo: z.string(),
});
export const IUpdateAcccountResponseDTOSchema = z.object({
id: z.string(),
is_freelancer: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),
street: z.string(),
city: z.string(),
state: z.string(),
postal_code: z.string(),
country: z.string(),
email: z.string().email(), // Validación específica para email
phone: z.string(),
fax: z.string(),
website: z.string().url(), // Validación específica para URL
legal_record: z.string(),
default_tax: z.number(),
status: z.string(),
lang_code: z.string(),
currency_code: z.string(),
logo: z.string(),
});

View File

@ -67,6 +67,7 @@ export default (sequelize: Sequelize) => {
trade_name: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
tin: {
type: DataTypes.STRING,
@ -108,10 +109,12 @@ export default (sequelize: Sequelize) => {
fax: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
website: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
validate: {
isUrl: true,
},

View File

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

View File

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

View File

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

View File

@ -1,17 +0,0 @@
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.findCustomer(transaction);
});
}
}

View File

@ -1,2 +1,2 @@
export * from "./customer-invoices";
export * from "./customers";
export * from "./get-customer-invoice.use-case";
export * from "./list-customer-invoices-use-case";

View File

@ -0,0 +1,16 @@
import { Collection, Result } from "@common/helpers";
import { ITransactionManager } from "@common/infrastructure/database";
import { CustomerInvoice, ICustomerInvoiceService } from "../domain";
export class ListCustomerInvoicesUseCase {
constructor(
private readonly invoiceService: ICustomerInvoiceService,
private readonly transactionManager: ITransactionManager
) {}
public execute(): Promise<Result<Collection<CustomerInvoice>, Error>> {
return this.transactionManager.complete((transaction) => {
return this.invoiceService.findCustomerInvoices(transaction);
});
}
}

View File

@ -1,5 +1,5 @@
import { AggregateRoot, UniqueID, UtcDate } from "@common/domain";
import { Result } from "@common/helpers";
import { Maybe, Result } from "@common/helpers";
import { Customer, CustomerInvoiceItem } from "../entities";
import { InvoiceStatus } from "../value-objetcs";
@ -8,7 +8,7 @@ export interface ICustomerInvoiceProps {
issueDate: UtcDate;
invoiceNumber: string;
invoiceType: string;
invoiceCustomerReference: string;
invoiceCustomerReference: Maybe<string>;
customer: Customer;
items: CustomerInvoiceItem[];
@ -20,7 +20,7 @@ export interface ICustomerInvoice {
issueDate: UtcDate;
invoiceNumber: string;
invoiceType: string;
invoiceCustomerReference: string;
invoiceCustomerReference: Maybe<string>;
customer: Customer;
items: CustomerInvoiceItem[];
@ -63,7 +63,7 @@ export class CustomerInvoice
return this.props.invoiceType;
}
get invoiceCustomerReference(): string {
get invoiceCustomerReference(): Maybe<string> {
return this.props.invoiceCustomerReference;
}

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { PostalAddress, TINNumber, UniqueID, UtcDate } from "@common/domain";
import { Result } from "@common/helpers";
import { Maybe, Result } from "@common/helpers";
import {
ISequelizeMapper,
MapperParamsType,
@ -70,7 +70,7 @@ export class CustomerInvoiceMapper
issueDate: issueDateOrError.data,
invoiceNumber: source.invoice_number,
invoiceType: source.invoice_type,
invoiceCustomerReference: source.invoice_customer_reference,
invoiceCustomerReference: Maybe.fromNullable(source.invoice_customer_reference),
customer: customerOrError.data,
items: [],
},
@ -89,7 +89,7 @@ export class CustomerInvoiceMapper
issue_date: source.issueDate.toDateString(),
invoice_number: source.invoiceNumber,
invoice_type: source.invoiceType,
invoice_customer_reference: source.invoiceCustomerReference,
invoice_customer_reference: source.invoiceCustomerReference.getOrUndefined(),
lang_code: "es",
currency_code: "EUR",

View File

@ -56,6 +56,7 @@ export default (sequelize: Sequelize) => {
id_article: {
type: DataTypes.BIGINT().UNSIGNED,
allowNull: true,
defaultValue: null,
},
position: {
type: new DataTypes.MEDIUMINT(),
@ -65,26 +66,32 @@ export default (sequelize: Sequelize) => {
description: {
type: new DataTypes.TEXT(),
allowNull: true,
defaultValue: null,
},
quantity: {
type: DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
unit_price: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
subtotal_price: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
discount: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
total_price: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
},
{

View File

@ -126,6 +126,7 @@ export default (sequelize: Sequelize) => {
customer_id: {
type: new DataTypes.UUID(),
allowNull: false,
},
customer_name: {
@ -146,6 +147,7 @@ export default (sequelize: Sequelize) => {
customer_street2: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
customer_city: {
@ -168,56 +170,67 @@ export default (sequelize: Sequelize) => {
subtotal_price: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
discount: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
discount_price: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
before_tax_price: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
tax: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
tax_price: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
total_price: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
defaultValue: null,
},
integrity_hash: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
comment: "Hash criptográfico para asegurar integridad",
},
previous_invoice_id: {
type: DataTypes.UUID,
allowNull: true,
defaultValue: null,
comment: "Referencia a la factura anterior (si aplica)",
},
signed_at: {
type: DataTypes.DATE,
allowNull: true,
defaultValue: null,
comment: "Fecha en que la factura fue firmada digitalmente",
},
},

View File

@ -67,6 +67,7 @@ export default (sequelize: Sequelize) => {
trade_name: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
tin: {
type: DataTypes.STRING,
@ -108,10 +109,12 @@ export default (sequelize: Sequelize) => {
fax: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
website: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
validate: {
isUrl: true,
},

View File

@ -1,81 +0,0 @@
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

@ -1,17 +1,15 @@
import { ICustomerRepository } from "@contexts/customer-billing/domain";
import { ICustomerInvoiceRepository } from "@contexts/customer-billing/domain/";
import { customerRepository } from "./customer.repository";
export * from "./customer.model";
export * from "./customer.repository";
import { customerInvoiceRepository } from "./customer-invoice.repository";
export * from "./customer-invoice.model";
export * from "./customer.model";
export * from "./customer-invoice.repository";
export const createCustomerRepository = (): ICustomerRepository => {
/*export const createCustomerRepository = (): ICustomerRepository => {
return customerRepository;
};
};*/
export const createCustomerInvoiceRepository = (): ICustomerInvoiceRepository => {
return customerRepository;
return customerInvoiceRepository;
};

View File

@ -1,6 +1,7 @@
import { SequelizeTransactionManager } from "@common/infrastructure";
import { CustomerInvoiceService } from "@contexts/customer-billing/domain";
import { customerInvoiceRepository } from "@contexts/customer-billing/infraestructure";
import { ListCustomerInvoicesUseCase } from "../../../../application";
import { ListCustomerInvoicesController } from "./list-customer-invoices.controller";
import { listCustomerInvoicesPresenter } from "./list-customer-invoices.presenter";

View File

@ -1,4 +1,4 @@
import { Collection, ensureBoolean, ensureNumber, ensureString } from "@common/helpers";
import { Collection, ensureString } from "@common/helpers";
import { CustomerInvoice } from "@contexts/customer-billing/domain";
import { IListCustomerInvoicesResponseDTO } from "../../../dto";
@ -8,10 +8,10 @@ export interface IListCustomerInvoicesPresenter {
}
export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = {
toDTO: (customers: Collection<CustomerInvoice>): IListCustomerInvoicesResponseDTO[] =>
customers.map((customer) => ({
toDTO: (invoice: Collection<CustomerInvoice>): IListCustomerInvoicesResponseDTO[] =>
invoice.map((customer) => ({
id: ensureString(customer.id.toString()),
reference: ensureString(customer.reference),
/*reference: ensureString(customer.),
is_freelancer: ensureBoolean(customer.isFreelancer),
name: ensureString(customer.name),
@ -34,6 +34,6 @@ export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = {
default_tax: ensureNumber(customer.defaultTax),
status: ensureString(customer.isActive ? "active" : "inactive"),
lang_code: ensureString(customer.langCode),
currency_code: ensureString(customer.currencyCode),
currency_code: ensureString(customer.currencyCode),*/
})),
};

View File

@ -1,16 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,38 +0,0 @@
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

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

View File

@ -1,6 +1,6 @@
export interface IListCustomerInvoicesResponseDTO {
id: string;
reference: string;
/*reference: string;
is_freelancer: boolean;
name: string;
@ -23,7 +23,7 @@ export interface IListCustomerInvoicesResponseDTO {
default_tax: number;
status: string;
lang_code: string;
currency_code: string;
currency_code: string;*/
}
export interface IGetCustomerInvoiceResponseDTO {}

View File

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

View File

@ -1,6 +1,16 @@
import { validateRequestDTO } from "@common/presentation";
import { ListAccountsSchema } from "@contexts/accounts/presentation";
import { listAccountsController } from "@contexts/accounts/presentation/controllers/list-accounts";
import {
ICreateAcccountResponseDTOSchema,
IGetAcccountResponseDTOSchema,
IUpdateAcccountResponseDTOSchema,
ListAccountsSchema,
} from "@contexts/accounts/presentation";
import {
createAccountController,
getAccountController,
listAccountsController,
updateAccountController,
} from "@contexts/accounts/presentation/controllers";
import { checkTabContext } from "@contexts/auth/infraestructure";
import { NextFunction, Request, Response, Router } from "express";
@ -17,5 +27,35 @@ export const accountsRouter = (appRouter: Router) => {
}
);
routes.get(
"/:accountId",
validateRequestDTO(IGetAcccountResponseDTOSchema),
checkTabContext,
//checkUser,
(req: Request, res: Response, next: NextFunction) => {
getAccountController().execute(req, res, next);
}
);
routes.post(
"/",
validateRequestDTO(ICreateAcccountResponseDTOSchema),
checkTabContext,
//checkUser,
(req: Request, res: Response, next: NextFunction) => {
createAccountController().execute(req, res, next);
}
);
routes.put(
"/:accountId",
validateRequestDTO(IUpdateAcccountResponseDTOSchema),
checkTabContext,
//checkUser,
(req: Request, res: Response, next: NextFunction) => {
updateAccountController().execute(req, res, next);
}
);
appRouter.use("/accounts", routes);
};

View File

@ -65,9 +65,6 @@ importers:
luxon:
specifier: ^3.5.0
version: 3.5.0
mariadb:
specifier: ^3.4.0
version: 3.4.0
module-alias:
specifier: ^2.2.3
version: 2.2.3
@ -94,16 +91,13 @@ importers:
version: 2.3.3
sequelize:
specifier: ^6.37.5
version: 6.37.5(mariadb@3.4.0)(mysql2@3.12.0)
version: 6.37.5(mysql2@3.12.0)
shallow-equal-object:
specifier: ^1.1.1
version: 1.1.1
ts-node:
specifier: ^10.9.1
version: 10.9.2(@types/node@22.12.0)(typescript@5.7.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
uuid:
specifier: ^11.0.5
version: 11.0.5
@ -201,6 +195,9 @@ importers:
ts-node-dev:
specifier: ^2.0.0
version: 2.0.0(@types/node@22.12.0)(typescript@5.7.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
typescript:
specifier: ^5.7.3
version: 5.7.3
@ -1289,9 +1286,6 @@ packages:
'@types/express@4.17.21':
resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/glob@8.1.0':
resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==}
@ -3215,10 +3209,6 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
mariadb@3.4.0:
resolution: {integrity: sha512-hdRPcAzs+MTxK5VG1thBW18gGTlw6yWBe9YnLB65GLo7q0fO5DWsgomIevV/pXSaWRmD3qi6ka4oSFRTExRiEQ==}
engines: {node: '>= 14'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@ -5538,8 +5528,6 @@ snapshots:
'@types/qs': 6.9.18
'@types/serve-static': 1.15.7
'@types/geojson@7946.0.16': {}
'@types/glob@8.1.0':
dependencies:
'@types/minimatch': 5.1.2
@ -8128,14 +8116,6 @@ snapshots:
dependencies:
tmpl: 1.0.5
mariadb@3.4.0:
dependencies:
'@types/geojson': 7946.0.16
'@types/node': 22.12.0
denque: 2.1.0
iconv-lite: 0.6.3
lru-cache: 10.4.3
math-intrinsics@1.1.0: {}
media-typer@0.3.0: {}
@ -8746,7 +8726,7 @@ snapshots:
sequelize-pool@7.1.0: {}
sequelize@6.37.5(mariadb@3.4.0)(mysql2@3.12.0):
sequelize@6.37.5(mysql2@3.12.0):
dependencies:
'@types/debug': 4.1.12
'@types/validator': 13.12.2
@ -8765,7 +8745,6 @@ snapshots:
validator: 13.12.0
wkx: 0.5.0
optionalDependencies:
mariadb: 3.4.0
mysql2: 3.12.0
transitivePeerDependencies:
- supports-color