This commit is contained in:
David Arranz 2025-04-27 22:47:47 +02:00
commit d844f242de
342 changed files with 21314 additions and 0 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
node_modules

5
.eslintrc.js Normal file
View File

@ -0,0 +1,5 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@repo/eslint-config/index.js"],
};

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# next.js
.next/
out/
build
# other
dist/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo

1
.npmrc Normal file
View File

@ -0,0 +1 @@
auto-install-peers = true

13
.prettierrc Normal file
View File

@ -0,0 +1,13 @@
{
"bracketSpacing": true,
"useTabs": false,
"printWidth": 100,
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"jsxSingleQuote": true,
"jsxBracketSameLine": false,
"arrowParens": "always",
"rcVerbose": true
}

60
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,60 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch firefox localhost",
"type": "firefox",
"request": "launch",
"reAttach": true,
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/client"
},
{
"name": "Launch Chrome localhost",
"type": "chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/client"
},
{
"type": "msedge",
"request": "launch",
"name": "CLIENT: Launch Edge against localhost",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/client"
},
{
"type": "node",
"request": "attach",
"name": "Attach to ts-node-dev",
"port": 4321,
"restart": true,
"timeout": 10000,
"sourceMaps": true,
"resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"]
},
{
"name": "Launch via YARN",
"request": "launch",
"runtimeArgs": ["run", "server"],
"runtimeExecutable": "yarn",
"skipFiles": ["<node_internals>/**", "client/**", "dist/**", "doc/**"],
"type": "node"
},
{
"name": "Turbo: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm run dev --filter=server",
"skipFiles": ["<node_internals>/**"],
"env": {
"NODE_OPTIONS": "--inspect"
}
}
]
}

30
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,30 @@
{
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"typescript.preferences.importModuleSpecifier": "shortest",
"javascript.preferences.importModuleSpecifier": "shortest",
"javascript.suggest.autoImports": true,
"typescript.suggest.autoImports": true,
"typescript.suggest.completeFunctionCalls": true,
"typescript.suggest.includeAutomaticOptionalChainCompletions": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnPaste": false,
"prettier.useEditorConfig": false,
"prettier.useTabs": false,
"prettier.configPath": ".prettierrc",
// other vscode settings
"[handlebars]": {
"editor.defaultFormatter": "vscode.html-language-features"
} // <- your root font size here
}

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# Turborepo Docker starter
This is a community-maintained example. If you experience a problem, please submit a pull request with a fix. GitHub Issues will be closed.
## Using this example
Run the following command:
```sh
npx create-turbo@latest -e with-docker
```
## What's inside?
This Turborepo includes the following:
### Apps and Packages
- `web`: a [Next.js](https://nextjs.org/) app
- `api`: an [Express](https://expressjs.com/) server
- `@repo/ui`: a React component library
- `@repo/logger`: Isomorphic logger (a small wrapper around console.log)
- `@repo/eslint-config`: ESLint presets
- `@repo/typescript-config`: tsconfig.json's used throughout the monorepo
- `@repo/jest-presets`: Jest configurations
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
### Docker
This repo is configured to be built with Docker, and Docker compose. To build all apps in this repo:
```
# Install dependencies
yarn install
# Create a network, which allows containers to communicate
# with each other, by using their container name as a hostname
docker network create app_network
# Build prod using new BuildKit engine
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose -f docker-compose.yml build
# Start prod in detached mode
docker-compose -f docker-compose.yml up -d
```
Open http://localhost:3000.
To shutdown all running containers:
```
# Stop running containers started by docker-compse
docker-compose -f docker-compose.yml down
```
### Remote Caching
> [!TIP]
> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache).
This example includes optional remote caching. In the Dockerfiles of the apps, uncomment the build arguments for `TURBO_TEAM` and `TURBO_TOKEN`. Then, pass these build arguments to your Docker build.
You can test this behavior using a command like:
`docker build -f apps/web/Dockerfile . --build-arg TURBO_TEAM=“your-team-name” --build-arg TURBO_TOKEN=“your-token“ --no-cache`
### Utilities
This Turborepo has some additional tools already setup for you:
- [TypeScript](https://www.typescriptlang.org/) for static type checking
- [ESLint](https://eslint.org/) for code linting
- [Jest](https://jestjs.io) test runner for all things JavaScript
- [Prettier](https://prettier.io) for code formatting

11
apps/server/.env Normal file
View File

@ -0,0 +1,11 @@
DB_HOST=localhost
DB_USER=rodax
DB_PASSWORD=rodax
DB_NAME=uecko_erp
DB_PORT=3306
PORT=3002
JWT_SECRET=supersecretkey
JWT_ACCESS_EXPIRATION=1h
JWT_REFRESH_EXPIRATION=7d

View File

@ -0,0 +1,11 @@
DB_HOST=localhost
DB_USER=rodax
DB_PASSWORD=rodax
DB_NAME=uecko_erp
DB_PORT=3306
PORT=3002
JWT_SECRET=supersecretkey
JWT_ACCESS_EXPIRATION=1h
JWT_REFRESH_EXPIRATION=7d

47
apps/server/Dockerfile Normal file
View File

@ -0,0 +1,47 @@
FROM node:18-alpine AS base
# The web Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
# Make sure you update this Dockerfile, the Dockerfile in the web workspace and copy that over to Dockerfile in the docs.
FROM base AS builder
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune api --docker
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN yarn install
# Build the project and its dependencies
COPY --from=builder /app/out/full/ .
# Uncomment and use build args to enable remote caching
# ARG TURBO_TEAM
# ENV TURBO_TEAM=$TURBO_TEAM
# ARG TURBO_TOKEN
# ENV TURBO_TOKEN=$TURBO_TOKEN
RUN yarn turbo build
FROM base AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 expressjs
RUN adduser --system --uid 1001 expressjs
USER expressjs
COPY --from=installer /app .
CMD node apps/api/dist/index.js

View File

@ -0,0 +1,10 @@
import config from "@repo/eslint-config/index.js";
/** @type {import("eslint").Linter.FlatConfig} */
export default [
{
...config,
files: ["**/*.js", "**/*.ts", "**/*.tsx"],
ignores: ["node_modules", "dist", "build"],
},
];

77
apps/server/package.json Normal file
View File

@ -0,0 +1,77 @@
{
"name": "server",
"version": "0.0.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"clean": "rm -rf dist && rm -rf node_modules",
"dev": "nodemon --exec \"node -r esbuild-register ./src/index.ts\" -e .ts",
"lint": "tsc --noEmit && eslint \"src/**/*.ts*\" --max-warnings 0",
"start": "node -r esbuild-register ./src/index.ts",
"test": "jest --detectOpenHandles"
},
"jest": {
"preset": "@repo/jest-presets/node"
},
"dependencies": {
"@rdx/core": "workspace:*",
"@rdx/ddd-domain": "workspace:*",
"@rdx/logger": "workspace:*",
"@rdx/modules": "workspace:*",
"@rdx/utils": "workspace:*",
"@modules/invoices": "workspace:*",
"bcrypt": "^5.1.1",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dinero.js": "^1.9.1",
"dotenv": "^16.5.0",
"express": "^4.21.2",
"helmet": "^8.1.0",
"http-status": "^2.1.0",
"jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.11.20",
"luxon": "^3.5.0",
"module-alias": "^2.2.3",
"mysql2": "^3.12.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"path": "^0.12.7",
"reflect-metadata": "^0.2.2",
"response-time": "^2.3.3",
"sequelize": "^6.37.7",
"zod": "^3.24.3"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@repo/eslint-config": "workspace:*",
"@repo/jest-presets": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/bcrypt": "^5.0.2",
"@types/body-parser": "^1.19.5",
"@types/cors": "^2.8.17",
"@types/dinero.js": "^1.9.4",
"@types/express": "^4.17.21",
"@types/glob": "^8.1.0",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.9",
"@types/luxon": "^3.6.2",
"@types/morgan": "^1.9.9",
"@types/node": "^22.15.2",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/response-time": "^2.3.8",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"esbuild": "^0.25.3",
"esbuild-register": "^3.6.0",
"eslint": "^9.25.1",
"jest": "^29.7.0",
"nodemon": "^3.1.10",
"supertest": "^7.1.0",
"typescript": "5.8.3"
}
}

View File

@ -0,0 +1,23 @@
import supertest from "supertest";
import { describe, it, expect } from "@jest/globals";
import { createApp } from "../app";
describe("app", () => {
it("status check returns 200", async () => {
await supertest(createApp())
.get("/status")
.expect(200)
.then((res) => {
expect(res.body.ok).toBe(true);
});
});
it("message endpoint says hello", async () => {
await supertest(createApp())
.get("/message/jared")
.expect(200)
.then((res) => {
expect(res.body.message).toBe("hello jared");
});
});
});

56
apps/server/src/app.ts Normal file
View File

@ -0,0 +1,56 @@
import { globalErrorHandler } from "@rdx/core";
import { logger } from "@rdx/logger";
import dotenv from "dotenv";
import express, { Application } from "express";
import helmet from "helmet";
import responseTime from "response-time";
dotenv.config();
export function createApp(): Application {
const app = express();
app.set("port", process.env.PORT ?? 3002);
// secure apps by setting various HTTP headers
app.disable("x-powered-by");
// Middlewares
app.use(express.json());
app.use(express.text());
app.use(express.urlencoded({ extended: true }));
app.use(responseTime()); // set up the response-time middleware
// secure apps by setting various HTTP headers
app.use(helmet());
// Middleware global para desactivar la caché en todas las rutas
app.use((req, res, next) => {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.setHeader("etag", "false");
next(); // Continúa con la siguiente función middleware o la ruta
});
// Inicializar Auth Provider
app.use((req, res, next) => {
//authProvider.initialize();
next();
});
app.use((req, _, next) => {
logger.info(`▶️ Incoming request ${req.method} to ${req.path}`);
next();
});
// Registrar rutas de la API
// app.use("/api/v1", v1Routes());
// Gestión global de errores.
// Siempre al final de la cadena de middlewares
// y después de las rutas.
app.use(globalErrorHandler);
return app;
}

View File

@ -0,0 +1,48 @@
import { logger } from "@rdx/logger";
import dotenv from "dotenv";
import { Sequelize } from "sequelize";
dotenv.config();
export const sequelize = new Sequelize(
process.env.DB_NAME as string, // database
process.env.DB_USER as string, // username
process.env.DB_PASSWORD as string, // password
{
host: process.env.DB_HOST as string,
dialect: "mysql",
port: parseInt(process.env.DB_PORT || "3306", 10),
dialectOptions: {
multipleStatements: true,
dateStrings: true,
typeCast: true,
//timezone: "Z",
},
pool: {
max: 10,
min: 0,
acquire: 30000,
idle: 10000,
},
logQueryParameters: true,
logging: process.env.DB_LOGGING === "true" ? logger.debug : false,
define: {
charset: "utf8mb4",
collate: "utf8mb4_unicode_ci",
//freezeTableName: true,
underscored: true,
timestamps: true,
},
}
);
export async function connectToDatabase(): Promise<void> {
try {
await sequelize.authenticate();
//await registerModels();
logger.info(`✔️${" "}Database connection established successfully.`);
} catch (error) {
logger.error("❌ Unable to connect to the database:", error);
process.exit(1);
}
}

View File

@ -0,0 +1,12 @@
import dotenv from "dotenv";
export * from "./database";
// Carga variables de entorno desde el archivo .env
dotenv.config();
// Exporta una configuración centralizada, aplicando valores por defecto donde sea necesario
export const ENV = {
HOST: process.env.HOST || process.env.HOSTNAME || "localhost",
PORT: process.env.PORT || "18888",
NODE_ENV: process.env.NODE_ENV || "development",
};

View File

@ -0,0 +1,66 @@
import * as glob from "glob";
import * as path from "path";
import { DataTypes } from "sequelize";
import { sequelize } from "./database";
import { logger } from "@rdx/logger";
/**
* 🔹 Registra todos los modelos en Sequelize
*/
export const registerModels = async () => {
const cwd = path.resolve(`${__dirname}/../`);
const models: { [key: string]: any } = {};
// Opciones para buscar los modelos
const globOptions = {
cwd,
nocase: true,
nodir: true,
absolute: false,
};
try {
logger.info(`🔎 Searching models in: ${cwd}`);
// Buscamos los ficheros que terminen en .model.js o .model.ts
glob.sync("**/*.model.{js,ts}", globOptions).forEach((file) => {
//logger.info(`📄 File >> ${file}...`);
const modelDef = require(path.join(file)).default;
const model = typeof modelDef === "function" ? modelDef(sequelize, DataTypes) : false;
if (model) {
models[model.name] = model;
logger.info(`🔸 Model >> ${model.name} (${file})`);
} else {
logger.info(`🚫 No model`);
}
});
// Asociaciones y hooks de los modelos, si existen
for (const modelName in models) {
const model = models[modelName];
if (model.associate) {
model.associate(sequelize, models);
}
if (model.hooks) {
model.hooks(sequelize);
}
}
} catch (error) {
logger.error("❌ Error registering models:", error);
process.exit(1);
}
try {
// Sincronizamos DB en modo desarrollo
if (process.env.NODE_ENV !== "production") {
await sequelize.sync({ force: false, alter: true });
logger.info(`✔️${" "}Database synchronized successfully.`);
} else {
logger.warning("⚠️ Running in production mode - Skipping database sync.");
}
} catch (error) {
logger.error("❌ Error synchronizing database:", error);
process.exit(1);
}
};

View File

@ -0,0 +1,2 @@
export * from "./module-loader";
export * from "./service-registry";

View File

@ -0,0 +1,54 @@
import { logger } from "@rdx/logger";
import { ModelInitializer } from "@rdx/modules";
import { Sequelize } from "sequelize";
const registeredModels: Map<string, any> = new Map();
const initializedModels = new Set<string>();
/**
* 🔹 Registra todos los modelos en Sequelize
*/
export const registerModel = (models: ModelInitializer[], database: Sequelize) => {
for (const initModelFn of models) {
const model = initModelFn(database);
if (model) {
registeredModels.set(model.name, model);
}
}
};
export const initModels = async (sequelize: Sequelize) => {
registeredModels.forEach((_, name) => loadModel(name, sequelize));
try {
// Sincronizamos DB en modo desarrollo
if (process.env.NODE_ENV !== "production") {
await sequelize.sync({ force: false, alter: true });
logger.info(`✔️${" "}Database synchronized successfully.`);
} else {
logger.warning("⚠️ Running in production mode - Skipping database sync.");
}
} catch (error) {
logger.error("❌ Error synchronizing database:", error);
process.exit(1);
}
};
export const loadModel = (name: string, sequelize: Sequelize) => {
if (initializedModels.has(name)) return;
const model = registeredModels.get(name);
if (!model) throw new Error(`❌ Model "${name}" not found.`);
// Asociaciones y hooks de los modelos, si existen
if (model.associate) {
model.associate(sequelize);
}
if (model.hooks) {
model.hooks(sequelize);
}
initializedModels.add(name);
logger.info(`🔸 Model "${model.name}" registered (sequelize)`);
};

View File

@ -0,0 +1,55 @@
import { logger } from "@rdx/logger";
import { IModuleServer } from "@rdx/modules";
import { Application } from "express";
import { Sequelize } from "sequelize";
import { initModels, registerModel } from "./model-loader";
import { registerService } from "./service-registry";
const registeredModules: Map<string, IModuleServer> = new Map();
const initializedModules = new Set<string>();
export function registerModule(pkg: IModuleServer) {
if (registeredModules.has(pkg.metadata.name)) {
throw new Error(`❌ Paquete "${pkg.metadata.name}" ya registrado.`);
}
registeredModules.set(pkg.metadata.name, pkg);
}
export function initModules(app: Application, database: Sequelize) {
registeredModules.forEach((_, name) => {
loadModule(name, app, database);
});
initModels(database);
}
const loadModule = (name: string, app: Application, database: Sequelize) => {
if (initializedModules.has(name)) return;
const pkg = registeredModules.get(name);
if (!pkg) throw new Error(`❌ Paquete "${name}" no encontrado.`);
// Resolver dependencias primero
const deps = pkg.metadata.dependencies || [];
deps.forEach((dep) => loadModule(dep, app, database));
// Inicializar el module
pkg.init(app);
const pkgApi = pkg.registerDependencies?.();
// Registrar modelos de Sequelize, si los expone
if (pkgApi?.models) {
registerModel(pkgApi.models, database);
}
// Registrar sus servicios, si los expone
if (pkgApi?.services) {
const services = pkgApi.services;
if (services && typeof services === "object") {
registerService(pkg.metadata.name, services);
}
}
initializedModules.add(name);
logger.info(`✅ Paquete "${name}" registrado.`);
};

View File

@ -0,0 +1,36 @@
const services: Record<string, any> = {};
/**
* Registra un objeto de servicio (API) bajo un nombre.
*/
export function registerService(name: string, api: any) {
if (services[name]) {
throw new Error(`❌ Servicio "${name}" ya fue registrado.`);
}
services[name] = api;
}
/**
* Recupera un servicio registrado, con tipado opcional.
*/
export function getService<T = any>(name: string): T {
const service = services[name];
if (!service) {
throw new Error(`❌ Servicio "${name}" no encontrado.`);
}
return service;
}
/**
* Permite saber si un servicio fue registrado.
*/
export function hasService(name: string): boolean {
return !!services[name];
}
/**
* Devuelve todos los servicios (para depuración o tests).
*/
export function listServices(): string[] {
return Object.keys(services);
}

137
apps/server/src/index.ts Normal file
View File

@ -0,0 +1,137 @@
import { logger } from "@rdx/logger";
import http from "http";
import { DateTime } from "luxon";
import { createApp } from "./app";
import { ENV } from "./config";
import { connectToDatabase, sequelize } from "./config/database";
import { initModules } from "./core/helpers";
import { registerModules } from "./modules";
// Guardamos información del estado del servidor
export const currentState = {
launchedAt: DateTime.now(),
appPath: process.cwd(),
host: ENV.HOST,
port: ENV.PORT,
environment: ENV.NODE_ENV,
connections: {} as Record<string, any>,
};
// Manejo de cierre forzado del servidor (graceful shutdown)
const serverStop = (server: http.Server) => {
const forceTimeout = 30000;
return new Promise<void>((resolve, reject) => {
logger.warn("⚡️ Shutting down server");
setTimeout(() => {
logger.error("Could not close connections in time, forcefully shutting down");
resolve();
}, forceTimeout).unref();
server.close((err) => {
if (err) {
return reject(err);
}
logger.info("Closed out remaining connections.");
logger.info("❎ Bye!");
resolve();
});
});
};
// Manejo de errores al iniciar el servidor
const serverError = (error: NodeJS.ErrnoException) => {
logger.info(`⛔️ Server wasn't able to start properly.`);
if (error.code === "EADDRINUSE") {
logger.error(error.message);
//logger.error(`The port ${error.port} is already used by another application.`);
} else {
logger.error(error);
}
// Dependiendo de la criticidad, podrías forzar el proceso a salir
process.exit(1);
};
// Almacena en "connections" cada nueva conexión (descomentar si se quiere seguimiento)
const serverConnection = (conn: any) => {
const key = `${conn.remoteAddress}:${conn.remotePort}`;
currentState.connections[key] = conn;
conn.on("close", () => {
delete currentState.connections[key];
});
};
//const sequelizeConn = createSequelizeAdapter();
//const firebirdConn = createFirebirdAdapter();
// Registrar paquetes de la aplicación
registerModules();
const app = createApp();
// Crea el servidor HTTP
const server = http
.createServer(app)
.once("listening", () =>
process.on("SIGINT", async () => {
// Por ejemplo, podrías desconectar la base de datos aquí:
// firebirdConn.disconnect();
// O forzar desconexión en adapters
// sequelizeConn.close();
await serverStop(server);
})
)
.on("close", () =>
logger.info(`Shut down at: ${DateTime.now().toLocaleString(DateTime.DATETIME_FULL)}`)
)
.on("connection", serverConnection)
.on("error", serverError);
// Ejemplo de adapters de base de datos (descoméntalos si los necesitas)
// const sequelizeConn = createSequelizeAdapter();
// const firebirdConn = createFirebirdAdapter();
// Manejo de promesas no capturadas
process.on("unhandledRejection", (reason: any, promise: Promise<any>) => {
logger.error("❌ Unhandled rejection at:", promise, "reason:", reason);
// Dependiendo de la aplicación, podrías desear una salida total o un cierre controlado
process.exit(1);
});
// Manejo de excepciones no controladas
process.on("uncaughtException", (error: Error) => {
// firebirdConn.disconnect();
logger.error(`❌ Uncaught exception:`, error.message);
logger.error(error.stack);
// process.exit(1);
});
// Arranca el servidor si la conexión a la base de datos va bien
(async (app) => {
try {
const now = DateTime.now();
logger.info(`Time: ${now.toLocaleString(DateTime.DATETIME_FULL)} ${now.zoneName}`);
logger.info(`Launched in: ${now.diff(currentState.launchedAt).toMillis()} ms`);
logger.info(`Environment: ${currentState.environment}`);
logger.info(`Process PID: ${process.pid}`);
await connectToDatabase();
// Lógica de inicialización de DB, si procede:
// initStructure(sequelizeConn.connection);
// insertUsers();
initModules(app, sequelize);
server.listen(currentState.port, () => {
logger.info("To shut down your server, press <CTRL> + C at any time");
logger.info(`⚡️ Server: http://${currentState.host}:${currentState.port}`);
});
} catch (error) {
serverError(error as NodeJS.ErrnoException);
}
})(app);

View File

@ -0,0 +1,9 @@
//import { ContactsPackage } from '@modules/contacts/server';
import { invoicesModule } from "@modules/invoices";
import { registerModule } from "./core/helpers";
export const registerModules = () => {
//registerPackage(ContactsPackage);
registerModule(invoicesModule);
//registerModule();
};

16
apps/server/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node", "jest"],
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
}
},
"files": ["src/index.ts"],
"include": ["src/index.ts"],
"exclude": ["node_modules", "dist", "**/*/__tests__"]
}

5
apps/web/.eslintrc.cjs Normal file
View File

@ -0,0 +1,5 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@repo/eslint-config/vite.js"],
};

