Inicial
This commit is contained in:
commit
d844f242de
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
||||
5
.eslintrc.js
Normal file
5
.eslintrc.js
Normal 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
36
.gitignore
vendored
Normal 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
|
||||
13
.prettierrc
Normal file
13
.prettierrc
Normal 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
60
.vscode/launch.json
vendored
Normal 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
30
.vscode/settings.json
vendored
Normal 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
75
README.md
Normal 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
11
apps/server/.env
Normal 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
|
||||
11
apps/server/.env.development
Normal file
11
apps/server/.env.development
Normal 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
47
apps/server/Dockerfile
Normal 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
|
||||
10
apps/server/eslint.config.js
Normal file
10
apps/server/eslint.config.js
Normal 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
77
apps/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
apps/server/src/__tests__/server.test.ts
Normal file
23
apps/server/src/__tests__/server.test.ts
Normal 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
56
apps/server/src/app.ts
Normal 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;
|
||||
}
|
||||
48
apps/server/src/config/database.ts
Normal file
48
apps/server/src/config/database.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/config/index.ts
Normal file
12
apps/server/src/config/index.ts
Normal 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",
|
||||
};
|
||||
66
apps/server/src/config/register-models.ts
Normal file
66
apps/server/src/config/register-models.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
2
apps/server/src/core/helpers/index.ts
Normal file
2
apps/server/src/core/helpers/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./module-loader";
|
||||
export * from "./service-registry";
|
||||
54
apps/server/src/core/helpers/model-loader.ts
Normal file
54
apps/server/src/core/helpers/model-loader.ts
Normal 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)`);
|
||||
};
|
||||
55
apps/server/src/core/helpers/module-loader.ts
Normal file
55
apps/server/src/core/helpers/module-loader.ts
Normal 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.`);
|
||||
};
|
||||
36
apps/server/src/core/helpers/service-registry.ts
Normal file
36
apps/server/src/core/helpers/service-registry.ts
Normal 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
137
apps/server/src/index.ts
Normal 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);
|
||||
9
apps/server/src/modules.ts
Normal file
9
apps/server/src/modules.ts
Normal 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
16
apps/server/tsconfig.json
Normal 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
5
apps/web/.eslintrc.cjs
Normal 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
13
apps/web/index.html
Normal 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
27
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
apps/web/public/typescript.svg
Normal file
1
apps/web/public/typescript.svg
Normal 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
1
apps/web/public/vite.svg
Normal 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
2
apps/web/src/main.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
import "./style.css";
|
||||
//# sourceMappingURL=main.d.ts.map
|
||||
1
apps/web/src/main.d.ts.map
Normal file
1
apps/web/src/main.d.ts.map
Normal 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
7
apps/web/src/main.js
Normal 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
25
apps/web/src/main.tsx
Normal 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
97
apps/web/src/style.css
Normal 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
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
7
apps/web/tsconfig.json
Normal file
7
apps/web/tsconfig.json
Normal 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
6
apps/web/vite.config.ts
Normal 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
29
docker-compose.yml
Normal 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
422
docs/API.md
Normal 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"
|
||||
}
|
||||
]
|
||||
```
|
||||
BIN
docs/DsRegistroVeriFactu.xlsx
Normal file
BIN
docs/DsRegistroVeriFactu.xlsx
Normal file
Binary file not shown.
84
docs/README.md
Normal file
84
docs/README.md
Normal 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
165
docs/REQUISITOS CLIENTES.md
Normal 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.
|
||||
|
||||
---
|
||||
207
docs/REQUISITOS GENERALES.md
Normal file
207
docs/REQUISITOS GENERALES.md
Normal 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)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
81
modules/invoices/package.json
Normal file
81
modules/invoices/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
modules/invoices/src/client/index.ts
Normal file
1
modules/invoices/src/client/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export default () => {};
|
||||
2
modules/invoices/src/index.ts
Normal file
2
modules/invoices/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
//export * from "./client";
|
||||
export * from "./server";
|
||||
@ -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
|
||||
);*/
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
5
modules/invoices/src/server/application/index.ts
Normal file
5
modules/invoices/src/server/application/index.ts
Normal 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";
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
1
modules/invoices/src/server/domain/aggregates/index.ts
Normal file
1
modules/invoices/src/server/domain/aggregates/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./invoice";
|
||||
203
modules/invoices/src/server/domain/aggregates/invoice.ts
Normal file
203
modules/invoices/src/server/domain/aggregates/invoice.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
2
modules/invoices/src/server/domain/entities/index.ts
Normal file
2
modules/invoices/src/server/domain/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./invoice-customer";
|
||||
export * from "./invoice-items";
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./invoice-address";
|
||||
export * from "./invoice-customer";
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./invoice-item";
|
||||
export * from "./invoice-items";
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
5
modules/invoices/src/server/domain/index.ts
Normal file
5
modules/invoices/src/server/domain/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./aggregates";
|
||||
export * from "./entities";
|
||||
export * from "./repositories";
|
||||
export * from "./services";
|
||||
export * from "./value-objects";
|
||||
1
modules/invoices/src/server/domain/repositories/index.ts
Normal file
1
modules/invoices/src/server/domain/repositories/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./invoice-repository.interface";
|
||||
@ -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>;
|
||||
}
|
||||
2
modules/invoices/src/server/domain/services/index.ts
Normal file
2
modules/invoices/src/server/domain/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./invoice-service.interface";
|
||||
export * from "./invoice.service";
|
||||
@ -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>>;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
28
modules/invoices/src/server/index.ts
Normal file
28
modules/invoices/src/server/index.ts
Normal 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: () => {},
|
||||
/*...*/
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./invoices.routes";
|
||||
@ -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);
|
||||
};
|
||||
3
modules/invoices/src/server/intrastructure/index.ts
Normal file
3
modules/invoices/src/server/intrastructure/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./express";
|
||||
export * from "./mappers";
|
||||
export * from "./sequelize";
|
||||
@ -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,
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./invoice.mapper";
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
@ -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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
@ -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
Loading…
Reference in New Issue
Block a user