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
Reference in New Issue
Block a user