13
apps/web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
apps/web/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --clearScreen false",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint \"src/**/*.ts\""
},
"dependencies": {
"@repo/ui": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.1",
"typescript": "5.8.3",
"vite": "^6.3.3"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

1
apps/web/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

2
apps/web/src/main.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
import "./style.css";
//# sourceMappingURL=main.d.ts.map

View File

@ -0,0 +1 @@
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["main.tsx"],"names":[],"mappings":"AACA,OAAO,aAAa,CAAC"}

7
apps/web/src/main.js Normal file
View File

@ -0,0 +1,7 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { createRoot } from "react-dom/client";
import "./style.css";
import typescriptLogo from "/typescript.svg";
import { Header, Counter } from "@repo/ui";
var App = function () { return (_jsxs("div", { children: [_jsx("a", { href: "https://vitejs.dev", target: "_blank", children: _jsx("img", { src: "/vite.svg", className: "logo", alt: "Vite logo" }) }), _jsx("a", { href: "https://www.typescriptlang.org/", target: "_blank", children: _jsx("img", { src: typescriptLogo, className: "logo vanilla", alt: "TypeScript logo" }) }), _jsx(Header, { title: "Web" }), _jsx("div", { className: "card", children: _jsx(Counter, {}) })] })); };
createRoot(document.getElementById("app")).render(_jsx(App, {}));

25
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,25 @@
import { createRoot } from "react-dom/client";
import "./style.css";
import typescriptLogo from "/typescript.svg";
import { Header, Counter } from "@repo/ui";
const App = () => (
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" className="logo" alt="Vite logo" />
</a>
<a href="https://www.typescriptlang.org/" target="_blank">
<img
src={typescriptLogo}
className="logo vanilla"
alt="TypeScript logo"
/>
</a>
<Header title="Web" />
<div className="card">
<Counter />
</div>
</div>
);
createRoot(document.getElementById("app")!).render(<App />);

97
apps/web/src/style.css Normal file
View File

@ -0,0 +1,97 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #f7df1eaa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

7
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "@repo/typescript-config/vite.json",
"include": ["src"],
"compilerOptions": {
"jsx": "react-jsx"
}
}

6
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});

29
docker-compose.yml Normal file
View File

@ -0,0 +1,29 @@
version: "3"
services:
web:
container_name: web
build:
context: .
dockerfile: ./apps/web/Dockerfile
restart: always
ports:
- 3000:3000
networks:
- app_network
api:
container_name: api
build:
context: .
dockerfile: ./apps/api/Dockerfile
restart: always
ports:
- 3001:3001
networks:
- app_network
# Define a network, which allows containers to communicate
# with each other, by using their container name as a hostname
networks:
app_network:
external: true

422
docs/API.md Normal file
View File

@ -0,0 +1,422 @@
## **Diseño de API RESTful**
### **1. Autenticación y Gestión de Sesiones**
#### **1.1. Inicio de sesión**
**Endpoint:**
`POST /api/auth/login`
**Solicitud:**
```json
{
"email": "usuario@empresa.com",
"password": "contraseña_segura"
}
```
**Respuesta:**
```json
{
"token": "eyJhbGciOi...",
"user": {
"id": 123,
"username": "usuario",
"email": "usuario@empresa.com"
},
"companies": [
{ "id": 1, "name": "Empresa A" },
{ "id": 2, "name": "Empresa B" }
]
}
```
---
#### **1.2. Cierre de sesión**
**Endpoint:**
`POST /api/auth/logout`
**Encabezados:**
```
Authorization: Bearer <token>
```
**Respuesta:**
```json
{
"message": "Sesión cerrada exitosamente"
}
```
---
#### **1.3. Selección de empresa activa**
**Endpoint:**
`POST /api/auth/select-company`
**Solicitud:**
```json
{
"company_id": 1
}
```
**Respuesta:**
```json
{
"message": "Empresa seleccionada exitosamente"
}
```
---
### **2. Gestión de Empresas**
#### **2.1. Listar empresas disponibles**
**Endpoint:**
`GET /api/companies`
**Encabezados:**
```
Authorization: Bearer <token>
```
**Respuesta:**
```json
[
{
"id": 1,
"name": "Empresa A",
"country_code": "ES",
"currency_code": "EUR"
},
{ "id": 2, "name": "Empresa B", "country_code": "US", "currency_code": "USD" }
]
```
---
#### **2.2. Crear una nueva empresa**
**Endpoint:**
`POST /api/companies`
**Solicitud:**
```json
{
"name": "Nueva Empresa",
"country_code": "ES",
"currency_code": "EUR"
}
```
**Respuesta:**
```json
{
"id": 3,
"message": "Empresa creada exitosamente"
}
```
---
#### **2.3. Actualizar datos de una empresa**
**Endpoint:**
`PUT /api/companies/{company_id}`
**Solicitud:**
```json
{
"name": "Empresa Actualizada",
"currency_code": "USD"
}
```
**Respuesta:**
```json
{
"message": "Empresa actualizada correctamente"
}
```
---
#### **2.4. Eliminar una empresa**
**Endpoint:**
`DELETE /api/companies/{company_id}`
**Respuesta:**
```json
{
"message": "Empresa eliminada exitosamente"
}
```
---
### **3. Gestión de Sucursales**
#### **3.1. Listar sucursales de una empresa**
**Endpoint:**
`GET /api/companies/{company_id}/branches`
**Respuesta:**
```json
[
{ "id": 10, "name": "Sucursal Madrid" },
{ "id": 20, "name": "Sucursal Barcelona" }
]
```
---
#### **3.2. Crear una sucursal**
**Endpoint:**
`POST /api/companies/{company_id}/branches`
**Solicitud:**
```json
{
"name": "Sucursal Sevilla",
"location": "Avenida Principal 123"
}
```
**Respuesta:**
```json
{
"id": 30,
"message": "Sucursal creada exitosamente"
}
```
---
#### **3.3. Actualizar datos de una sucursal**
**Endpoint:**
`PUT /api/branches/{branch_id}`
**Solicitud:**
```json
{
"name": "Sucursal Actualizada",
"location": "Calle Nueva 456"
}
```
**Respuesta:**
```json
{
"message": "Sucursal actualizada correctamente"
}
```
---
#### **3.4. Eliminar una sucursal**
**Endpoint:**
`DELETE /api/branches/{branch_id}`
**Respuesta:**
```json
{
"message": "Sucursal eliminada exitosamente"
}
```
---
### **4. Gestión de Usuarios**
#### **4.1. Listar usuarios de una empresa**
**Endpoint:**
`GET /api/companies/{company_id}/users`
**Respuesta:**
```json
[
{
"id": 1,
"username": "admin",
"email": "admin@empresa.com",
"roles": ["Administrador"]
},
{
"id": 2,
"username": "usuario",
"email": "usuario@empresa.com",
"roles": ["Ventas"]
}
]
```
---
#### **4.2. Crear un usuario**
**Endpoint:**
`POST /api/users`
**Solicitud:**
```json
{
"username": "nuevo_usuario",
"email": "nuevo@empresa.com",
"password": "clave_segura",
"company_id": 1,
"roles": ["Ventas"]
}
```
**Respuesta:**
```json
{
"id": 3,
"message": "Usuario creado exitosamente"
}
```
---
#### **4.3. Actualizar usuario**
**Endpoint:**
`PUT /api/users/{user_id}`
**Solicitud:**
```json
{
"email": "nuevo_correo@empresa.com",
"roles": ["Compras"]
}
```
**Respuesta:**
```json
{
"message": "Usuario actualizado correctamente"
}
```
---
#### **4.4. Eliminar usuario**
**Endpoint:**
`DELETE /api/users/{user_id}`
**Respuesta:**
```json
{
"message": "Usuario eliminado exitosamente"
}
```
---
### **5. Gestión de Permisos**
#### **5.1. Obtener permisos de un usuario en una empresa**
**Endpoint:**
`GET /api/companies/{company_id}/users/{user_id}/permissions`
**Respuesta:**
```json
{
"modules": {
"Presupuestos": ["read", "write"],
"Facturas": ["read"],
"Clientes": ["read", "write", "delete"]
}
}
```
---
#### **5.2. Actualizar permisos de un usuario**
**Endpoint:**
`PUT /api/users/{user_id}/permissions`
**Solicitud:**
```json
{
"modules": {
"Presupuestos": ["read", "write"],
"Inventario": ["read"]
}
}
```
**Respuesta:**
```json
{
"message": "Permisos actualizados correctamente"
}
```
---
### **6. Auditoría**
#### **6.1. Obtener historial de cambios en documentos**
**Endpoint:**
`GET /api/audit/documents/{document_id}`
**Respuesta:**
```json
[
{
"timestamp": "2025-01-22T14:00:00Z",
"user": "admin",
"change": "Se modificó el precio de 100 a 120"
},
{
"timestamp": "2025-01-20T10:30:00Z",
"user": "usuario",
"change": "Se creó el documento"
}
]
```

Binary file not shown.

84
docs/README.md Normal file
View File

@ -0,0 +1,84 @@
# Turborepo starter
This Turborepo starter is maintained by the Turborepo core team.
## Using this example
Run the following command:
```sh
npx create-turbo@latest
```
## What's inside?
This Turborepo includes the following packages/apps:
### Apps and Packages
- `docs`: a [Next.js](https://nextjs.org/) app
- `web`: another [Next.js](https://nextjs.org/) app
- `@repo/ui`: a stub React component library shared by both `web` and `docs` applications
- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
### Utilities
This Turborepo has some additional tools already setup for you:
- [TypeScript](https://www.typescriptlang.org/) for static type checking
- [ESLint](https://eslint.org/) for code linting
- [Prettier](https://prettier.io) for code formatting
### Build
To build all apps and packages, run the following command:
```
cd my-turborepo
pnpm build
```
### Develop
To develop all apps and packages, run the following command:
```
cd my-turborepo
pnpm dev
```
### Remote Caching
> [!TIP]
> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache).
Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines.
By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands:
```
cd my-turborepo
npx turbo login
```
This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
```
npx turbo link
```
## Useful Links
Learn more about the power of Turborepo:
- [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks)
- [Caching](https://turbo.build/repo/docs/core-concepts/caching)
- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching)
- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering)
- [Configuration Options](https://turbo.build/repo/docs/reference/configuration)
- [CLI Usage](https://turbo.build/repo/docs/reference/command-line-reference)

165
docs/REQUISITOS CLIENTES.md Normal file
View File

@ -0,0 +1,165 @@
# **Especificaciones del Módulo de Clientes - ERP**
El **módulo de clientes** del ERP permite gestionar la información de clientes, asegurando compatibilidad con la gestión multiempresa, multiidioma y multi-moneda. A continuación, se detallan las especificaciones, incluyendo los campos clave y su función.
---
## **1. Funcionalidades del Módulo de Clientes**
El módulo de clientes debe permitir:
- **Crear, leer, actualizar y eliminar clientes** (CRUD).
- **Asociar clientes a una empresa** y, opcionalmente, a una o más sucursales.
- **Registrar clientes como personas físicas o empresas**.
- **Gestionar datos fiscales, comerciales y de contacto**.
- **Definir configuraciones de facturación y pago personalizadas**.
- **Aplicar descuentos en distintos niveles**.
- **Manejar aspectos financieros como retenciones y recargos**.
- **Identificar clientes con riesgo financiero**.
- **Asignar comerciales o delegados**.
- **Controlar el estado del cliente** (activo/inactivo).
- **Registrar auditoría de cambios**.
---
## **2. Estructura del Cliente y sus Campos**
Cada cliente tiene los siguientes atributos:
### **Datos Generales**
| Campo | Tipo de Dato | Descripción |
|-----------------------|-----------------|-------------|
| `id` | `INT` (PK) | Identificador único del cliente. |
| `company_id` | `INT` (FK) | Empresa a la que pertenece el cliente. |
| `is_company` | `BOOLEAN` | Indica si el cliente es una empresa (`true`) o una persona física (`false`). |
| `fiscal_name` | `VARCHAR(255)` | Nombre fiscal del cliente (solo si es empresa). |
| `commercial_name` | `VARCHAR(255)` | Nombre comercial del cliente (si aplica). |
| `name` | `VARCHAR(255)` | Nombre de la persona o empresa. |
| `email` | `VARCHAR(100)` | Correo electrónico único del cliente. |
| `phone` | `VARCHAR(20)` | Número de teléfono de contacto. |
| `address` | `TEXT` | Dirección del cliente. |
| `country_code` | `CHAR(2)` | Código del país del cliente (ISO 3166-1 alpha-2). |
| `origin` | `VARCHAR(100)` | Origen del cliente (ej. publicidad, redes sociales, feria, escaparate). |
### **Configuración de Moneda e Idioma**
| Campo | Tipo de Dato | Descripción |
|---------------------|-----------------|-------------|
| `currency_code` | `CHAR(3)` | Código de la divisa con la que trabaja el cliente (ISO 4217). |
| `language_code` | `CHAR(5)` | Idioma preferido del cliente para documentos y comunicación (ej. `es-ES`, `en-US`). |
### **Información Fiscal y Financiera**
| Campo | Tipo de Dato | Descripción |
|------------------------|----------------|-------------|
| `vat_percentage` | `DECIMAL(5,2)` | Porcentaje de IVA aplicable al cliente. |
| `equivalence_charge` | `BOOLEAN` | Indica si el cliente aplica recargo de equivalencia. |
| `withholding_percentage` | `DECIMAL(5,2)` | Porcentaje de retención en facturas del cliente. |
| `payment_method` | `VARCHAR(100)` | Forma de pago habitual del cliente (ej. transferencia, tarjeta, efectivo). |
| `payment_day` | `INT` | Día del mes en que el cliente realiza pagos (1-31). |
| `risk` | `BOOLEAN` | Indica si el cliente tiene riesgo financiero o historial de morosidad. |
### **Descuentos y Tarifas Especiales**
| Campo | Tipo de Dato | Descripción |
|---------------------|----------------|-------------|
| `discount_line` | `DECIMAL(5,2)` | Descuento aplicado a nivel de línea en presupuestos. |
| `discount_chapter` | `DECIMAL(5,2)` | Descuento aplicado a nivel de capítulo en presupuestos. |
| `discount_global` | `DECIMAL(5,2)` | Descuento global aplicado al presupuesto. |
| `price_point` | `DECIMAL(10,2)` | Valor de "precio punto" del cliente, usado para calcular costos personalizados en catálogo. |
### **Clasificación y Estado**
| Campo | Tipo de Dato | Descripción |
|---------------|----------------|-------------|
| `client_type` | `VARCHAR(100)` | Tipo de cliente (ej. profesionales, constructoras, distribuidores, particulares). |
| `is_active` | `BOOLEAN` | Indica si el cliente está activo (`true`) o dado de baja (`false`). |
### **Delegados y Comerciales**
| Campo | Tipo de Dato | Descripción |
|-----------------|----------------|-------------|
| `Client_Sales_Rep` | `Tabla Relacional` | Relación con los comerciales asignados a este cliente. |
---
## **3. Auditoría y Seguridad**
Cada cambio en los datos de un cliente debe registrarse en una tabla de auditoría:
```sql
CREATE TABLE Client_Audit (
id SERIAL PRIMARY KEY,
client_id INT NOT NULL REFERENCES Clients(id) ON DELETE CASCADE,
user_id INT NOT NULL REFERENCES Users(id),
change_description TEXT,
change_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
Esto permitirá rastrear modificaciones y garantizar transparencia en la gestión de clientes.
---
## **4. Endpoints Principales de la API**
### **4.1. Listar Clientes**
```http
GET /api/companies/{company_id}/clients
Authorization: Bearer <token>
```
### **4.2. Crear Cliente**
```http
POST /api/companies/{company_id}/clients
Authorization: Bearer <token>
```
**Solicitud:**
```json
{
"is_company": true,
"fiscal_name": "Empresa XYZ SL",
"commercial_name": "XYZ Comercial",
"name": "Empresa XYZ",
"email": "contacto@xyz.com",
"phone": "+34912345678",
"country_code": "ES",
"origin": "Publicidad",
"currency_code": "EUR",
"language_code": "es-ES",
"vat_percentage": 21.00,
"payment_method": "Transferencia bancaria",
"discount_line": 5.00,
"discount_chapter": 2.50,
"discount_global": 10.00,
"price_point": 1.20,
"payment_day": 15,
"risk": false,
"equivalence_charge": false,
"withholding_percentage": 10.00,
"client_type": "Distribuidor",
"is_active": true
}
```
### **4.3. Obtener Cliente por ID**
```http
GET /api/clients/{client_id}
Authorization: Bearer <token>
```
### **4.4. Actualizar Cliente**
```http
PUT /api/clients/{client_id}
Authorization: Bearer <token>
```
### **4.5. Baja Lógica de Cliente**
```http
PATCH /api/clients/{client_id}/deactivate
Authorization: Bearer <token>
```
### **4.6. Asignar Comercial a Cliente**
```http
POST /api/clients/{client_id}/sales-reps
Authorization: Bearer <token>
```
---
## **5. Seguridad y Control de Acceso**
- **Middleware de autenticación** para validar que solo usuarios autorizados gestionen clientes.
- **Permisos basados en roles**, como `Clientes: leer`, `Clientes: escribir`, `Clientes: eliminar`.
- **Reglas de validación** para evitar datos erróneos o incompletos.
- **Registro de auditoría** para cada modificación.
---

View File

@ -0,0 +1,207 @@
# **Requisitos Técnicos del ERP**
## **1. Administración de Usuarios**
- **Roles y Perfiles**:
- Los usuarios pueden tener uno o más perfiles predefinidos (por ejemplo, "Ventas", "Compras").
- Los perfiles controlan permisos generales a nivel de empresa y refinados a nivel de sucursal.
- Un usuario puede tener diferentes perfiles en distintas sucursales de la misma empresa.
- **Permisos**:
- Configurables por módulo (Presupuestos, Facturas, etc.).
- Granularidad: permitir acciones específicas como "leer", "escribir", "eliminar".
- Un usuario puede tener permisos para acceder a una o más sucursales de la empresa, pero debe trabajar en una empresa activa por sesión.
- Si el usuario solo tiene permisos para una sucursal, se selecciona automáticamente. Si tiene acceso a varias, se selecciona solo cuando sea necesario.
- **Superadministrador**:
- Puede crear, modificar y eliminar usuarios.
- Gestiona perfiles, permisos y asociaciones con empresas y sucursales.
---
## **2. Gestión Multiempresa y Multisucursal**
- **Usuarios y Empresas**:
- Un usuario puede estar asociado a múltiples empresas.
- En cada sesión o pestaña, el usuario puede trabajar con una empresa específica.
- La empresa activa se elige al inicio de sesión.
- **Sucursales**:
- Cada empresa debe tener al menos una sucursal, pero puede tener varias.
- Los usuarios de una sucursal pueden gestionar datos de otras sucursales si tienen los permisos adecuados.
- Se permite definir permisos para operar en múltiples sucursales dentro de la misma empresa.
- **Flujo de Selección**:
- Al iniciar sesión, el usuario elige la empresa activa.
- Si tiene acceso a una única sucursal, esta se selecciona automáticamente.
- Si tiene acceso a múltiples sucursales, la selección de sucursal ocurre solo cuando sea necesario para una operación específica.
---
## **3. Soporte Multiidioma y Regionalización**
- **Idiomas**:
- La interfaz del ERP debe estar disponible en varios idiomas.
- Los documentos (presupuestos, facturas) pueden generarse en el idioma del cliente.
- El idioma de usuario es configurable en su perfil.
- **Monedas e Impuestos**:
- Configuración de moneda por empresa, con posibilidad de conversión.
- Definición de impuestos según país (IVA, retenciones, tasas locales).
- Flexibilidad para configurar tarifas por categoría de productos o servicios.
---
## **4. Contexto de Sesión y Pestaña**
- **Contexto por Pestaña**:
- Cada pestaña tiene un contexto independiente (`tab_id`), que incluye la empresa activa.
- Si se requiere una sucursal para la operación, se solicitará en ese momento.
- Se almacena el `tab_id` junto con la empresa activa en el backend para cada sesión de usuario.
- **Gestión de Sesiones**:
- Tokens JWT deben incluir información sobre:
- `user_id`
- `tab_id`
- `active_company_id`
- Lista de permisos específicos
- Se debe registrar el contexto de pestaña en la base de datos para auditoría y trazabilidad.
---
## **5. Auditoría y Registro de Actividades**
- **Registro de Cambios en Documentos**:
- Presupuestos, facturas y otros documentos deben mantener un historial detallado de modificaciones.
- Se registra la siguiente información:
- Usuario que realizó el cambio.
- Fecha y hora.
- Cambios específicos (antes y después).
- **Trazabilidad Global**:
- Auditar las actividades del usuario a nivel de empresa, sucursal y pestaña.
- Se debe asociar cada acción al `tab_id` para identificar actividades por pestaña específica.
---
## **6. Documentos Operativos**
- **Historial de Cambios**:
- Cada documento (presupuestos, facturas, etc.) debe registrar todas las modificaciones con la posibilidad de revertirlas si es necesario.
- **Exportación Multiidioma**:
- Generación de documentos en diferentes idiomas según la configuración del cliente.
---
## **7. Seguridad**
- **Autenticación**:
- Autenticación basada en tokens JWT.
- Contraseñas encriptadas con bcrypt o Argon2.
- Soporte para autenticación en dos pasos (2FA) en futuras iteraciones.
- **Control de Acceso**:
- Validación de permisos en cada solicitud al backend.
- Aplicación del principio de mínimos privilegios para usuarios.
---
## **8. Extensibilidad y Modularidad**
- **Estructura Modular**:
- El sistema debe permitir la integración de nuevos módulos sin afectar los permisos o el funcionamiento existente.
- Módulos actuales planificados:
- Presupuestos
- Facturas
- Clientes
- Pedidos
- Inventario (futuro)
- Recursos Humanos (futuro)
---
## **9. Base de Datos - Diseño Técnico**
### **Tablas Principales**
```sql
-- Usuarios
CREATE TABLE Users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
```sql
-- Empresas
CREATE TABLE Companies (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
country_code CHAR(2),
currency_code CHAR(3),
tax_config JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
```sql
-- Sucursales
CREATE TABLE Branches (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
company_id INT NOT NULL REFERENCES Companies(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
```sql
-- Relación Usuario - Empresa
CREATE TABLE User_Company (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES Users(id),
company_id INT NOT NULL REFERENCES Companies(id),
is_active BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
```sql
-- Relación Usuario - Sucursal
CREATE TABLE User_Branch (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES Users(id),
branch_id INT NOT NULL REFERENCES Branches(id),
permissions JSONB NOT NULL
);
```
```sql
-- Contexto por pestaña
CREATE TABLE Tab_Context (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES Users(id),
tab_id UUID NOT NULL,
company_id INT NOT NULL REFERENCES Companies(id),
branch_id INT REFERENCES Branches(id)
);
```
---

View File

@ -0,0 +1,81 @@
{
"name": "@modules/invoices",
"version": "0.0.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
"clean": "rm -rf dist && rm -rf node_modules",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"typecheck": "tsc --noEmit",
"test": "jest"
},
"jest": {
"preset": "@repo/jest-presets/node"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@repo/eslint-config": "workspace:*",
"@repo/jest-presets": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/bcrypt": "^5.0.2",
"@types/body-parser": "^1.19.5",
"@types/cors": "^2.8.17",
"@types/dinero.js": "^1.9.4",
"@types/express": "^4.17.21",
"@types/glob": "^8.1.0",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.9",
"@types/luxon": "^3.6.2",
"@types/morgan": "^1.9.9",
"@types/node": "^22.15.2",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/response-time": "^2.3.8",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"esbuild": "^0.25.3",
"esbuild-register": "^3.6.0",
"eslint": "^9.25.1",
"jest": "^29.7.0",
"nodemon": "^3.1.10",
"supertest": "^7.1.0",
"tsc-alias": "^1.8.15",
"typescript": "5.8.3"
},
"dependencies": {
"@rdx/core": "workspace:*",
"@rdx/ddd-domain": "workspace:*",
"@rdx/logger": "workspace:*",
"@rdx/modules": "workspace:*",
"@rdx/utils": "workspace:*",
"bcrypt": "^5.1.1",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dinero.js": "^1.9.1",
"dotenv": "^16.5.0",
"express": "^4.21.2",
"helmet": "^8.1.0",
"http-status": "^2.1.0",
"jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.11.20",
"luxon": "^3.5.0",
"module-alias": "^2.2.3",
"mysql2": "^3.12.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"path": "^0.12.7",
"reflect-metadata": "^0.2.2",
"response-time": "^2.3.3",
"sequelize": "^6.37.7",
"zod": "^3.24.3"
}
}

View File

@ -0,0 +1 @@
export default () => {};

View File

@ -0,0 +1,2 @@
//export * from "./client";
export * from "./server";

View File

@ -0,0 +1,141 @@
import { ITransactionManager } from "@rdx/core";
import { UniqueID, UtcDate } from "@rdx/ddd-domain";
import { logger } from "@rdx/logger";
import { Result } from "@rdx/utils";
import { IInvoiceService, Invoice } from "../domain";
import { IInvoiceProps, InvoiceNumber, InvoiceSerie, InvoiceStatus } from "../domain";
import { ICreateInvoiceRequestDTO } from "../presentation";
export class CreateInvoiceUseCase {
constructor(
private readonly invoiceService: IInvoiceService,
private readonly transactionManager: ITransactionManager
) {}
public execute(
invoiceID: UniqueID,
dto: ICreateInvoiceRequestDTO
): Promise<Result<Invoice, Error>> {
return this.transactionManager.complete(async (transaction) => {
try {
const validOrErrors = this.validateInvoiceData(dto);
if (validOrErrors.isFailure) {
return Result.fail(validOrErrors.error);
}
const data = validOrErrors.data;
// Update invoice with dto
return await this.invoiceService.createInvoice(invoiceID, data, transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
});
}
private validateInvoiceData(dto: ICreateInvoiceRequestDTO): Result<IInvoiceProps, Error> {
const errors: Error[] = [];
const invoiceNumerOrError = InvoiceNumber.create(dto.invoice_number);
const invoiceSeriesOrError = InvoiceSerie.create(dto.invoice_series);
const issueDateOrError = UtcDate.create(dto.issue_date);
const operationDateOrError = UtcDate.create(dto.operation_date);
const result = Result.combine([
invoiceNumerOrError,
invoiceSeriesOrError,
issueDateOrError,
operationDateOrError,
]);
if (result.isFailure) {
return Result.fail(result.error);
}
const validatedData: IInvoiceProps = {
status: InvoiceStatus.createDraft(),
invoiceNumber: invoiceNumerOrError.data,
invoiceSeries: invoiceSeriesOrError.data,
issueDate: issueDateOrError.data,
operationDate: operationDateOrError.data,
invoiceCurrency: dto.currency,
};
/*if (errors.length > 0) {
const message = errors.map((err) => err.message).toString();
return Result.fail(new Error(message));
}*/
return Result.ok(validatedData);
/*let invoice_status = InvoiceStatus.create(dto.status).object;
if (invoice_status.isEmpty()) {
invoice_status = InvoiceStatus.createDraft();
}
let invoice_series = InvoiceSeries.create(dto.invoice_series).object;
if (invoice_series.isEmpty()) {
invoice_series = InvoiceSeries.create(dto.invoice_series).object;
}
let issue_date = InvoiceDate.create(dto.issue_date).object;
if (issue_date.isEmpty()) {
issue_date = InvoiceDate.createCurrentDate().object;
}
let operation_date = InvoiceDate.create(dto.operation_date).object;
if (operation_date.isEmpty()) {
operation_date = InvoiceDate.createCurrentDate().object;
}
let invoiceCurrency = Currency.createFromCode(dto.currency).object;
if (invoiceCurrency.isEmpty()) {
invoiceCurrency = Currency.createDefaultCode().object;
}
let invoiceLanguage = Language.createFromCode(dto.language_code).object;
if (invoiceLanguage.isEmpty()) {
invoiceLanguage = Language.createDefaultCode().object;
}
const items = new Collection<InvoiceItem>(
dto.items?.map(
(item) =>
InvoiceSimpleItem.create({
description: Description.create(item.description).object,
quantity: Quantity.create(item.quantity).object,
unitPrice: UnitPrice.create({
amount: item.unit_price.amount,
currencyCode: item.unit_price.currency,
precision: item.unit_price.precision,
}).object,
}).object
)
);
if (!invoice_status.isDraft()) {
throw Error("Error al crear una factura que no es borrador");
}
return DraftInvoice.create(
{
invoiceSeries: invoice_series,
issueDate: issue_date,
operationDate: operation_date,
invoiceCurrency,
language: invoiceLanguage,
invoiceNumber: InvoiceNumber.create(undefined).object,
//notes: Note.create(invoiceDTO.notes).object,
//senderId: UniqueID.create(null).object,
recipient,
items,
},
invoiceId
);*/
}
}

View File

@ -0,0 +1,23 @@
import { ITransactionManager } from "@rdx/core";
import { UniqueID } from "@rdx/ddd-domain";
import { logger } from "@rdx/logger";
import { Result } from "@rdx/utils";
import { IInvoiceService } from "../domain";
export class DeleteInvoiceUseCase {
constructor(
private readonly invoiceService: IInvoiceService,
private readonly transactionManager: ITransactionManager
) {}
public execute(invoiceID: UniqueID): Promise<Result<Boolean, Error>> {
return this.transactionManager.complete(async (transaction) => {
try {
return await this.invoiceService.deleteInvoiceById(invoiceID, transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,23 @@
import { ITransactionManager } from "@rdx/core";
import { UniqueID } from "@rdx/ddd-domain";
import { logger } from "@rdx/logger";
import { Result } from "@rdx/utils";
import { IInvoiceService, Invoice } from "../domain";
export class GetInvoiceUseCase {
constructor(
private readonly invoiceService: IInvoiceService,
private readonly transactionManager: ITransactionManager
) {}
public execute(invoiceID: UniqueID): Promise<Result<Invoice, Error>> {
return this.transactionManager.complete(async (transaction) => {
try {
return await this.invoiceService.findInvoiceById(invoiceID, transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,5 @@
export * from "./create-invoice.use-case";
export * from "./delete-invoice.use-case";
export * from "./get-invoice.use-case";
export * from "./list-invoices.use-case";
//export * from "./update-invoice.use-case";

View File

@ -0,0 +1,22 @@
import { ITransactionManager } from "@rdx/core";
import { logger } from "@rdx/logger";
import { Collection, Result } from "@rdx/utils";
import { IInvoiceService, Invoice } from "../domain";
export class ListInvoicesUseCase {
constructor(
private readonly invoiceService: IInvoiceService,
private readonly transactionManager: ITransactionManager
) {}
public execute(): Promise<Result<Collection<Invoice>, Error>> {
return this.transactionManager.complete(async (transaction) => {
try {
return await this.invoiceService.findInvoices(transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,70 @@
import {
ApplicationServiceError,
IApplicationServiceError,
} from "@/contexts/common/application/services/ApplicationServiceError";
import { IAdapter, RepositoryBuilder } from "@/contexts/common/domain";
import { Result, UniqueID } from "@shared/contexts";
import { NullOr } from "@shared/utilities";
import {
IInvoiceParticipantAddress,
IInvoiceParticipantAddressRepository,
} from "../../domain";
export const participantAddressFinder = async (
addressId: UniqueID,
adapter: IAdapter,
repository: RepositoryBuilder<IInvoiceParticipantAddressRepository>,
) => {
if (addressId.isNull()) {
return Result.fail<IApplicationServiceError>(
ApplicationServiceError.create(
ApplicationServiceError.INVALID_REQUEST_PARAM,
`Participant address ID required`,
),
);
}
const transaction = adapter.startTransaction();
let address: NullOr<IInvoiceParticipantAddress> = null;
try {
await transaction.complete(async (t) => {
address = await repository({ transaction: t }).getById(addressId);
});
if (address === null) {
return Result.fail<IApplicationServiceError>(
ApplicationServiceError.create(
ApplicationServiceError.NOT_FOUND_ERROR,
"",
{
id: addressId.toString(),
entity: "participant address",
},
),
);
}
return Result.ok<IInvoiceParticipantAddress>(address);
} catch (error: unknown) {
const _error = error as Error;
if (repository().isRepositoryError(_error)) {
return Result.fail<IApplicationServiceError>(
ApplicationServiceError.create(
ApplicationServiceError.REPOSITORY_ERROR,
_error.message,
_error,
),
);
}
return Result.fail<IApplicationServiceError>(
ApplicationServiceError.create(
ApplicationServiceError.UNEXCEPTED_ERROR,
_error.message,
_error,
),
);
}
};

View File

@ -0,0 +1,20 @@
import { IAdapter, RepositoryBuilder } from "@/contexts/common/domain";
import { UniqueID } from "@shared/contexts";
import { IInvoiceParticipantRepository } from "../../domain";
import { InvoiceCustomer } from "../../domain/entities/invoice-customer/invoice-customer";
export const participantFinder = async (
participantId: UniqueID,
adapter: IAdapter,
repository: RepositoryBuilder<IInvoiceParticipantRepository>
): Promise<InvoiceCustomer | undefined> => {
if (!participantId || (participantId && participantId.isNull())) {
return Promise.resolve(undefined);
}
const participant = await adapter
.startTransaction()
.complete((t) => repository({ transaction: t }).getById(participantId));
return Promise.resolve(participant ? participant : undefined);
};

View File

@ -0,0 +1,400 @@
import { ITransactionManager } from "@rdx/core";
import { UniqueID } from "@rdx/ddd-domain";
import { logger } from "@rdx/logger";
import { Collection, Result } from "@rdx/utils";
import { IInvoiceService, Invoice, InvoiceItem } from "../domain";
import { IInvoiceProps, InvoiceNumber, InvoiceStatus } from "../domain";
import { IUpdateInvoiceRequestDTO } from "../presentation/dto";
export class UpdateInvoiceUseCase {
constructor(
private readonly invoiceService: IInvoiceService,
private readonly transactionManager: ITransactionManager
) {}
public execute(
invoiceID: UniqueID,
dto: Partial<IUpdateInvoiceRequestDTO>
): Promise<Result<Invoice, Error>> {
return this.transactionManager.complete(async (transaction) => {
try {
const validOrErrors = this.validateInvoiceData(dto);
if (validOrErrors.isFailure) {
return Result.fail(validOrErrors.error);
}
const data = validOrErrors.data;
// Update invoice with dto
return await this.invoiceService.updateInvoiceById(invoiceID, data, transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
});
}
private validateInvoiceData(
dto: Partial<IUpdateInvoiceRequestDTO>
): Result<Partial<IInvoiceProps>, Error> {
const errors: Error[] = [];
const validatedData: Partial<IInvoiceProps> = {};
// Create invoice
let invoice_status = InvoiceStatus.create(invoiceDTO.status).object;
if (invoice_status.isEmpty()) {
invoice_status = InvoiceStatus.createDraft();
}
let invoice_series = InvoiceSeries.create(invoiceDTO.invoice_series).object;
if (invoice_series.isEmpty()) {
invoice_series = InvoiceSeries.create(invoiceDTO.invoice_series).object;
}
let issue_date = InvoiceDate.create(invoiceDTO.issue_date).object;
if (issue_date.isEmpty()) {
issue_date = InvoiceDate.createCurrentDate().object;
}
let operation_date = InvoiceDate.create(invoiceDTO.operation_date).object;
if (operation_date.isEmpty()) {
operation_date = InvoiceDate.createCurrentDate().object;
}
let invoiceCurrency = Currency.createFromCode(invoiceDTO.currency).object;
if (invoiceCurrency.isEmpty()) {
invoiceCurrency = Currency.createDefaultCode().object;
}
let invoiceLanguage = Language.createFromCode(invoiceDTO.language_code).object;
if (invoiceLanguage.isEmpty()) {
invoiceLanguage = Language.createDefaultCode().object;
}
const items = new Collection<InvoiceItem>(
invoiceDTO.items?.map(
(item) =>
InvoiceSimpleItem.create({
description: Description.create(item.description).object,
quantity: Quantity.create(item.quantity).object,
unitPrice: UnitPrice.create({
amount: item.unit_price.amount,
currencyCode: item.unit_price.currency,
precision: item.unit_price.precision,
}).object,
}).object
)
);
if (!invoice_status.isDraft()) {
throw Error("Error al crear una factura que no es borrador");
}
return DraftInvoice.create(
{
invoiceSeries: invoice_series,
issueDate: issue_date,
operationDate: operation_date,
invoiceCurrency,
language: invoiceLanguage,
invoiceNumber: InvoiceNumber.create(undefined).object,
//notes: Note.create(invoiceDTO.notes).object,
//senderId: UniqueID.create(null).object,
recipient,
items,
},
invoiceId
);
}
}
export type UpdateInvoiceResponseOrError =
| Result<never, IUseCaseError> // Misc errors (value objects)
| Result<Invoice, never>; // Success!
export class UpdateInvoiceUseCase2
implements
IUseCase<{ id: UniqueID; data: IUpdateInvoice_DTO }, Promise<UpdateInvoiceResponseOrError>>
{
private _context: IInvoicingContext;
private _adapter: ISequelizeAdapter;
private _repositoryManager: IRepositoryManager;
constructor(context: IInvoicingContext) {
this._context = context;
this._adapter = context.adapter;
this._repositoryManager = context.repositoryManager;
}
private getRepository<T>(name: string) {
return this._repositoryManager.getRepository<T>(name);
}
private handleValidationFailure(
validationError: Error,
message?: string
): Result<never, IUseCaseError> {
return Result.fail<IUseCaseError>(
UseCaseError.create(
UseCaseError.INVALID_INPUT_DATA,
message ? message : validationError.message,
validationError
)
);
}
async execute(request: {
id: UniqueID;
data: IUpdateInvoice_DTO;
}): Promise<UpdateInvoiceResponseOrError> {
const { id, data: invoiceDTO } = request;
// Validaciones
const invoiceDTOOrError = ensureUpdateInvoice_DTOIsValid(invoiceDTO);
if (invoiceDTOOrError.isFailure) {
return this.handleValidationFailure(invoiceDTOOrError.error);
}
const transaction = this._adapter.startTransaction();
const invoiceRepoBuilder = this.getRepository<IInvoiceRepository>("Invoice");
let invoice: Invoice | null = null;
try {
await transaction.complete(async (t) => {
invoice = await invoiceRepoBuilder({ transaction: t }).getById(id);
});
if (invoice === null) {
return Result.fail<IUseCaseError>(
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, `Invoice not found`, {
id: request.id.toString(),
entity: "invoice",
})
);
}
return Result.ok<Invoice>(invoice);
} catch (error: unknown) {
const _error = error as Error;
if (invoiceRepoBuilder().isRepositoryError(_error)) {
return this.handleRepositoryError(error as BaseError, invoiceRepoBuilder());
} else {
return this.handleUnexceptedError(error);
}
}
// Recipient validations
/*const recipientIdOrError = ensureParticipantIdIsValid(
invoiceDTO?.recipient?.id,
);
if (recipientIdOrError.isFailure) {
return this.handleValidationFailure(
recipientIdOrError.error,
"Recipient ID not valid",
);
}
const recipientId = recipientIdOrError.object;
const recipientBillingIdOrError = ensureParticipantAddressIdIsValid(
invoiceDTO?.recipient?.billing_address_id,
);
if (recipientBillingIdOrError.isFailure) {
return this.handleValidationFailure(
recipientBillingIdOrError.error,
"Recipient billing address ID not valid",
);
}
const recipientBillingId = recipientBillingIdOrError.object;
const recipientShippingIdOrError = ensureParticipantAddressIdIsValid(
invoiceDTO?.recipient?.shipping_address_id,
);
if (recipientShippingIdOrError.isFailure) {
return this.handleValidationFailure(
recipientShippingIdOrError.error,
"Recipient shipping address ID not valid",
);
}
const recipientShippingId = recipientShippingIdOrError.object;
const recipientContact = await this.findContact(
recipientId,
recipientBillingId,
recipientShippingId,
);
if (!recipientContact) {
return this.handleValidationFailure(
new Error(`Recipient with ID ${recipientId.toString()} does not exist`),
);
}
// Crear invoice
const invoiceOrError = await this.tryUpdateInvoiceInstance(
invoiceDTO,
invoiceIdOrError.object,
//senderId,
//senderBillingId,
//senderShippingId,
recipientContact,
);
if (invoiceOrError.isFailure) {
const { error: domainError } = invoiceOrError;
let errorCode = "";
let message = "";
switch (domainError.code) {
case Invoice.ERROR_CUSTOMER_WITHOUT_NAME:
errorCode = UseCaseError.INVALID_INPUT_DATA;
message =
"El cliente debe ser una compañía o tener nombre y apellidos.";
break;
default:
errorCode = UseCaseError.UNEXCEPTED_ERROR;
message = "";
break;
}
return Result.fail<IUseCaseError>(
UseCaseError.create(errorCode, message, domainError),
);
}
return this.saveInvoice(invoiceOrError.object);
*/
}
private async tryUpdateInvoiceInstance(invoiceDTO, invoiceId, recipient) {
// Create invoice
let invoice_status = InvoiceStatus.create(invoiceDTO.status).object;
if (invoice_status.isEmpty()) {
invoice_status = InvoiceStatus.createDraft();
}
let invoice_series = InvoiceSeries.create(invoiceDTO.invoice_series).object;
if (invoice_series.isEmpty()) {
invoice_series = InvoiceSeries.create(invoiceDTO.invoice_series).object;
}
let issue_date = InvoiceDate.create(invoiceDTO.issue_date).object;
if (issue_date.isEmpty()) {
issue_date = InvoiceDate.createCurrentDate().object;
}
let operation_date = InvoiceDate.create(invoiceDTO.operation_date).object;
if (operation_date.isEmpty()) {
operation_date = InvoiceDate.createCurrentDate().object;
}
let invoiceCurrency = Currency.createFromCode(invoiceDTO.currency).object;
if (invoiceCurrency.isEmpty()) {
invoiceCurrency = Currency.createDefaultCode().object;
}
let invoiceLanguage = Language.createFromCode(invoiceDTO.language_code).object;
if (invoiceLanguage.isEmpty()) {
invoiceLanguage = Language.createDefaultCode().object;
}
const items = new Collection<InvoiceItem>(
invoiceDTO.items?.map(
(item) =>
InvoiceSimpleItem.create({
description: Description.create(item.description).object,
quantity: Quantity.create(item.quantity).object,
unitPrice: UnitPrice.create({
amount: item.unit_price.amount,
currencyCode: item.unit_price.currency,
precision: item.unit_price.precision,
}).object,
}).object
)
);
if (!invoice_status.isDraft()) {
throw Error("Error al crear una factura que no es borrador");
}
return DraftInvoice.create(
{
invoiceSeries: invoice_series,
issueDate: issue_date,
operationDate: operation_date,
invoiceCurrency,
language: invoiceLanguage,
invoiceNumber: InvoiceNumber.create(undefined).object,
//notes: Note.create(invoiceDTO.notes).object,
//senderId: UniqueID.create(null).object,
recipient,
items,
},
invoiceId
);
}
private async findContact(
contactId: UniqueID,
billingAddressId: UniqueID,
shippingAddressId: UniqueID
) {
const contactRepoBuilder = this.getRepository<IContactRepository>("Contact");
const contact = await contactRepoBuilder().getById2(
contactId,
billingAddressId,
shippingAddressId
);
return contact;
}
private async saveInvoice(invoice: DraftInvoice) {
const transaction = this._adapter.startTransaction();
const invoiceRepoBuilder = this.getRepository<IInvoiceRepository>("Invoice");
try {
await transaction.complete(async (t) => {
const invoiceRepo = invoiceRepoBuilder({ transaction: t });
await invoiceRepo.save(invoice);
});
return Result.ok<DraftInvoice>(invoice);
} catch (error: unknown) {
const _error = error as Error;
if (invoiceRepoBuilder().isRepositoryError(_error)) {
return this.handleRepositoryError(error as BaseError, invoiceRepoBuilder());
} else {
return this.handleUnexceptedError(error);
}
}
}
private handleUnexceptedError(error): Result<never, IUseCaseError> {
return Result.fail<IUseCaseError>(
UseCaseError.create(UseCaseError.UNEXCEPTED_ERROR, error.message, error)
);
}
private handleRepositoryError(
error: BaseError,
repository: IInvoiceRepository
): Result<never, IUseCaseError> {
const { message, details } = repository.handleRepositoryError(error);
return Result.fail<IUseCaseError>(
UseCaseError.create(UseCaseError.REPOSITORY_ERROR, message, details)
);
}
}

View File

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

View File

@ -0,0 +1,203 @@
import { AggregateRoot, MoneyValue, UniqueID, UtcDate } from "@rdx/ddd-domain";
import { Collection, Result } from "@rdx/utils";
import { InvoiceCustomer, InvoiceItem, InvoiceItems } from "../entities";
import { InvoiceNumber, InvoiceSerie, InvoiceStatus } from "../value-objects";
export interface IInvoiceProps {
invoiceNumber: InvoiceNumber;
invoiceSeries: InvoiceSerie;
status: InvoiceStatus;
issueDate: UtcDate;
operationDate: UtcDate;
//dueDate: UtcDate; // ? --> depende de la forma de pago
//tax: Tax; // ? --> detalles?
invoiceCurrency: string;
//language: Language;
//purchareOrderNumber: string;
//notes: Note;
//senderId: UniqueID;
//paymentInstructions: Note;
//paymentTerms: string;
customer?: InvoiceCustomer;
items?: InvoiceItems;
}
export interface IInvoice {
id: UniqueID;
invoiceNumber: InvoiceNumber;
invoiceSeries: InvoiceSerie;
status: InvoiceStatus;
issueDate: UtcDate;
operationDate: UtcDate;
//senderId: UniqueID;
customer?: InvoiceCustomer;
//dueDate
//tax: Tax;
//language: Language;
invoiceCurrency: string;
//purchareOrderNumber: string;
//notes: Note;
//paymentInstructions: Note;
//paymentTerms: string;
items: InvoiceItems;
calculateSubtotal: () => MoneyValue;
calculateTaxTotal: () => MoneyValue;
calculateTotal: () => MoneyValue;
}
export class Invoice extends AggregateRoot<IInvoiceProps> implements IInvoice {
private _items!: Collection<InvoiceItem>;
//protected _status: InvoiceStatus;
protected constructor(props: IInvoiceProps, id?: UniqueID) {
super(props, id);
this._items = props.items || InvoiceItems.create();
}
static create(props: IInvoiceProps, id?: UniqueID): Result<Invoice, Error> {
const invoice = new Invoice(props, id);
// Reglas de negocio / validaciones
// ...
// ...
// 🔹 Disparar evento de dominio "InvoiceAuthenticatedEvent"
//const { invoice } = props;
//user.addDomainEvent(new InvoiceAuthenticatedEvent(id, invoice.toString()));
return Result.ok(invoice);
}
get invoiceNumber() {
return this.props.invoiceNumber;
}
get invoiceSeries() {
return this.props.invoiceSeries;
}
get issueDate() {
return this.props.issueDate;
}
/*get senderId(): UniqueID {
return this.props.senderId;
}*/
get customer(): InvoiceCustomer | undefined {
return this.props.customer;
}
get operationDate() {
return this.props.operationDate;
}
/*get language() {
return this.props.language;
}*/
get dueDate() {
return undefined;
}
get tax() {
return undefined;
}
get status() {
return this.props.status;
}
get items() {
return this._items;
}
/*get purchareOrderNumber() {
return this.props.purchareOrderNumber;
}
get paymentInstructions() {
return this.props.paymentInstructions;
}
get paymentTerms() {
return this.props.paymentTerms;
}
get billTo() {
return this.props.billTo;
}
get shipTo() {
return this.props.shipTo;
}*/
get invoiceCurrency() {
return this.props.invoiceCurrency;
}
/*get notes() {
return this.props.notes;
}*/
// Method to get the complete list of line items
/*get lineItems(): InvoiceLineItem[] {
return this._lineItems;
}
addLineItem(lineItem: InvoiceLineItem, position?: number): void {
if (position === undefined) {
this._lineItems.push(lineItem);
} else {
this._lineItems.splice(position, 0, lineItem);
}
}*/
calculateSubtotal(): MoneyValue {
const invoiceSubtotal = MoneyValue.create({
amount: 0,
currency_code: this.props.invoiceCurrency,
scale: 2,
}).data;
return this._items.getAll().reduce((subtotal, item) => {
return subtotal.add(item.calculateTotal());
}, invoiceSubtotal);
}
// Method to calculate the total tax in the invoice
calculateTaxTotal(): MoneyValue {
const taxTotal = MoneyValue.create({
amount: 0,
currency_code: this.props.invoiceCurrency,
scale: 2,
}).data;
return taxTotal;
}
// Method to calculate the total invoice amount, including taxes
calculateTotal(): MoneyValue {
return this.calculateSubtotal().add(this.calculateTaxTotal());
}
}

View File

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

View File

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

View File

@ -0,0 +1,78 @@
import { EmailAddress, Name, PostalAddress, ValueObject } from "@rdx/ddd-domain";
import { Result } from "@rdx/utils";
import { PhoneNumber } from "libphonenumber-js";
import { InvoiceAddressType } from "../../value-objects";
export interface IInvoiceAddressProps {
type: InvoiceAddressType;
title: Name;
address: PostalAddress;
email: EmailAddress;
phone: PhoneNumber;
}
export interface IInvoiceAddress {
type: InvoiceAddressType;
title: Name;
address: PostalAddress;
email: EmailAddress;
phone: PhoneNumber;
}
export class InvoiceAddress extends ValueObject<IInvoiceAddressProps> implements IInvoiceAddress {
public static create(props: IInvoiceAddressProps) {
return Result.ok(new this(props));
}
public static createShippingAddress(props: IInvoiceAddressProps) {
return Result.ok(
new this({
...props,
type: InvoiceAddressType.create("shipping").data,
})
);
}
public static createBillingAddress(props: IInvoiceAddressProps) {
return Result.ok(
new this({
...props,
type: InvoiceAddressType.create("billing").data,
})
);
}
get title(): Name {
return this.props.title;
}
get address(): PostalAddress {
return this.props.address;
}
get email(): EmailAddress {
return this.props.email;
}
get phone(): PhoneNumber {
return this.props.phone;
}
get type(): InvoiceAddressType {
return this.props.type;
}
getValue(): IInvoiceAddressProps {
return this.props;
}
toPrimitive() {
return {
type: this.type.toString(),
title: this.title.toString(),
address: this.address.toString(),
email: this.email.toString(),
phone: this.phone.toString(),
};
}
}

View File

@ -0,0 +1,61 @@
import { DomainEntity, Name, TINNumber, UniqueID } from "@rdx/ddd-domain";
import { Result } from "@rdx/utils";
import { InvoiceAddress } from "./invoice-address";
export interface IInvoiceCustomerProps {
tin: TINNumber;
companyName: Name;
firstName: Name;
lastName: Name;
billingAddress?: InvoiceAddress;
shippingAddress?: InvoiceAddress;
}
export interface IInvoiceCustomer {
id: UniqueID;
tin: TINNumber;
companyName: Name;
firstName: Name;
lastName: Name;
billingAddress?: InvoiceAddress;
shippingAddress?: InvoiceAddress;
}
export class InvoiceCustomer
extends DomainEntity<IInvoiceCustomerProps>
implements IInvoiceCustomer
{
public static create(
props: IInvoiceCustomerProps,
id?: UniqueID
): Result<InvoiceCustomer, Error> {
const participant = new InvoiceCustomer(props, id);
return Result.ok<InvoiceCustomer>(participant);
}
get tin(): TINNumber {
return this.props.tin;
}
get companyName(): Name {
return this.props.companyName;
}
get firstName(): Name {
return this.props.firstName;
}
get lastName(): Name {
return this.props.lastName;
}
get billingAddress() {
return this.props.billingAddress;
}
get shippingAddress() {
return this.props.shippingAddress;
}
}

View File

@ -0,0 +1,84 @@
import { MoneyValue, Quantity } from "@rdx/ddd-domain";
import { InvoiceItemDescription } from "../../../value-objects";
import { InvoiceItem } from "../invoice-item";
describe("InvoiceItem", () => {
it("debería calcular correctamente el subtotal (unitPrice * quantity)", () => {
const props = {
description: InvoiceItemDescription.create("Producto A"),
quantity: Quantity.create({ amount: 200, scale: 2 }),
unitPrice: MoneyValue.create(50),
discount: Percentage.create(0),
};
const result = InvoiceItem.create(props);
expect(result.isOk()).toBe(true);
const invoiceItem = result.unwrap();
expect(invoiceItem.subtotalPrice.value).toBe(100); // 50 * 2
});
it("debería calcular correctamente el total con descuento", () => {
const props = {
description: new InvoiceItemDescription("Producto B"),
quantity: new Quantity(3),
unitPrice: new MoneyValue(30),
discount: new Percentage(10), // 10%
};
const result = InvoiceItem.create(props);
expect(result.isOk()).toBe(true);
const invoiceItem = result.unwrap();
expect(invoiceItem.totalPrice.value).toBe(81); // (30 * 3) - 10% de (30 * 3)
});
it("debería devolver los valores correctos de las propiedades", () => {
const props = {
description: new InvoiceItemDescription("Producto C"),
quantity: new Quantity(1),
unitPrice: new MoneyValue(100),
discount: new Percentage(5),
};
const result = InvoiceItem.create(props);
expect(result.isOk()).toBe(true);
const invoiceItem = result.unwrap();
expect(invoiceItem.description.value).toBe("Producto C");
expect(invoiceItem.quantity.value).toBe(1);
expect(invoiceItem.unitPrice.value).toBe(100);
expect(invoiceItem.discount.value).toBe(5);
});
it("debería manejar correctamente un descuento del 0%", () => {
const props = {
description: new InvoiceItemDescription("Producto D"),
quantity: new Quantity(4),
unitPrice: new MoneyValue(25),
discount: new Percentage(0),
};
const result = InvoiceItem.create(props);
expect(result.isOk()).toBe(true);
const invoiceItem = result.unwrap();
expect(invoiceItem.totalPrice.value).toBe(100); // 25 * 4
});
it("debería manejar correctamente un descuento del 100%", () => {
const props = {
description: new InvoiceItemDescription("Producto E"),
quantity: new Quantity(2),
unitPrice: new MoneyValue(50),
discount: new Percentage(100),
};
const result = InvoiceItem.create(props);
expect(result.isOk()).toBe(true);
const invoiceItem = result.unwrap();
expect(invoiceItem.totalPrice.value).toBe(0); // (50 * 2) - 100% de (50 * 2)
});
});

View File

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

View File

@ -0,0 +1,94 @@
import { DomainEntity, MoneyValue, Percentage, Quantity, UniqueID } from "@rdx/ddd-domain";
import { Result } from "@rdx/utils";
import { InvoiceItemDescription } from "../../value-objects";
export interface IInvoiceItemProps {
description: InvoiceItemDescription;
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 IInvoiceItem {
id: UniqueID;
description: InvoiceItemDescription;
quantity: Quantity;
unitPrice: MoneyValue;
subtotalPrice: MoneyValue;
discount: Percentage;
totalPrice: MoneyValue;
}
export class InvoiceItem extends DomainEntity<IInvoiceItemProps> implements IInvoiceItem {
private _subtotalPrice!: MoneyValue;
private _totalPrice!: MoneyValue;
public static create(props: IInvoiceItemProps, id?: UniqueID): Result<InvoiceItem, Error> {
const item = new InvoiceItem(props, id);
// Reglas de negocio / validaciones
// ...
// ...
// 🔹 Disparar evento de dominio "InvoiceItemCreatedEvent"
//const { invoice } = props;
//user.addDomainEvent(new InvoiceAuthenticatedEvent(id, invoice.toString()));
return Result.ok(item);
}
get description(): InvoiceItemDescription {
return this.props.description;
}
get quantity(): Quantity {
return this.props.quantity;
}
get unitPrice(): MoneyValue {
return this.props.unitPrice;
}
get subtotalPrice(): MoneyValue {
if (!this._subtotalPrice) {
this._subtotalPrice = this.calculateSubtotal();
}
return this._subtotalPrice;
}
get discount(): Percentage {
return this.props.discount;
}
get totalPrice(): MoneyValue {
if (!this._totalPrice) {
this._totalPrice = this.calculateTotal();
}
return this._totalPrice;
}
getValue() {
return this.props;
}
/*toPrimitive() {
return {
description: this.description.toPrimitive(),
quantity: this.quantity.toPrimitive(),
unit_price: this.unitPrice.toPrimitive(),
subtotal_price: this.subtotalPrice.toPrimitive(),
discount: this.discount.toPrimitive(),
total_price: this.totalPrice.toPrimitive(),
};
}*/
calculateSubtotal(): MoneyValue {
return this.unitPrice.multiply(this.quantity.toNumber()); // Precio unitario * Cantidad
}
calculateTotal(): MoneyValue {
return this.subtotalPrice.subtract(this.subtotalPrice.percentage(this.discount.toNumber()));
}
}

View File

@ -0,0 +1,8 @@
import { Collection } from "@rdx/utils";
import { InvoiceItem } from "./invoice-item";
export class InvoiceItems extends Collection<InvoiceItem> {
public static create(items?: InvoiceItem[]): InvoiceItems {
return new InvoiceItems(items);
}
}

View File

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

View File

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

View File

@ -0,0 +1,12 @@
import { UniqueID } from "@rdx/ddd-domain";
import { Collection, Result } from "@rdx/utils";
import { Invoice } from "../aggregates";
export interface IInvoiceRepository {
findAll(transaction?: any): Promise<Result<Collection<Invoice>, Error>>;
getById(id: UniqueID, transaction?: any): Promise<Result<Invoice, Error>>;
deleteById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
create(invoice: Invoice, transaction?: any): Promise<void>;
update(invoice: Invoice, transaction?: any): Promise<void>;
}

View File

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

View File

@ -0,0 +1,22 @@
import { UniqueID } from "@rdx/ddd-domain";
import { Collection, Result } from "@rdx/utils";
import { IInvoiceProps, Invoice } from "../aggregates";
export interface IInvoiceService {
findInvoices(transaction?: any): Promise<Result<Collection<Invoice>, Error>>;
findInvoiceById(invoiceId: UniqueID, transaction?: any): Promise<Result<Invoice>>;
updateInvoiceById(
invoiceId: UniqueID,
data: Partial<IInvoiceProps>,
transaction?: any
): Promise<Result<Invoice, Error>>;
createInvoice(
invoiceId: UniqueID,
data: IInvoiceProps,
transaction?: any
): Promise<Result<Invoice, Error>>;
deleteInvoiceById(invoiceId: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
}

View File

@ -0,0 +1,82 @@
import { UniqueID } from "@rdx/ddd-domain";
import { Collection, Result } from "@rdx/utils";
import { Transaction } from "sequelize";
import { IInvoiceProps, Invoice } from "../aggregates";
import { IInvoiceRepository } from "../repositories";
import { IInvoiceService } from "./invoice-service.interface";
export class InvoiceService implements IInvoiceService {
constructor(private readonly repo: IInvoiceRepository) {}
async findInvoices(transaction?: Transaction): Promise<Result<Collection<Invoice>, Error>> {
const invoicesOrError = await this.repo.findAll(transaction);
if (invoicesOrError.isFailure) {
return Result.fail(invoicesOrError.error);
}
// Solo devolver usuarios activos
//const allInvoices = invoicesOrError.data.filter((invoice) => invoice.isActive);
//return Result.ok(new Collection(allInvoices));
return invoicesOrError;
}
async findInvoiceById(invoiceId: UniqueID, transaction?: Transaction): Promise<Result<Invoice>> {
return await this.repo.getById(invoiceId, transaction);
}
async updateInvoiceById(
invoiceId: UniqueID,
data: Partial<IInvoiceProps>,
transaction?: Transaction
): Promise<Result<Invoice, Error>> {
// Verificar si la factura existe
return Result.fail(new Error("No implementado"));
const invoiceOrError = await this.repo.getById(invoiceId, transaction);
if (invoiceOrError.isFailure) {
return Result.fail(new Error("Invoice not found"));
}
/*const updatedInvoiceOrError = Invoice.update(invoiceOrError.data, data);
if (updatedInvoiceOrError.isFailure) {
return Result.fail(
new Error(`Error updating invoice: ${updatedInvoiceOrError.error.message}`)
);
}
const updateInvoice = updatedInvoiceOrError.data;
await this.repo.update(updateInvoice, transaction);
return Result.ok(updateInvoice);*/
}
async createInvoice(
invoiceId: UniqueID,
data: IInvoiceProps,
transaction?: Transaction
): Promise<Result<Invoice, Error>> {
// Verificar si la factura existe
const invoiceOrError = await this.repo.getById(invoiceId, transaction);
if (invoiceOrError.isSuccess) {
return Result.fail(new Error("Invoice exists"));
}
const newInvoiceOrError = Invoice.create(data, invoiceId);
if (newInvoiceOrError.isFailure) {
return Result.fail(new Error(`Error creating invoice: ${newInvoiceOrError.error.message}`));
}
const newInvoice = newInvoiceOrError.data;
await this.repo.create(newInvoice, transaction);
return Result.ok(newInvoice);
}
async deleteInvoiceById(
invoiceId: UniqueID,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
return this.repo.deleteById(invoiceId, transaction);
}
}

View File

@ -0,0 +1,5 @@
export * from "./invoice-address-type";
export * from "./invoice-item-description";
export * from "./invoice-number";
export * from "./invoice-serie";
export * from "./invoice-status";

View File

@ -0,0 +1,38 @@
import { ValueObject } from "@rdx/ddd-domain";
import { Result } from "@rdx/utils";
interface IInvoiceAddressTypeProps {
value: string;
}
export enum INVOICE_ADDRESS_TYPE {
SHIPPING = "shipping",
BILLING = "billing",
}
export class InvoiceAddressType extends ValueObject<IInvoiceAddressTypeProps> {
private static readonly ALLOWED_TYPES = ["shipping", "billing"];
static create(value: string): Result<InvoiceAddressType, Error> {
if (!this.ALLOWED_TYPES.includes(value)) {
return Result.fail(
new Error(
`Invalid address type: ${value}. Allowed types are: ${this.ALLOWED_TYPES.join(", ")}`
)
);
}
return Result.ok(new InvoiceAddressType({ value }));
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.getValue();
}
toPrimitive(): string {
return this.getValue();
}
}

View File

@ -0,0 +1,50 @@
import { ValueObject } from "@rdx/ddd-domain";
import { Maybe, Result } from "@rdx/utils";
import { z } from "zod";
interface IInvoiceItemDescriptionProps {
value: string;
}
export class InvoiceItemDescription extends ValueObject<IInvoiceItemDescriptionProps> {
private static readonly MAX_LENGTH = 255;
protected static validate(value: string) {
const schema = z
.string()
.trim()
.max(InvoiceItemDescription.MAX_LENGTH, {
message: `Description must be at most ${InvoiceItemDescription.MAX_LENGTH} characters long`,
});
return schema.safeParse(value);
}
static create(value: string) {
const valueIsValid = InvoiceItemDescription.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message));
}
return Result.ok(new InvoiceItemDescription({ value }));
}
static createNullable(value?: string): Result<Maybe<InvoiceItemDescription>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.none<InvoiceItemDescription>());
}
return InvoiceItemDescription.create(value!).map((value) => Maybe.some(value));
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.getValue();
}
toPrimitive() {
return this.getValue();
}
}

View File

@ -0,0 +1,42 @@
import { ValueObject } from "@rdx/ddd-domain";
import { Result } from "@rdx/utils";
import { z } from "zod";
interface IInvoiceNumberProps {
value: string;
}
export class InvoiceNumber extends ValueObject<IInvoiceNumberProps> {
private static readonly MAX_LENGTH = 255;
protected static validate(value: string) {
const schema = z
.string()
.trim()
.max(InvoiceNumber.MAX_LENGTH, {
message: `Name must be at most ${InvoiceNumber.MAX_LENGTH} characters long`,
});
return schema.safeParse(value);
}
static create(value: string) {
const valueIsValid = InvoiceNumber.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message));
}
return Result.ok(new InvoiceNumber({ value }));
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.getValue();
}
toPrimitive() {
return this.getValue();
}
}

View File

@ -0,0 +1,50 @@
import { ValueObject } from "@rdx/ddd-domain";
import { Maybe, Result } from "@rdx/utils";
import { z } from "zod";
interface IInvoiceSerieProps {
value: string;
}
export class InvoiceSerie extends ValueObject<IInvoiceSerieProps> {
private static readonly MAX_LENGTH = 255;
protected static validate(value: string) {
const schema = z
.string()
.trim()
.max(InvoiceSerie.MAX_LENGTH, {
message: `Name must be at most ${InvoiceSerie.MAX_LENGTH} characters long`,
});
return schema.safeParse(value);
}
static create(value: string) {
const valueIsValid = InvoiceSerie.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message));
}
return Result.ok(new InvoiceSerie({ value }));
}
static createNullable(value?: string): Result<Maybe<InvoiceSerie>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.none<InvoiceSerie>());
}
return InvoiceSerie.create(value!).map((value) => Maybe.some(value));
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.getValue();
}
toPrimitive() {
return this.getValue();
}
}

View File

@ -0,0 +1,80 @@
import { ValueObject } from "@rdx/ddd-domain";
import { Result } from "@rdx/utils";
interface IInvoiceStatusProps {
value: string;
}
export enum INVOICE_STATUS {
DRAFT = "draft",
EMITTED = "emitted",
SENT = "sent",
REJECTED = "rejected",
}
export class InvoiceStatus extends ValueObject<IInvoiceStatusProps> {
private static readonly ALLOWED_STATUSES = ["draft", "emitted", "sent", "rejected"];
private static readonly TRANSITIONS: Record<string, string[]> = {
draft: [INVOICE_STATUS.EMITTED],
emitted: [INVOICE_STATUS.SENT, INVOICE_STATUS.REJECTED, INVOICE_STATUS.DRAFT],
sent: [INVOICE_STATUS.REJECTED],
rejected: [],
};
static create(value: string): Result<InvoiceStatus, Error> {
if (!this.ALLOWED_STATUSES.includes(value)) {
return Result.fail(new Error(`Estado de la factura no válido: ${value}`));
}
return Result.ok(
value === "rejected"
? InvoiceStatus.createRejected()
: value === "sent"
? InvoiceStatus.createSent()
: value === "emitted"
? InvoiceStatus.createSent()
: InvoiceStatus.createDraft()
);
}
public static createDraft(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.DRAFT });
}
public static createEmitted(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.EMITTED });
}
public static createSent(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.SENT });
}
public static createRejected(): InvoiceStatus {
return new InvoiceStatus({ value: INVOICE_STATUS.REJECTED });
}
getValue(): string {
return this.props.value;
}
toPrimitive() {
return this.getValue();
}
canTransitionTo(nextStatus: string): boolean {
return InvoiceStatus.TRANSITIONS[this.props.value].includes(nextStatus);
}
transitionTo(nextStatus: string): Result<InvoiceStatus, Error> {
if (!this.canTransitionTo(nextStatus)) {
return Result.fail(
new Error(`Transición no permitida de ${this.props.value} a ${nextStatus}`)
);
}
return InvoiceStatus.create(nextStatus);
}
toString(): string {
return this.getValue();
}
}

View File

@ -0,0 +1,28 @@
/* import { getService } from "@apps/server/src/core/service-registry"; */
import { logger } from "@rdx/logger";
import { IModuleServer } from "@rdx/modules";
import { Application } from "express";
import { initInvoiceModel } from "./intrastructure";
export const invoicesModule: IModuleServer = {
metadata: {
name: "invoices",
version: "1.0.0",
dependencies: [],
},
init(app: Application) {
// const contacts = getService<ContactsService>("contacts");
//invoicesRouter(app);
logger.info("🚀 Invoices module initialized");
},
registerDependencies() {
logger.info("🚀 Invoices module dependencies registered");
return {
models: [(sequelize) => initInvoiceModel(sequelize)],
services: {
getInvoice: () => {},
/*...*/
},
};
},
};

View File

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

View File

@ -0,0 +1,66 @@
import { Express } from "express";
import {
buildGetInvoiceController,
buildListInvoicesController,
ICreateInvoiceRequestSchema,
} from "../../presentation";
import { buildCreateInvoiceController } from "#/server/presentation/controllers/create-invoice";
import { validateAndParseBody } from "@rdx/core";
import { NextFunction, Request, Response, Router } from "express";
import { Sequelize } from "sequelize";
export const invoicesRouter = (app: Express, database: Sequelize) => {
const routes: Router = Router({ mergeParams: true });
routes.get(
"/",
//checkTabContext,
//checkUser,
(req: Request, res: Response, next: NextFunction) => {
buildListInvoicesController(database).execute(req, res, next);
}
);
routes.get(
"/:invoiceId",
//checkTabContext,
//checkUser,
(req: Request, res: Response, next: NextFunction) => {
buildGetInvoiceController(database).execute(req, res, next);
}
);
routes.post(
"/",
validateAndParseBody(ICreateInvoiceRequestSchema, { sanitize: false }),
//checkTabContext,
//checkUser,
(req: Request, res: Response, next: NextFunction) => {
buildCreateInvoiceController(database).execute(req, res, next);
}
);
/*
routes.put(
"/:invoiceId",
validateAndParseBody(IUpdateInvoiceRequestSchema),
checkTabContext,
//checkUser,
(req: Request, res: Response, next: NextFunction) => {
buildUpdateInvoiceController().execute(req, res, next);
}
);
routes.delete(
"/:invoiceId",
validateAndParseBody(IDeleteInvoiceRequestSchema),
checkTabContext,
//checkUser,
(req: Request, res: Response, next: NextFunction) => {
buildDeleteInvoiceController().execute(req, res, next);
}
);*/
app.use("/invoices", routes);
};

View File

@ -0,0 +1,3 @@
export * from "./express";
export * from "./mappers";
export * from "./sequelize";

View File

@ -0,0 +1,63 @@
import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure";
import { Name, TINNumber, UniqueID } from "@shared/contexts";
import { Contact, IContactProps } from "../../domain";
import { IInvoicingContext } from "../InvoicingContext";
import { Contact_Model, TCreationContact_Model } from "../sequelize/contact.mo.del";
import { IContactAddressMapper, createContactAddressMapper } from "./contactAddress.mapper";
export interface IContactMapper
extends ISequelizeMapper<Contact_Model, TCreationContact_Model, Contact> {}
class ContactMapper
extends SequelizeMapper<Contact_Model, TCreationContact_Model, Contact>
implements IContactMapper
{
public constructor(props: { addressMapper: IContactAddressMapper; context: IInvoicingContext }) {
super(props);
}
protected toDomainMappingImpl(source: Contact_Model, params: any): Contact {
if (!source.billingAddress) {
this.handleRequiredFieldError(
"billingAddress",
new Error("Missing participant's billing address")
);
}
if (!source.shippingAddress) {
this.handleRequiredFieldError(
"shippingAddress",
new Error("Missing participant's shipping address")
);
}
const billingAddress = this.props.addressMapper.mapToDomain(source.billingAddress!, params);
const shippingAddress = this.props.addressMapper.mapToDomain(source.shippingAddress!, params);
const props: IContactProps = {
tin: this.mapsValue(source, "tin", TINNumber.create),
firstName: this.mapsValue(source, "first_name", Name.create),
lastName: this.mapsValue(source, "last_name", Name.create),
companyName: this.mapsValue(source, "company_name", Name.create),
billingAddress,
shippingAddress,
};
const id = this.mapsValue(source, "id", UniqueID.create);
const contactOrError = Contact.create(props, id);
if (contactOrError.isFailure) {
throw contactOrError.error;
}
return contactOrError.object;
}
}
export const createContactMapper = (context: IInvoicingContext): IContactMapper =>
new ContactMapper({
addressMapper: createContactAddressMapper(context),
context,
});

View File

@ -0,0 +1,65 @@
import {
ISequelizeMapper,
SequelizeMapper,
} from "@/contexts/common/infrastructure";
import {
City,
Country,
Email,
Note,
Phone,
PostalCode,
Province,
Street,
UniqueID,
} from "@shared/contexts";
import { ContactAddress, IContactAddressProps } from "../../domain";
import { IInvoicingContext } from "../InvoicingContext";
import {
ContactAddress_Model,
TCreationContactAddress_Attributes,
} from "../sequelize";
export interface IContactAddressMapper
extends ISequelizeMapper<
ContactAddress_Model,
TCreationContactAddress_Attributes,
ContactAddress
> {}
export const createContactAddressMapper = (
context: IInvoicingContext
): IContactAddressMapper => new ContactAddressMapper({ context });
class ContactAddressMapper
extends SequelizeMapper<
ContactAddress_Model,
TCreationContactAddress_Attributes,
ContactAddress
>
implements IContactAddressMapper
{
protected toDomainMappingImpl(source: ContactAddress_Model, params: any) {
const id = this.mapsValue(source, "id", UniqueID.create);
const props: IContactAddressProps = {
type: source.type,
street: this.mapsValue(source, "street", Street.create),
city: this.mapsValue(source, "city", City.create),
province: this.mapsValue(source, "province", Province.create),
postalCode: this.mapsValue(source, "postal_code", PostalCode.create),
country: this.mapsValue(source, "country", Country.create),
email: this.mapsValue(source, "email", Email.create),
phone: this.mapsValue(source, "phone", Phone.create),
notes: this.mapsValue(source, "notes", Note.create),
};
const addressOrError = ContactAddress.create(props, id);
if (addressOrError.isFailure) {
throw addressOrError.error;
}
return addressOrError.object;
}
}

View File

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

View File

@ -0,0 +1,100 @@
import { Invoice, InvoiceItem, InvoiceItemDescription } from "#/server/domain";
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@rdx/core";
import { MoneyValue, Percentage, Quantity, UniqueID } from "@rdx/ddd-domain";
import { Result } from "@rdx/utils";
import { InferCreationAttributes } from "sequelize";
import { InvoiceItemCreationAttributes, InvoiceItemModel, InvoiceModel } from "../sequelize";
export interface IInvoiceItemMapper
extends ISequelizeMapper<InvoiceItemModel, InvoiceItemCreationAttributes, InvoiceItem> {}
export class InvoiceItemMapper
extends SequelizeMapper<InvoiceItemModel, InvoiceItemCreationAttributes, InvoiceItem>
implements IInvoiceItemMapper
{
public mapToDomain(
source: InvoiceItemModel,
params?: MapperParamsType
): Result<InvoiceItem, Error> {
const { sourceParent } = params as { sourceParent: InvoiceModel };
const idOrError = UniqueID.create(source.item_id);
const descriptionOrError = InvoiceItemDescription.create(source.description);
const quantityOrError = Quantity.create({
amount: source.quantity_amount,
scale: source.quantity_scale,
});
const unitPriceOrError = MoneyValue.create({
amount: source.unit_price_amount,
scale: source.unit_price_scale,
currency_code: sourceParent.invoice_currency,
});
const discountOrError = Percentage.create({
amount: source.discount_amount,
scale: source.discount_scale,
});
const result = Result.combine([
idOrError,
descriptionOrError,
quantityOrError,
unitPriceOrError,
discountOrError,
]);
if (result.isFailure) {
return Result.fail(result.error);
}
return InvoiceItem.create(
{
description: descriptionOrError.data,
quantity: quantityOrError.data,
unitPrice: unitPriceOrError.data,
discount: discountOrError.data,
},
idOrError.data
//sourceParent
);
}
public mapToPersistence(
source: InvoiceItem,
params?: MapperParamsType
): InferCreationAttributes<InvoiceItemModel, {}> {
const { index, sourceParent } = params as {
index: number;
sourceParent: Invoice;
};
const lineData = {
parent_id: undefined,
invoice_id: sourceParent.id.toPrimitive(),
item_type: "simple",
position: index,
item_id: source.id.toPrimitive(),
description: source.description.toPrimitive(),
quantity_amount: source.quantity.toPrimitive().amount,
quantity_scale: source.quantity.toPrimitive().scale,
unit_price_amount: source.unitPrice.toPrimitive().amount,
unit_price_scale: source.unitPrice.toPrimitive().scale,
subtotal_amount: source.subtotalPrice.toPrimitive().amount,
subtotal_scale: source.subtotalPrice.toPrimitive().scale,
discount_amount: source.discount.toPrimitive().amount,
discount_scale: source.discount.toPrimitive().scale,
total_amount: source.totalPrice.toPrimitive().amount,
total_scale: source.totalPrice.toPrimitive().scale,
};
return lineData;
}
}

View File

@ -0,0 +1,97 @@
import { Invoice, InvoiceNumber, InvoiceSerie, InvoiceStatus } from "#/server/domain";
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@rdx/core";
import { UniqueID, UtcDate } from "@rdx/ddd-domain";
import { Result } from "@rdx/utils";
import { InvoiceCreationAttributes, InvoiceModel } from "../sequelize";
import { InvoiceItemMapper } from "./invoice-item.mapper";
export interface IInvoiceMapper
extends ISequelizeMapper<InvoiceModel, InvoiceCreationAttributes, Invoice> {}
export class InvoiceMapper
extends SequelizeMapper<InvoiceModel, InvoiceCreationAttributes, Invoice>
implements IInvoiceMapper
{
private invoiceItemMapper: InvoiceItemMapper;
constructor() {
super();
this.invoiceItemMapper = new InvoiceItemMapper(); // Instanciar el mapper de items
}
public mapToDomain(source: InvoiceModel, params?: MapperParamsType): Result<Invoice, Error> {
const idOrError = UniqueID.create(source.id);
const statusOrError = InvoiceStatus.create(source.invoice_status);
const invoiceSeriesOrError = InvoiceSerie.create(source.invoice_series);
const invoiceNumberOrError = InvoiceNumber.create(source.invoice_number);
const issueDateOrError = UtcDate.create(source.issue_date);
const operationDateOrError = UtcDate.create(source.operation_date);
const result = Result.combine([
idOrError,
statusOrError,
invoiceSeriesOrError,
invoiceNumberOrError,
issueDateOrError,
operationDateOrError,
]);
if (result.isFailure) {
return Result.fail(result.error);
}
// Mapear los items de la factura
const itemsOrErrors = this.invoiceItemMapper.mapArrayToDomain(source.items, {
sourceParent: source,
...params,
});
if (itemsOrErrors.isFailure) {
return Result.fail(itemsOrErrors.error);
}
const invoiceCurrency = source.invoice_currency || "EUR";
return Invoice.create(
{
status: statusOrError.data,
invoiceSeries: invoiceSeriesOrError.data,
invoiceNumber: invoiceNumberOrError.data,
issueDate: issueDateOrError.data,
operationDate: operationDateOrError.data,
invoiceCurrency,
items: itemsOrErrors.data,
},
idOrError.data
);
}
public mapToPersistence(source: Invoice, params?: MapperParamsType): InvoiceCreationAttributes {
const subtotal = source.calculateSubtotal();
const total = source.calculateTotal();
const items = this.invoiceItemMapper.mapCollectionToPersistence(source.items, params);
return {
id: source.id.toString(),
invoice_status: source.status.toPrimitive(),
invoice_series: source.invoiceSeries.toPrimitive(),
invoice_number: source.invoiceNumber.toPrimitive(),
issue_date: source.issueDate.toPrimitive(),
operation_date: source.operationDate.toPrimitive(),
invoice_language: "es",
invoice_currency: source.invoiceCurrency || "EUR",
subtotal_amount: subtotal.amount,
subtotal_scale: subtotal.scale,
total_amount: total.amount,
total_scale: total.scale,
items,
};
}
}
const invoiceMapper: InvoiceMapper = new InvoiceMapper();
export { invoiceMapper };

View File

@ -0,0 +1,119 @@
import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure";
import { Name, TINNumber, UniqueID } from "@shared/contexts";
import {
IInvoiceCustomerProps,
Invoice,
InvoiceCustomer,
InvoiceParticipantBillingAddress,
InvoiceParticipantShippingAddress,
} from "../../domain";
import { IInvoicingContext } from "../InvoicingContext";
import { InvoiceParticipant_Model, TCreationInvoiceParticipant_Model } from "../sequelize";
import {
IInvoiceParticipantAddressMapper,
createInvoiceParticipantAddressMapper,
} from "./invoiceParticipantAddress.mapper";
export interface IInvoiceParticipantMapper
extends ISequelizeMapper<
InvoiceParticipant_Model,
TCreationInvoiceParticipant_Model,
InvoiceCustomer
> {}
export const createInvoiceParticipantMapper = (
context: IInvoicingContext
): IInvoiceParticipantMapper =>
new InvoiceParticipantMapper({
context,
addressMapper: createInvoiceParticipantAddressMapper(context),
});
class InvoiceParticipantMapper
extends SequelizeMapper<
InvoiceParticipant_Model,
TCreationInvoiceParticipant_Model,
InvoiceCustomer
>
implements IInvoiceParticipantMapper
{
public constructor(props: {
addressMapper: IInvoiceParticipantAddressMapper;
context: IInvoicingContext;
}) {
super(props);
}
protected toDomainMappingImpl(source: InvoiceParticipant_Model, params: any) {
/*if (!source.billingAddress) {
this.handleRequiredFieldError(
"billingAddress",
new Error("Missing participant's billing address"),
);
}
if (!source.shippingAddress) {
this.handleRequiredFieldError(
"shippingAddress",
new Error("Missing participant's shipping address"),
);
}
*/
const billingAddress = source.billingAddress
? ((this.props.addressMapper as IInvoiceParticipantAddressMapper).mapToDomain(
source.billingAddress,
params
) as InvoiceParticipantBillingAddress)
: undefined;
const shippingAddress = source.shippingAddress
? ((this.props.addressMapper as IInvoiceParticipantAddressMapper).mapToDomain(
source.shippingAddress,
params
) as InvoiceParticipantShippingAddress)
: undefined;
const props: IInvoiceCustomerProps = {
tin: this.mapsValue(source, "tin", TINNumber.create),
firstName: this.mapsValue(source, "first_name", Name.create),
lastName: this.mapsValue(source, "last_name", Name.create),
companyName: this.mapsValue(source, "company_name", Name.create),
billingAddress,
shippingAddress,
};
const id = this.mapsValue(source, "participant_id", UniqueID.create);
const participantOrError = InvoiceCustomer.create(props, id);
if (participantOrError.isFailure) {
throw participantOrError.error;
}
return participantOrError.object;
}
protected toPersistenceMappingImpl(
source: InvoiceCustomer,
params: { sourceParent: Invoice }
): TCreationInvoiceParticipant_Model {
const { sourceParent } = params;
return {
invoice_id: sourceParent.id.toPrimitive(),
participant_id: source.id.toPrimitive(),
tin: source.tin.toPrimitive(),
first_name: source.firstName.toPrimitive(),
last_name: source.lastName.toPrimitive(),
company_name: source.companyName.toPrimitive(),
billingAddress: (
this.props.addressMapper as IInvoiceParticipantAddressMapper
).mapToPersistence(source.billingAddress!, { sourceParent: source }),
shippingAddress: (
this.props.addressMapper as IInvoiceParticipantAddressMapper
).mapToPersistence(source.shippingAddress!, { sourceParent: source }),
};
}
}

View File

@ -0,0 +1,87 @@
import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure";
import {
City,
Country,
Email,
Note,
Phone,
PostalCode,
Province,
Street,
UniqueID,
} from "@shared/contexts";
import {
IInvoiceParticipantAddressProps,
InvoiceCustomer,
InvoiceParticipantAddress,
} from "../../domain";
import { IInvoicingContext } from "../InvoicingContext";
import {
InvoiceParticipantAddress_Model,
TCreationInvoiceParticipantAddress_Model,
} from "../sequelize";
export interface IInvoiceParticipantAddressMapper
extends ISequelizeMapper<
InvoiceParticipantAddress_Model,
TCreationInvoiceParticipantAddress_Model,
InvoiceParticipantAddress
> {}
export const createInvoiceParticipantAddressMapper = (
context: IInvoicingContext
): IInvoiceParticipantAddressMapper => new InvoiceParticipantAddressMapper({ context });
class InvoiceParticipantAddressMapper
extends SequelizeMapper<
InvoiceParticipantAddress_Model,
TCreationInvoiceParticipantAddress_Model,
InvoiceParticipantAddress
>
implements IInvoiceParticipantAddressMapper
{
protected toDomainMappingImpl(source: InvoiceParticipantAddress_Model, params: any) {
const id = this.mapsValue(source, "address_id", UniqueID.create);
const props: IInvoiceParticipantAddressProps = {
type: source.type,
street: this.mapsValue(source, "street", Street.create),
city: this.mapsValue(source, "city", City.create),
province: this.mapsValue(source, "province", Province.create),
postalCode: this.mapsValue(source, "postal_code", PostalCode.create),
country: this.mapsValue(source, "country", Country.create),
email: this.mapsValue(source, "email", Email.create),
phone: this.mapsValue(source, "phone", Phone.create),
notes: this.mapsValue(source, "notes", Note.create),
};
const addressOrError = InvoiceParticipantAddress.create(props, id);
if (addressOrError.isFailure) {
throw addressOrError.error;
}
return addressOrError.object;
}
protected toPersistenceMappingImpl(
source: InvoiceParticipantAddress,
params: { sourceParent: InvoiceCustomer }
) {
const { sourceParent } = params;
return {
address_id: source.id.toPrimitive(),
participant_id: sourceParent.id.toPrimitive(),
type: String(source.type),
title: source.title,
street: source.street.toPrimitive(),
city: source.city.toPrimitive(),
postal_code: source.postalCode.toPrimitive(),
province: source.province.toPrimitive(),
country: source.country.toPrimitive(),
email: source.email.toPrimitive(),
phone: source.phone.toPrimitive(),
};
}
}

View File

@ -0,0 +1,84 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { ContactAddress_Model, TCreationContactAddress_Attributes } from "./contactAddress.mo.del";
export type TCreationContact_Model = InferCreationAttributes<
Contact_Model,
{ omit: "shippingAddress" | "billingAddress" }
> & {
billingAddress: TCreationContactAddress_Attributes;
shippingAddress: TCreationContactAddress_Attributes;
};
export class Contact_Model extends Model<
InferAttributes<Contact_Model, { omit: "shippingAddress" | "billingAddress" }>,
InferCreationAttributes<Contact_Model, { omit: "shippingAddress" | "billingAddress" }>
> {
// To avoid table creation
static async sync(): Promise<any> {
return Promise.resolve();
}
static associate(connection: Sequelize) {
const { Contact_Model, ContactAddress_Model } = connection.models;
Contact_Model.hasOne(ContactAddress_Model, {
as: "shippingAddress",
foreignKey: "customer_id",
onDelete: "CASCADE",
});
Contact_Model.hasOne(ContactAddress_Model, {
as: "billingAddress",
foreignKey: "customer_id",
onDelete: "CASCADE",
});
}
declare id: string;
declare tin: CreationOptional<string>;
declare company_name: CreationOptional<string>;
declare first_name: CreationOptional<string>;
declare last_name: CreationOptional<string>;
declare shippingAddress?: NonAttribute<ContactAddress_Model>;
declare billingAddress?: NonAttribute<ContactAddress_Model>;
}
export default (sequelize: Sequelize) => {
Contact_Model.init(
{
id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
tin: {
type: new DataTypes.STRING(),
},
company_name: {
type: new DataTypes.STRING(),
},
first_name: {
type: new DataTypes.STRING(),
},
last_name: {
type: new DataTypes.STRING(),
},
},
{
sequelize,
tableName: "customers",
timestamps: false,
}
);
return Contact_Model;
};

View File

@ -0,0 +1,75 @@
import {
CreationOptional,
DataTypes,
ForeignKey,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { Contact_Model } from "./contact.mo.del";
export type TCreationContactAddress_Attributes = InferCreationAttributes<
ContactAddress_Model,
{ omit: "customer" }
>;
export class ContactAddress_Model extends Model<
InferAttributes<ContactAddress_Model, { omit: "customer" }>,
TCreationContactAddress_Attributes
> {
// To avoid table creation
static async sync(): Promise<any> {
return Promise.resolve();
}
static associate(connection: Sequelize) {
const { Contact_Model, ContactAddress_Model } = connection.models;
ContactAddress_Model.belongsTo(Contact_Model, {
as: "customer",
foreignKey: "customer_id",
});
}
declare id: string;
declare customer_id: ForeignKey<Contact_Model["id"]>;
declare type: string;
declare street: CreationOptional<string>;
declare postal_code: CreationOptional<string>;
declare city: CreationOptional<string>;
declare province: CreationOptional<string>;
declare country: CreationOptional<string>;
declare phone: CreationOptional<string>;
declare email: CreationOptional<string>;
declare customer?: NonAttribute<Contact_Model>;
}
export default (sequelize: Sequelize) => {
ContactAddress_Model.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
customer_id: new DataTypes.UUID(),
type: DataTypes.STRING(),
street: DataTypes.STRING(),
postal_code: DataTypes.STRING(),
city: DataTypes.STRING,
province: DataTypes.STRING,
country: DataTypes.STRING,
email: DataTypes.STRING,
phone: DataTypes.STRING,
},
{
sequelize,
tableName: "customer_addresses",
timestamps: false,
}
);
return ContactAddress_Model;
};

View File

@ -0,0 +1,11 @@
import { IInvoiceRepository } from "../../domain";
import { invoiceRepository } from "./invoice.repository";
export * from "./invoice-item.model";
export * from "./invoice.model";
export * from "./invoice.repository";
export const createInvoiceRepository = (): IInvoiceRepository => {
return invoiceRepository;
};

View File

@ -0,0 +1,166 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Sequelize,
} from "sequelize";
export type InvoiceItemCreationAttributes = InferCreationAttributes<InvoiceItemModel, {}> & {};
export class InvoiceItemModel extends Model<
InferAttributes<InvoiceItemModel>,
InvoiceItemCreationAttributes
> {
static associate(connection: Sequelize) {
/*const { Invoice_Model, InvoiceItem_Model } = connection.models;
InvoiceItem_Model.belongsTo(Invoice_Model, {
as: "invoice",
foreignKey: "invoice_id",
onDelete: "CASCADE",
});*/
}
declare item_id: string;
declare invoice_id: string;
declare parent_id: CreationOptional<string>;
declare position: number;
declare item_type: string;
declare description: CreationOptional<string>;
declare quantity_amount: CreationOptional<number>;
declare quantity_scale: CreationOptional<number>;
declare unit_price_amount: CreationOptional<number>;
declare unit_price_scale: CreationOptional<number>;
declare subtotal_amount: CreationOptional<number>;
declare subtotal_scale: CreationOptional<number>;
declare discount_amount: CreationOptional<number>;
declare discount_scale: CreationOptional<number>;
declare total_amount: CreationOptional<number>;
declare total_scale: CreationOptional<number>;
//declare invoice?: NonAttribute<InvoiceModel>;
}
export default (sequelize: Sequelize) => {
InvoiceItemModel.init(
{
item_id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
invoice_id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
parent_id: {
type: new DataTypes.UUID(),
allowNull: true, // Puede ser nulo para elementos de nivel superior
},
position: {
type: new DataTypes.MEDIUMINT(),
autoIncrement: false,
allowNull: false,
},
item_type: {
type: new DataTypes.STRING(),
allowNull: false,
defaultValue: "simple",
},
description: {
type: new DataTypes.TEXT(),
allowNull: true,
},
quantity_amount: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
quantity_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
unit_price_amount: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
unit_price_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
/*tax_slug: {
type: new DataTypes.DECIMAL(3, 2),
allowNull: true,
},
tax_rate: {
type: new DataTypes.DECIMAL(3, 2),
allowNull: true,
},
tax_equalization: {
type: new DataTypes.DECIMAL(3, 2),
allowNull: true,
},*/
subtotal_amount: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
subtotal_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
discount_amount: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
discount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
/*tax_amount: {
type: new DataTypes.BIGINT(),
allowNull: true,
},*/
total_amount: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
total_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
},
{
sequelize,
tableName: "invoice_items",
defaultScope: {},
scopes: {},
}
);
return InvoiceItemModel;
};

View File

@ -0,0 +1,141 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { InvoiceItemCreationAttributes, InvoiceItemModel } from "./invoice-item.model";
export type InvoiceCreationAttributes = InferCreationAttributes<InvoiceModel, { omit: "items" }> & {
items?: InvoiceItemCreationAttributes[];
};
export class InvoiceModel extends Model<InferAttributes<InvoiceModel>, InvoiceCreationAttributes> {
static associate(connection: Sequelize) {
const { InvoiceModel, InvoiceItemModel } = connection.models;
InvoiceModel.hasMany(InvoiceItemModel, {
as: "items",
foreignKey: "invoice_id",
onDelete: "CASCADE",
});
}
declare id: string;
declare invoice_status: string;
declare invoice_series: CreationOptional<string>;
declare invoice_number: CreationOptional<string>;
declare issue_date: CreationOptional<string>;
declare operation_date: CreationOptional<string>;
declare invoice_language: string;
declare invoice_currency: string;
// Subtotal
declare subtotal_amount: CreationOptional<number>;
declare subtotal_scale: CreationOptional<number>;
// Total
declare total_amount: CreationOptional<number>;
declare total_scale: CreationOptional<number>;
// Relationships
declare items: NonAttribute<InvoiceItemModel[]>;
//declare customer: NonAttribute<InvoiceParticipant_Model[]>;
}
const initInvoiceModel = (sequelize: Sequelize) => {
return sequelize.define(
"InvoiceModel",
{
id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
invoice_status: {
type: new DataTypes.STRING(),
allowNull: false,
},
invoice_series: {
type: new DataTypes.STRING(),
allowNull: true,
defaultValue: null,
},
invoice_number: {
type: new DataTypes.STRING(),
allowNull: true,
defaultValue: null,
},
issue_date: {
type: new DataTypes.DATEONLY(),
allowNull: true,
defaultValue: null,
},
operation_date: {
type: new DataTypes.DATEONLY(),
allowNull: true,
defaultValue: null,
},
invoice_language: {
type: new DataTypes.STRING(),
allowNull: false,
},
invoice_currency: {
type: new DataTypes.STRING(3), // ISO 4217
allowNull: false,
},
subtotal_amount: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
subtotal_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
total_amount: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
total_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
},
{
tableName: "invoices",
paranoid: true, // softs deletes
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [{ unique: true, fields: ["invoice_number"] }],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
};
export { initInvoiceModel };

View File

@ -0,0 +1,111 @@
import { SequelizeRepository } from "@rdx/core";
import { UniqueID } from "@rdx/ddd-domain";
import { Collection, Result } from "@rdx/utils";
import { Transaction } from "sequelize";
import { IInvoiceRepository, Invoice } from "../../domain";
import { IInvoiceMapper, invoiceMapper } from "../mappers/invoice.mapper";
import { InvoiceItemModel } from "./invoice-item.model";
import { InvoiceModel } from "./invoice.model";
class InvoiceRepository extends SequelizeRepository<Invoice> implements IInvoiceRepository {
private readonly _mapper!: IInvoiceMapper;
/**
* 🔹 Función personalizada para mapear errores de unicidad en autenticación
*/
private _customErrorMapper(error: Error): string | null {
if (error.name === "SequelizeUniqueConstraintError") {
return "Invoice with this email already exists";
}
return null;
}
constructor(mapper: IInvoiceMapper) {
super();
this._mapper = mapper;
}
async invoiceExists(id: UniqueID, transaction?: Transaction): Promise<Result<boolean, Error>> {
try {
const _invoice = await this._getById(InvoiceModel, id, {}, transaction);
return Result.ok(Boolean(id.equals(_invoice.id)));
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async findAll(transaction?: Transaction): Promise<Result<Collection<Invoice>, Error>> {
try {
const rawInvoices: any = await this._findAll(
InvoiceModel,
{
include: [
{
model: InvoiceItemModel,
as: "items",
},
],
},
transaction
);
if (!rawInvoices === true) {
return Result.fail(new Error("Invoice with email not exists"));
}
return this._mapper.mapArrayToDomain(rawInvoices);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async getById(id: UniqueID, transaction?: Transaction): Promise<Result<Invoice, Error>> {
try {
const rawInvoice: any = await this._getById(
InvoiceModel,
id,
{
include: [
{
model: InvoiceItemModel,
as: "items",
},
],
},
transaction
);
if (!rawInvoice === true) {
return Result.fail(new Error(`Invoice with id ${id.toString()} not exists`));
}
return this._mapper.mapToDomain(rawInvoice);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async deleteById(id: UniqueID, transaction?: Transaction): Promise<Result<boolean, Error>> {
try {
this._deleteById(InvoiceModel, id);
return Result.ok<boolean>(true);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async create(invoice: Invoice, transaction?: Transaction): Promise<void> {
const invoiceData = this._mapper.mapToPersistence(invoice);
await this._save(InvoiceModel, invoice.id, invoiceData, {}, transaction);
}
async update(invoice: Invoice, transaction?: Transaction): Promise<void> {
const invoiceData = this._mapper.mapToPersistence(invoice);
await this._save(InvoiceModel, invoice.id, invoiceData, {}, transaction);
}
}
const invoiceRepository = new InvoiceRepository(invoiceMapper);
export { invoiceRepository };

View File

@ -0,0 +1,106 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { InvoiceModel } from "./invoice.model";
import {
InvoiceParticipantAddress_Model,
TCreationInvoiceParticipantAddress_Model,
} from "./invoiceParticipantAddress.mo.del";
export type TCreationInvoiceParticipant_Model = InferCreationAttributes<
InvoiceParticipant_Model,
{ omit: "shippingAddress" | "billingAddress" | "invoice" }
> & {
billingAddress: TCreationInvoiceParticipantAddress_Model;
shippingAddress: TCreationInvoiceParticipantAddress_Model;
};
export class InvoiceParticipant_Model extends Model<
InferAttributes<
InvoiceParticipant_Model,
{ omit: "shippingAddress" | "billingAddress" | "invoice" }
>,
InferCreationAttributes<
InvoiceParticipant_Model,
{ omit: "shippingAddress" | "billingAddress" | "invoice" }
>
> {
static associate(connection: Sequelize) {
const { Invoice_Model, InvoiceParticipantAddress_Model, InvoiceParticipant_Model } =
connection.models;
InvoiceParticipant_Model.belongsTo(Invoice_Model, {
as: "invoice",
foreignKey: "invoice_id",
onDelete: "CASCADE",
});
InvoiceParticipant_Model.hasOne(InvoiceParticipantAddress_Model, {
as: "shippingAddress",
foreignKey: "participant_id",
onDelete: "CASCADE",
});
InvoiceParticipant_Model.hasOne(InvoiceParticipantAddress_Model, {
as: "billingAddress",
foreignKey: "participant_id",
onDelete: "CASCADE",
});
}
declare participant_id: string;
declare invoice_id: string;
declare tin: CreationOptional<string>;
declare company_name: CreationOptional<string>;
declare first_name: CreationOptional<string>;
declare last_name: CreationOptional<string>;
declare shippingAddress?: NonAttribute<InvoiceParticipantAddress_Model>;
declare billingAddress?: NonAttribute<InvoiceParticipantAddress_Model>;
declare invoice?: NonAttribute<InvoiceModel>;
}
export default (sequelize: Sequelize) => {
InvoiceParticipant_Model.init(
{
participant_id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
invoice_id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
tin: {
type: new DataTypes.STRING(),
allowNull: true,
},
company_name: {
type: new DataTypes.STRING(),
allowNull: true,
},
first_name: {
type: new DataTypes.STRING(),
allowNull: true,
},
last_name: {
type: new DataTypes.STRING(),
allowNull: true,
},
},
{
sequelize,
tableName: "invoice_participants",
timestamps: false,
}
);
return InvoiceParticipant_Model;
};

View File

@ -0,0 +1,94 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { InvoiceParticipant_Model } from "./invoiceParticipant.mo.del";
export type TCreationInvoiceParticipantAddress_Model = InferCreationAttributes<
InvoiceParticipantAddress_Model,
{ omit: "participant" }
>;
export class InvoiceParticipantAddress_Model extends Model<
InferAttributes<InvoiceParticipantAddress_Model, { omit: "participant" }>,
InferCreationAttributes<InvoiceParticipantAddress_Model, { omit: "participant" }>
> {
static associate(connection: Sequelize) {
const { InvoiceParticipantAddress_Model, InvoiceParticipant_Model } = connection.models;
InvoiceParticipantAddress_Model.belongsTo(InvoiceParticipant_Model, {
as: "participant",
foreignKey: "participant_id",
});
}
declare address_id: string;
declare participant_id: string;
declare type: string;
declare street: CreationOptional<string>;
declare postal_code: CreationOptional<string>;
declare city: CreationOptional<string>;
declare province: CreationOptional<string>;
declare country: CreationOptional<string>;
declare phone: CreationOptional<string>;
declare email: CreationOptional<string>;
declare participant?: NonAttribute<InvoiceParticipant_Model>;
}
export default (sequelize: Sequelize) => {
InvoiceParticipantAddress_Model.init(
{
address_id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
participant_id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
type: {
type: new DataTypes.STRING(),
allowNull: false,
},
street: {
type: new DataTypes.STRING(),
allowNull: true,
},
postal_code: {
type: new DataTypes.STRING(),
allowNull: true,
},
city: {
type: new DataTypes.STRING(),
allowNull: true,
},
province: {
type: new DataTypes.STRING(),
allowNull: true,
},
country: {
type: new DataTypes.STRING(),
allowNull: true,
},
email: {
type: new DataTypes.STRING(),
allowNull: true,
},
phone: {
type: new DataTypes.STRING(),
allowNull: true,
},
},
{
sequelize,
tableName: "invoice_participant_addresses",
}
);
return InvoiceParticipantAddress_Model;
};

View File

@ -0,0 +1,45 @@
import { ExpressController } from "@rdx/core";
import { UniqueID } from "@rdx/ddd-domain";
import { CreateInvoiceUseCase } from "../../../application";
import { ICreateInvoiceRequestDTO } from "../../dto";
import { ICreateInvoicePresenter } from "./presenter";
export class CreateInvoiceController extends ExpressController {
public constructor(
private readonly createInvoice: CreateInvoiceUseCase,
private readonly presenter: ICreateInvoicePresenter
) {
super();
}
protected async executeImpl() {
const createDTO: ICreateInvoiceRequestDTO = this.req.body;
// Validar ID
const invoiceIdOrError = UniqueID.create(createDTO.id);
if (invoiceIdOrError.isFailure) return this.invalidInputError("Invoice ID not valid");
const invoiceOrError = await this.createInvoice.execute(invoiceIdOrError.data, createDTO);
if (invoiceOrError.isFailure) {
return this.handleError(invoiceOrError.error);
}
return this.ok(this.presenter.toDTO(invoiceOrError.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,17 @@
import { CreateInvoiceUseCase } from "#/server/application";
import { InvoiceService } from "#/server/domain";
import { invoiceRepository } from "#/server/intrastructure";
import { SequelizeTransactionManager } from "@rdx/core";
import { Sequelize } from "sequelize";
import { CreateInvoiceController } from "./create-invoice.controller";
import { createInvoicePresenter } from "./presenter";
export const buildCreateInvoiceController = (database: Sequelize) => {
const transactionManager = new SequelizeTransactionManager(database);
const invoiceService = new InvoiceService(invoiceRepository);
const useCase = new CreateInvoiceUseCase(invoiceService, transactionManager);
const presenter = createInvoicePresenter;
return new CreateInvoiceController(useCase, presenter);
};

View File

@ -0,0 +1,19 @@
import { InvoiceItem } from "@/contexts/invoicing/domain/InvoiceItems";
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
import { ICollection, IMoney_Response_DTO } from "@shared/contexts";
export const invoiceItemPresenter = (
items: ICollection<InvoiceItem>,
context: IInvoicingContext
) =>
items.totalCount > 0
? items.items.map((item: InvoiceItem) => ({
description: item.description.toString(),
quantity: item.quantity.toString(),
unit_measure: "",
unit_price: item.unitPrice.toPrimitive() as IMoney_Response_DTO,
subtotal: item.calculateSubtotal().toPrimitive() as IMoney_Response_DTO,
tax_amount: item.calculateTaxAmount().toPrimitive() as IMoney_Response_DTO,
total: item.calculateTotal().toPrimitive() as IMoney_Response_DTO,
}))
: [];

Some files were not shown because too many files have changed in this diff Show More