From 4a73456c46c098edb3cf64e6c5c96e31d65ad19d Mon Sep 17 00:00:00 2001 From: david Date: Tue, 24 Jun 2025 20:38:57 +0200 Subject: [PATCH] Facturas de cliente --- .vscode/extensions.json | 2 +- .vscode/settings.json | 1 + .../presentation/dto/accounts.schemas.ts | 2 +- .../domain/value-objects/auth-user-roles.ts | 2 +- .../domain/value-objects/hash-password.ts | 2 +- .../domain/value-objects/plain-password.ts | 2 +- .../auth/domain/value-objects/token.ts | 2 +- .../auth/domain/value-objects/username.ts | 2 +- .../presentation/dto/auth.validation.dto.ts | 2 +- .../presentation/dto/user.validation.dto.ts | 2 +- .../dto/contacts.validation.dto.ts | 2 +- .../customer-invoices.validation.dto.ts | 2 +- .../dto/customers.validation.dto.ts | 2 +- apps/web/index.html | 2 +- apps/web/package.json | 2 +- apps/web/src/routes/app-routes.tsx | 5 +- biome.json | 2 +- docs/DTOS - GUIA DE ESTILO.md | 150 + .../value-objects/invoice-item-description.ts | 2 +- .../domain/value-objects/invoice-number.ts | 2 +- .../domain/value-objects/invoice-serie.ts | 2 +- .../presentation/dto/invoices.schemas.ts | 2 +- modules/core/package.json | 9 +- .../express => errors}/api-error.ts | 0 .../core/src/api/errors/conflict-api-error.ts | 12 + .../src/api/errors/domain-validation-error.ts | 29 + modules/core/src/api/errors/error-mapper.ts | 96 + .../src/api/errors/forbidden-api-error.ts | 12 + modules/core/src/api/errors/index.ts | 3 + .../core/src/api/errors/internal-api-error.ts | 12 + .../src/api/errors/not-found-api-error.ts | 12 + .../src/api/errors/unauthorized-api-error.ts | 12 + .../src/api/errors/unavailable-api-error.ts | 12 + .../src/api/errors/validation-api-error.ts | 13 + .../api/errors/validation-error-collection.ts | 33 + modules/core/src/api/index.ts | 1 + .../express/express-controller.ts | 126 +- .../src/api/infrastructure/express/index.ts | 2 - .../middlewares/global-error-handler.ts | 2 +- .../express/middlewares/index.ts | 1 + .../express/middlewares/validate-request.ts | 63 + .../express/validate-request-dto.ts | 38 - modules/core/src/common/dto/index.ts | 2 +- modules/core/src/common/dto/list.dto.ts | 15 - modules/core/src/common/dto/list.view.dto.ts | 18 + modules/core/src/common/dto/metadata.dto.ts | 42 +- modules/core/src/common/dto/money.dto.ts | 26 +- modules/core/src/common/dto/percentage.dto.ts | 7 +- modules/core/src/common/dto/quantity.dto.ts | 25 +- .../lib/data-source/datasource.interface.ts | 4 +- modules/customer-invoices/package.json | 7 +- .../create-customer-invoice.use-case.ts | 38 +- .../create-customer-invoice/index.ts | 1 + .../delete-customer-invoice.use-case.ts | 0 .../delete-customer-invoice/index.ts | 1 + .../get-customer-invoice.use-case.ts | 0 .../application/get-customer-invoice/index.ts | 1 + .../build-customer-invoice-from-dto.ts | 54 + .../build-customer-invoice-items-from-dto.ts | 83 + .../helpers/extract-or-push-error.ts | 45 + .../helpers/has-no-undefined-fields.ts | 26 + .../src/api/application/helpers/index.ts | 1 + .../src/api/application/index.ts | 10 +- .../list-customer-invoices.use-case.ts | 22 - .../list-customer-invoices/index.ts | 1 + .../list-customer-invoices.use-case.ts | 35 + .../InvoiceParticipant.presenter.ts.bak | 0 ...InvoiceParticipantAddress.presenter.ts.bak | 0 .../presenter/index.ts | 0 .../presenter/list-invoices.presenter.ts | 28 +- .../update-customer-invoice/index.ts | 1 + .../update-customer-invoice.use-case.ts | 0 .../create-customer-invoice.ts | 39 + .../create-customer-invoice/index.ts | 1 + .../delete-invoice.controller.ts.bak | 0 .../delete-customer-invoice}/index.ts.bak | 0 .../get-invoice.controller.ts | 0 .../get-customer-invoice}/index.ts | 0 .../presenter/InvoiceItem.presenter.ts.bak | 0 .../InvoiceParticipant.presenter.ts.bak | 0 ...InvoiceParticipantAddress.presenter.ts.bak | 0 .../presenter/get-invoice.presenter.ts | 0 .../get-customer-invoice}/presenter/index.ts | 0 .../src/api/controllers/index.ts | 5 + .../list-customer-invoices}/index.ts | 14 +- .../list-customer-invoices.controller.ts | 37 + .../update-customer-invoice}/index.ts.bak | 0 .../presenter/InvoiceItem.presenter.ts.bak | 0 .../InvoiceParticipant.presenter.ts.bak | 0 ...InvoiceParticipantAddress.presenter.ts.bak | 0 .../presenter/UpdateInvoice.presenter.ts.bak | 0 .../presenter/index.ts.bak | 0 .../update-invoice.controller.ts.bak | 0 .../api/domain/aggregates/customer-invoice.ts | 8 +- .../src/api/domain/errors/index.ts | 0 .../customer-invoices/src/api/domain/index.ts | 1 + .../customer-invoice-service.interface.ts | 6 +- .../services/customer-invoice.service.ts | 6 +- .../customer-invoice-item-description.ts | 18 +- .../value-objects/customer-invoice-number.ts | 20 +- .../value-objects/customer-invoice-serie.ts | 20 +- .../value-objects/customer-invoice-status.ts | 12 +- .../express/customer-invoices.routes.ts | 20 +- .../mappers/customer-invoice.mapper.ts | 4 +- .../src/api/presentation/index.ts | 5 - .../list-invoices/list-invoices.controller.ts | 38 - .../src/common/dto/common/index.ts | 0 .../dto/customer-invoices.request.dto.ts | 13 - .../common/dto/customer-invoices.schemas.ts | 2 +- .../customer-invoices/src/common/dto/index.ts | 5 +- .../create-customer-invoice.command.dto.ts | 31 + .../src/common/dto/request/index.ts | 2 + .../list-customer-invoices.query.dto.ts | 33 + .../update-customer-invoice.command.dto.ts | 0 .../customer-invoice-creation.result.dto.ts | 0 .../src/common/dto/response/index.ts | 1 + .../list-customer-invoices.result.dto.ts | 30 + .../web/components/customer-invoices-grid.tsx | 8 +- .../web/context/customer-invoices-context.tsx | 3 +- .../src/web/customer-invoice-routes.tsx | 17 +- .../customer-invoices/src/web/hooks/index.ts | 5 +- .../use-create-customer-invoice-mutation.ts | 23 + ....tsx => use-customer-invoices-context.tsx} | 0 .../web/hooks/use-customer-invoices-query.tsx | 25 + .../src/web/hooks/use-customer-invoices.tsx | 20 - .../src/web/invoice-routes.tsx | 60 - .../src/web/pages/create/create.tsx | 133 + .../src/web/pages/create/index.ts | 1 + .../web/pages/create/invoice-edit-form.tsx | 908 ++++++ .../src/web/pages/create/types.ts | 35 + .../src/web/pages/create/utils.ts | 41 + .../customer-invoices/src/web/pages/index.tsx | 1 + .../customer-invoices/src/web/pages/list.tsx | 2 +- .../src/value-objects/email-address.ts | 2 +- .../rdx-ddd-domain/src/value-objects/name.ts | 2 +- .../src/value-objects/percentage.ts | 2 +- .../src/value-objects/phone-number.ts | 2 +- .../src/value-objects/postal-address.ts | 2 +- .../src/value-objects/quantity.ts | 2 +- .../rdx-ddd-domain/src/value-objects/slug.ts | 2 +- .../src/value-objects/tin-number.ts | 2 +- .../src/value-objects/unique-id.ts | 4 +- .../src/value-objects/utc-date.ts | 2 +- packages/rdx-criteria/src/critera.ts | 27 +- .../value-objects/__tests__/utc-date.test.ts | 22 +- .../src/value-objects/email-address.ts | 2 +- packages/rdx-ddd/src/value-objects/name.ts | 2 +- .../rdx-ddd/src/value-objects/percentage.ts | 2 +- .../rdx-ddd/src/value-objects/phone-number.ts | 2 +- .../src/value-objects/postal-address.ts | 2 +- .../rdx-ddd/src/value-objects/quantity.ts | 2 +- packages/rdx-ddd/src/value-objects/slug.ts | 2 +- .../rdx-ddd/src/value-objects/tin-number.ts | 2 +- .../rdx-ddd/src/value-objects/unique-id.ts | 2 +- .../rdx-ddd/src/value-objects/utc-date.ts | 12 +- .../src/components/layout/app-breadcrumb.tsx | 2 +- .../src/components/layout/app-content.tsx | 6 +- .../src/components/layout/app-layout.tsx | 3 +- .../src/components/layout/data-table.tsx | 2 +- packages/shadcn-ui/package.json | 13 +- packages/shadcn-ui/src/components/card.tsx | 51 +- packages/typescript-config/root.json | 3 +- pnpm-lock.yaml | 2520 ++++++++++------- 163 files changed, 3899 insertions(+), 1630 deletions(-) create mode 100644 docs/DTOS - GUIA DE ESTILO.md rename modules/core/src/api/{infrastructure/express => errors}/api-error.ts (100%) create mode 100644 modules/core/src/api/errors/conflict-api-error.ts create mode 100644 modules/core/src/api/errors/domain-validation-error.ts create mode 100644 modules/core/src/api/errors/error-mapper.ts create mode 100644 modules/core/src/api/errors/forbidden-api-error.ts create mode 100644 modules/core/src/api/errors/index.ts create mode 100644 modules/core/src/api/errors/internal-api-error.ts create mode 100644 modules/core/src/api/errors/not-found-api-error.ts create mode 100644 modules/core/src/api/errors/unauthorized-api-error.ts create mode 100644 modules/core/src/api/errors/unavailable-api-error.ts create mode 100644 modules/core/src/api/errors/validation-api-error.ts create mode 100644 modules/core/src/api/errors/validation-error-collection.ts create mode 100644 modules/core/src/api/infrastructure/express/middlewares/validate-request.ts delete mode 100644 modules/core/src/api/infrastructure/express/validate-request-dto.ts delete mode 100644 modules/core/src/common/dto/list.dto.ts create mode 100644 modules/core/src/common/dto/list.view.dto.ts rename modules/customer-invoices/src/api/application/{ => create-customer-invoice}/create-customer-invoice.use-case.ts (83%) create mode 100644 modules/customer-invoices/src/api/application/create-customer-invoice/index.ts rename modules/customer-invoices/src/api/application/{ => delete-customer-invoice}/delete-customer-invoice.use-case.ts (100%) create mode 100644 modules/customer-invoices/src/api/application/delete-customer-invoice/index.ts rename modules/customer-invoices/src/api/application/{ => get-customer-invoice}/get-customer-invoice.use-case.ts (100%) create mode 100644 modules/customer-invoices/src/api/application/get-customer-invoice/index.ts create mode 100644 modules/customer-invoices/src/api/application/helpers/build-customer-invoice-from-dto.ts create mode 100644 modules/customer-invoices/src/api/application/helpers/build-customer-invoice-items-from-dto.ts create mode 100644 modules/customer-invoices/src/api/application/helpers/extract-or-push-error.ts create mode 100644 modules/customer-invoices/src/api/application/helpers/has-no-undefined-fields.ts create mode 100644 modules/customer-invoices/src/api/application/helpers/index.ts delete mode 100644 modules/customer-invoices/src/api/application/list-customer-invoices.use-case.ts create mode 100644 modules/customer-invoices/src/api/application/list-customer-invoices/index.ts create mode 100644 modules/customer-invoices/src/api/application/list-customer-invoices/list-customer-invoices.use-case.ts rename modules/customer-invoices/src/api/{presentation/list-invoices => application/list-customer-invoices}/presenter/InvoiceParticipant.presenter.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/list-invoices => application/list-customer-invoices}/presenter/InvoiceParticipantAddress.presenter.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/list-invoices => application/list-customer-invoices}/presenter/index.ts (100%) rename modules/customer-invoices/src/api/{presentation/list-invoices => application/list-customer-invoices}/presenter/list-invoices.presenter.ts (59%) create mode 100644 modules/customer-invoices/src/api/application/update-customer-invoice/index.ts rename modules/customer-invoices/src/api/application/{ => update-customer-invoice}/update-customer-invoice.use-case.ts (100%) create mode 100644 modules/customer-invoices/src/api/controllers/create-customer-invoice/create-customer-invoice.ts create mode 100644 modules/customer-invoices/src/api/controllers/create-customer-invoice/index.ts rename modules/customer-invoices/src/api/{presentation/delete-invoice => controllers/delete-customer-invoice}/delete-invoice.controller.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/delete-invoice => controllers/delete-customer-invoice}/index.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/get-invoice => controllers/get-customer-invoice}/get-invoice.controller.ts (100%) rename modules/customer-invoices/src/api/{presentation/get-invoice => controllers/get-customer-invoice}/index.ts (100%) rename modules/customer-invoices/src/api/{presentation/get-invoice => controllers/get-customer-invoice}/presenter/InvoiceItem.presenter.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/get-invoice => controllers/get-customer-invoice}/presenter/InvoiceParticipant.presenter.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/get-invoice => controllers/get-customer-invoice}/presenter/InvoiceParticipantAddress.presenter.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/get-invoice => controllers/get-customer-invoice}/presenter/get-invoice.presenter.ts (100%) rename modules/customer-invoices/src/api/{presentation/get-invoice => controllers/get-customer-invoice}/presenter/index.ts (100%) create mode 100644 modules/customer-invoices/src/api/controllers/index.ts rename modules/customer-invoices/src/api/{presentation/list-invoices => controllers/list-customer-invoices}/index.ts (66%) create mode 100644 modules/customer-invoices/src/api/controllers/list-customer-invoices/list-customer-invoices.controller.ts rename modules/customer-invoices/src/api/{presentation/update-invoice => controllers/update-customer-invoice}/index.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/update-invoice => controllers/update-customer-invoice}/presenter/InvoiceItem.presenter.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/update-invoice => controllers/update-customer-invoice}/presenter/InvoiceParticipant.presenter.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/update-invoice => controllers/update-customer-invoice}/presenter/InvoiceParticipantAddress.presenter.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/update-invoice => controllers/update-customer-invoice}/presenter/UpdateInvoice.presenter.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/update-invoice => controllers/update-customer-invoice}/presenter/index.ts.bak (100%) rename modules/customer-invoices/src/api/{presentation/update-invoice => controllers/update-customer-invoice}/update-invoice.controller.ts.bak (100%) create mode 100644 modules/customer-invoices/src/api/domain/errors/index.ts delete mode 100644 modules/customer-invoices/src/api/presentation/index.ts delete mode 100644 modules/customer-invoices/src/api/presentation/list-invoices/list-invoices.controller.ts create mode 100644 modules/customer-invoices/src/common/dto/common/index.ts create mode 100644 modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/request/index.ts create mode 100644 modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/request/update-customer-invoice.command.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/response/customer-invoice-creation.result.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/response/index.ts create mode 100644 modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts create mode 100644 modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts rename modules/customer-invoices/src/web/hooks/{customer-invoices-context.tsx => use-customer-invoices-context.tsx} (100%) create mode 100644 modules/customer-invoices/src/web/hooks/use-customer-invoices-query.tsx delete mode 100644 modules/customer-invoices/src/web/hooks/use-customer-invoices.tsx delete mode 100644 modules/customer-invoices/src/web/invoice-routes.tsx create mode 100644 modules/customer-invoices/src/web/pages/create/create.tsx create mode 100644 modules/customer-invoices/src/web/pages/create/index.ts create mode 100644 modules/customer-invoices/src/web/pages/create/invoice-edit-form.tsx create mode 100644 modules/customer-invoices/src/web/pages/create/types.ts create mode 100644 modules/customer-invoices/src/web/pages/create/utils.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 9069b50c..699ed733 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["esbenp.prettier-vscode", "biomejs.biome"] + "recommendations": ["biomejs.biome"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 853691d3..78bcc5e5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,6 +16,7 @@ "typescript.suggest.includeAutomaticOptionalChainCompletions": true, "typescript.suggestionActions.enabled": true, "typescript.preferences.importModuleSpecifier": "shortest", + "typescript.autoClosingTags": true, "editor.quickSuggestions": { "strings": "on" diff --git a/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts b/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts index fc0aa9ad..de6e5cda 100644 --- a/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts +++ b/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as z from "zod/v4"; export const ListAccountsRequestSchema = z.object({}); diff --git a/apps/server/archive/contexts/auth/domain/value-objects/auth-user-roles.ts b/apps/server/archive/contexts/auth/domain/value-objects/auth-user-roles.ts index 716584d3..ff328bf4 100644 --- a/apps/server/archive/contexts/auth/domain/value-objects/auth-user-roles.ts +++ b/apps/server/archive/contexts/auth/domain/value-objects/auth-user-roles.ts @@ -1,6 +1,6 @@ import { ValueObject } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { z } from "zod"; +import * as z from "zod/v4"; const RoleSchema = z.enum(["Admin", "User", "Manager", "Editor"]); diff --git a/apps/server/archive/contexts/auth/domain/value-objects/hash-password.ts b/apps/server/archive/contexts/auth/domain/value-objects/hash-password.ts index 21c37c9d..f7c2511d 100644 --- a/apps/server/archive/contexts/auth/domain/value-objects/hash-password.ts +++ b/apps/server/archive/contexts/auth/domain/value-objects/hash-password.ts @@ -1,7 +1,7 @@ import { ValueObject } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import bcrypt from "bcrypt"; -import { z } from "zod"; +import * as z from "zod/v4"; interface HashPasswordProps { value: string; diff --git a/apps/server/archive/contexts/auth/domain/value-objects/plain-password.ts b/apps/server/archive/contexts/auth/domain/value-objects/plain-password.ts index 8d666a1e..cdd92a53 100644 --- a/apps/server/archive/contexts/auth/domain/value-objects/plain-password.ts +++ b/apps/server/archive/contexts/auth/domain/value-objects/plain-password.ts @@ -1,6 +1,6 @@ import { ValueObject } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { z } from "zod"; +import * as z from "zod/v4"; interface PlainPasswordProps { value: string; diff --git a/apps/server/archive/contexts/auth/domain/value-objects/token.ts b/apps/server/archive/contexts/auth/domain/value-objects/token.ts index c6ebe57f..d989904b 100644 --- a/apps/server/archive/contexts/auth/domain/value-objects/token.ts +++ b/apps/server/archive/contexts/auth/domain/value-objects/token.ts @@ -1,6 +1,6 @@ import { ValueObject } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { z } from "zod"; +import * as z from "zod/v4"; interface TokenProps { value: string; diff --git a/apps/server/archive/contexts/auth/domain/value-objects/username.ts b/apps/server/archive/contexts/auth/domain/value-objects/username.ts index bec7b4ea..072e9b4c 100644 --- a/apps/server/archive/contexts/auth/domain/value-objects/username.ts +++ b/apps/server/archive/contexts/auth/domain/value-objects/username.ts @@ -1,6 +1,6 @@ import { ValueObject } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { z } from "zod"; +import * as z from "zod/v4"; interface UsernameProps { value: string; diff --git a/apps/server/archive/contexts/auth/presentation/dto/auth.validation.dto.ts b/apps/server/archive/contexts/auth/presentation/dto/auth.validation.dto.ts index 96b1a3a8..4ebccdf5 100644 --- a/apps/server/archive/contexts/auth/presentation/dto/auth.validation.dto.ts +++ b/apps/server/archive/contexts/auth/presentation/dto/auth.validation.dto.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as z from "zod/v4"; export const RegisterUserSchema = z.object({ username: z.string().min(3, "Username must be at least 3 characters long"), diff --git a/apps/server/archive/contexts/auth/presentation/dto/user.validation.dto.ts b/apps/server/archive/contexts/auth/presentation/dto/user.validation.dto.ts index 2ddd2864..f5852158 100644 --- a/apps/server/archive/contexts/auth/presentation/dto/user.validation.dto.ts +++ b/apps/server/archive/contexts/auth/presentation/dto/user.validation.dto.ts @@ -1,3 +1,3 @@ -import { z } from "zod"; +import * as z from "zod/v4"; export const ListUsersSchema = z.object({}); diff --git a/apps/server/archive/contexts/contacts/presentation/dto/contacts.validation.dto.ts b/apps/server/archive/contexts/contacts/presentation/dto/contacts.validation.dto.ts index c844dc9c..16b1b220 100644 --- a/apps/server/archive/contexts/contacts/presentation/dto/contacts.validation.dto.ts +++ b/apps/server/archive/contexts/contacts/presentation/dto/contacts.validation.dto.ts @@ -1,3 +1,3 @@ -import { z } from "zod"; +import * as z from "zod/v4"; export const ListContactsSchema = z.object({}); diff --git a/apps/server/archive/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts b/apps/server/archive/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts index e133753f..5de35fd1 100644 --- a/apps/server/archive/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts +++ b/apps/server/archive/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as z from "zod/v4"; export const ListCustomerInvoicesSchema = z.object({}); export const GetCustomerInvoiceSchema = z.object({}); diff --git a/apps/server/archive/contexts/customer-billing/presentation/dto/customers.validation.dto.ts b/apps/server/archive/contexts/customer-billing/presentation/dto/customers.validation.dto.ts index ba8d4eec..43dfc2b5 100644 --- a/apps/server/archive/contexts/customer-billing/presentation/dto/customers.validation.dto.ts +++ b/apps/server/archive/contexts/customer-billing/presentation/dto/customers.validation.dto.ts @@ -1,3 +1,3 @@ -import { z } from "zod"; +import * as z from "zod/v4"; export const ListCustomersSchema = z.object({}); diff --git a/apps/web/index.html b/apps/web/index.html index 692e8b38..10318ef9 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -13,7 +13,7 @@ -
+
diff --git a/apps/web/package.json b/apps/web/package.json index 8b50fbbc..656b9cdd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,7 +41,7 @@ "i18next-browser-languagedetector": "^8.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-hook-form": "^7.56.2", + "react-hook-form": "^7.56.4", "react-hook-form-persist": "^3.0.0", "react-i18next": "^15.0.1", "react-router-dom": "^6.26.0", diff --git a/apps/web/src/routes/app-routes.tsx b/apps/web/src/routes/app-routes.tsx index a43700b1..42479ff8 100644 --- a/apps/web/src/routes/app-routes.tsx +++ b/apps/web/src/routes/app-routes.tsx @@ -38,6 +38,7 @@ export const AppRoutes = (): JSX.Element => { + {/* Fallback Route */} }> {/* Auth Layout */} @@ -56,11 +57,7 @@ export const AppRoutes = (): JSX.Element => { } /> } /> } /> - } /> - - {/* Fallback Route */} - } /> diff --git a/biome.json b/biome.json index 75b48fde..2c39dc01 100644 --- a/biome.json +++ b/biome.json @@ -20,7 +20,7 @@ "recommended": true, "correctness": { "useExhaustiveDependencies": "info", - "noUnreachable": "off" + "noUnreachable": "warn" }, "complexity": { "noForEach": "off", diff --git a/docs/DTOS - GUIA DE ESTILO.md b/docs/DTOS - GUIA DE ESTILO.md new file mode 100644 index 00000000..f2cfdcfe --- /dev/null +++ b/docs/DTOS - GUIA DE ESTILO.md @@ -0,0 +1,150 @@ +# Guía de estilo — DTOs y organización de carpetas + +> **Objetivo:** asegurar que **todos** los equipos usen un vocabulario y una estructura de ficheros idéntica al modelar, versionar y serializar los *Data Transfer Objects* (DTO). +> Esta guía aplica a cualquier API HTTP/JSON implementada en Express + TypeScript que siga DDD, SOLID y CQRS. + +--- + +## 1. Estructura de carpetas obligatoria + +```text +src/ +└─ / (ej. billing/) + └─ api/ + └─ dto/ + ├─ common/ ← Tipos reutilizables (MoneyDTO, AddressDTO…) + ├─ request/ ← Sólo comandos y queries + │ ├─ *.command.dto.ts + │ └─ *.query.dto.ts + │ + └─ response/ ← Sólo resultados/vistas + ├─ *.result.dto.ts + └─ *.view.dto.ts + +``` + +*Alias TS recomendado*: `@/dto/*` → `src//api/dto/*`. + +--- + +## 2. Convención de nombres + +| Categoría | Sufijo **obligatorio** | Descripción & ejemplos | +|-----------|------------------------|------------------------| +| **Comandos** (mutaciones) | `…CommandDTO` | `CreateInvoiceCommandDTO`, `UpdateInvoiceCommandDTO`, `DeleteInvoiceCommandDTO`, `ChangeInvoiceStatusCommandDTO` | +| **Queries** (lecturas con filtros) | `…QueryDTO` | `ListInvoicesQueryDTO`, `GetInvoiceByIdQueryDTO` | +| **Resultados** (respuesta de comandos) | `…ResultDTO` | `InvoiceCreationResultDTO`, `InvoiceDeletionResultDTO` | +| **Vistas** (respuesta de queries) | `…ViewDTO` | `InvoiceViewDTO`, `InvoiceSummaryViewDTO` | +| **Tipos comunes** | `…DTO` | `MoneyDTO`, `PaginationMetaDTO`, `AddressDTO` | + +*Regla de oro:* **No existe ningún DTO sin sufijo, salvo los tipos comunes dentro de `common/`.** + +--- + +## 3. Reglas de contenido + +1. **Solo datos planos**: números, cadenas, literales, arrays; nada de lógica. +2. **Fechas** en ISO-8601 UTC (`yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`). +3. **Enums** expuestos como _string literal_ en `snake_case` o `UPPER_SNAKE_CASE`; evita números mágicos. +4. **Moneda** + - **Siempre** con la estructura común: + +```ts +export interface MoneyDTO { + amount: number | null; // unidades mínimas (ej. céntimos) + scale: number; // nº de decimales (2 = céntimos) + currency_code: string; // ISO-4217 (“EUR”) +} +``` + + - Se importa desde `dto/common/money.dto.ts`. + +--- + +## 4. Guía de mapeo + +| Dirección | Componente responsable | Ubicación | +|-----------|------------------------|-----------| +| **DTO → Dominio** | `…CommandMapper` / `…QueryMapper` | `src//application/mappers/` | +| **Dominio → DTO** | `…ResultMapper` / `…ViewMapper` | mismo directorio | + +Cada mapper implementa **una** función pública: + +```ts +interface InvoiceCreationResultMapper { + toResult(entity: Invoice): InvoiceCreationResultDTO; +} +``` + +--- + +## 5. Validación y versiones + +1. **Validación de entrada** + - Usa `class-validator` o Zod en el *controller*; nunca en el dominio. + - Convierte a Value Objects una vez que el DTO pasó la validación. + +2. **Versionado** + - Añade sufijos de versión en el **archivo**, no en el nombre de la interfaz: + `invoice.view.v2.dto.ts` → `export interface InvoiceViewV2DTO { … }` + - Mantén las versiones anteriores durante **≥1 release** o hasta que los consumidores migren. + +--- + +## 6. Ejemplo completo (creación de factura) + +```text +billing/ +├─ api/ +│ └─ dto/ +│ ├─ input/create-invoice.command.dto.ts +│ ├─ output/invoice-creation.result.dto.ts +│ └─ common/money.dto.ts +└─ application/ + └─ mappers/ + └─ invoice-creation.result.mapper.ts +``` + +```ts +// create-invoice.command.dto.ts +export interface CreateInvoiceCommandDTO { + customerId: string; + issueDate: string; + lines: ReadonlyArray<{ + description: string; + quantity: number; + unitPrice: MoneyDTO; + }>; +} + +// invoice-creation.result.dto.ts +export interface InvoiceCreationResultDTO { + invoiceId: string; + number: string; + totalAmount: MoneyDTO; + createdAt: string; +} +``` + +--- + +## 7. Checklist antes de hacer *merge* + +- [ ] Archivo ubicado en la carpeta correcta. +- [ ] Sufijo conforme (**CommandDTO**, **QueryDTO**, **ResultDTO**, **ViewDTO**). +- [ ] Todos los importes usan `MoneyDTO`. +- [ ] Tipos opcionales marcados con `?` y comentados. +- [ ] **Sin** lógica, constructores ni métodos. +- [ ] PR incluye al menos un test de mapper (input ⇄ dominio ⇄ output). + +--- + +## 8. Tabla resumen + +| Carpeta | Sufijo | Ejemplo clásico | +|---------|--------|-----------------| +| `dto/input/` | `CommandDTO` | `DeleteInvoiceCommandDTO` | +| `dto/input/` | `QueryDTO` | `ListInvoicesQueryDTO` | +| `dto/output/` | `ResultDTO` | `InvoiceDeletionResultDTO` | +| `dto/output/` | `ViewDTO` | `InvoiceSummaryViewDTO` | +| `dto/common/` | `DTO` | `MoneyDTO` | diff --git a/modules.bak/invoices/src/server/domain/value-objects/invoice-item-description.ts b/modules.bak/invoices/src/server/domain/value-objects/invoice-item-description.ts index 9cd47653..0e3aae95 100644 --- a/modules.bak/invoices/src/server/domain/value-objects/invoice-item-description.ts +++ b/modules.bak/invoices/src/server/domain/value-objects/invoice-item-description.ts @@ -1,6 +1,6 @@ import { ValueObject } from "core/common/domain"; import { Maybe, Result } from "core/common/helpers"; -import { z } from "zod"; +import * as z from "zod/v4"; interface IInvoiceItemDescriptionProps { value: string; diff --git a/modules.bak/invoices/src/server/domain/value-objects/invoice-number.ts b/modules.bak/invoices/src/server/domain/value-objects/invoice-number.ts index 92341077..87bcbc9b 100644 --- a/modules.bak/invoices/src/server/domain/value-objects/invoice-number.ts +++ b/modules.bak/invoices/src/server/domain/value-objects/invoice-number.ts @@ -1,6 +1,6 @@ import { ValueObject } from "core/common/domain"; import { Result } from "core/common/helpers"; -import { z } from "zod"; +import * as z from "zod/v4"; interface IInvoiceNumberProps { value: string; diff --git a/modules.bak/invoices/src/server/domain/value-objects/invoice-serie.ts b/modules.bak/invoices/src/server/domain/value-objects/invoice-serie.ts index a4da6559..f0ead545 100644 --- a/modules.bak/invoices/src/server/domain/value-objects/invoice-serie.ts +++ b/modules.bak/invoices/src/server/domain/value-objects/invoice-serie.ts @@ -1,6 +1,6 @@ import { ValueObject } from "core/common/domain"; import { Maybe, Result } from "core/common/helpers"; -import { z } from "zod"; +import * as z from "zod/v4"; interface IInvoiceSerieProps { value: string; diff --git a/modules.bak/invoices/src/server/presentation/dto/invoices.schemas.ts b/modules.bak/invoices/src/server/presentation/dto/invoices.schemas.ts index e07ec582..b887ec47 100644 --- a/modules.bak/invoices/src/server/presentation/dto/invoices.schemas.ts +++ b/modules.bak/invoices/src/server/presentation/dto/invoices.schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as z from "zod/v4"; export const ICreateInvoiceRequestSchema = z.object({ id: z.string().uuid(), diff --git a/modules/core/package.json b/modules/core/package.json index ed7fe2e7..654276ca 100644 --- a/modules/core/package.json +++ b/modules/core/package.json @@ -14,18 +14,21 @@ "@types/jest": "29.5.14", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.3", - "typescript": "^5.8.3" + "ts-to-zod": "^3.15.0", + "typescript": "^5.8.3", + "zod-to-ts": "^1.2.0" }, "dependencies": { + "@repo/rdx-criteria": "workspace:*", "@repo/rdx-ddd": "workspace:*", "@repo/rdx-utils": "workspace:*", - "@repo/rdx-criteria": "workspace:*", "@tanstack/react-query": "^5.75.4", "axios": "^1.9.0", "http-status": "^2.1.0", "joi": "^17.13.3", "libphonenumber-js": "^1.11.20", "react-router-dom": "^6.26.0", - "sequelize": "^6.37.5" + "sequelize": "^6.37.5", + "zod": "^3.25.67" } } diff --git a/modules/core/src/api/infrastructure/express/api-error.ts b/modules/core/src/api/errors/api-error.ts similarity index 100% rename from modules/core/src/api/infrastructure/express/api-error.ts rename to modules/core/src/api/errors/api-error.ts diff --git a/modules/core/src/api/errors/conflict-api-error.ts b/modules/core/src/api/errors/conflict-api-error.ts new file mode 100644 index 00000000..bd903221 --- /dev/null +++ b/modules/core/src/api/errors/conflict-api-error.ts @@ -0,0 +1,12 @@ +import { ApiError } from "./api-error"; + +export class ConflictApiError extends ApiError { + constructor(detail: string) { + super({ + status: 409, + title: "Conflict", + detail, + type: "https://httpstatuses.com/409", + }); + } +} \ No newline at end of file diff --git a/modules/core/src/api/errors/domain-validation-error.ts b/modules/core/src/api/errors/domain-validation-error.ts new file mode 100644 index 00000000..ed4fa698 --- /dev/null +++ b/modules/core/src/api/errors/domain-validation-error.ts @@ -0,0 +1,29 @@ +/** + * Clase DomainValidationError + * Representa un error de validación de dominio. + * + * Esta clase extiende la clase Error de JavaScript y se utiliza para manejar errores + * específicos de validación dentro del dominio de la aplicación. Permite identificar + * el código de error, el campo afectado y un detalle descriptivo del error. + * + * @class DomainValidationError + * @extends {Error} + * @property {string} code - Código del error de validación. + * @property {string} field - Campo afectado por el error de validación. + * @property {string} detail - Detalle descriptivo del error de validación. + * + * @example + * const error = new DomainValidationError("INVALID_EMAIL", "email", "El email no es válido"); + * console.error(error); + */ + +export class DomainValidationError extends Error { + constructor( + public readonly code: string, + public readonly field: string, + public readonly detail: string + ) { + super(`[${field}] ${detail}`); + this.name = "DomainValidationError"; + } +} diff --git a/modules/core/src/api/errors/error-mapper.ts b/modules/core/src/api/errors/error-mapper.ts new file mode 100644 index 00000000..e585c8ac --- /dev/null +++ b/modules/core/src/api/errors/error-mapper.ts @@ -0,0 +1,96 @@ +import { ConnectionError, UniqueConstraintError } from "sequelize"; + +import { ApiError } from "./api-error"; +import { ConflictApiError } from "./conflict-api-error"; +import { DomainValidationError } from "./domain-validation-error"; +import { ForbiddenApiError } from "./forbidden-api-error"; +import { InternalApiError } from "./internal-api-error"; +import { NotFoundApiError } from "./not-found-api-error"; +import { UnauthorizedApiError } from "./unauthorized-api-error"; +import { UnavailableApiError } from "./unavailable-api-error"; +import { ValidationApiError } from "./validation-api-error"; +import { ValidationErrorCollection } from "./validation-error-collection"; + +/** + * Mapea errores de la aplicación a errores de la API. + * + * Esta función toma un error de la aplicación y lo convierte en un objeto ApiError + * adecuado para enviar como respuesta HTTP. Maneja errores comunes como validación, + * conflictos, no encontrados, autenticación y errores de infraestructura. + * + * @param error - El error de la aplicación a mapear. + * @returns Un objeto ApiError que representa el error mapeado. + * @example + * const error = new Error("Invalid input"); + * const apiError = errorMapper.toApiError(error); + * console.log(apiError); + * // Output: ValidationApiError { status: 422, title: 'Validation Failed', detail: 'Invalid input', type: 'https://httpstatuses.com/422' } + * @throws {ApiError} Si el error no puede ser mapeado a un tipo conocido. + * @see ApiError + * @see ValidationApiError + */ + +export const errorMapper = { + toApiError: (error: Error): ApiError => { + const message = error.message || "An unexpected error occurred"; + + // 1. 🔍 Errores de validación complejos (agrupados) + if (error instanceof ValidationErrorCollection) { + return new ValidationApiError(error.message, error.details); + } + + // 2. 🔍 Errores individuales de validación de dominio + if (error instanceof DomainValidationError) { + return new ValidationApiError(error.detail, [{ path: error.field, message: error.detail }]); + } + + // 3. 🔍 Errores individuales de validación + if ( + message.includes("invalid") || + message.includes("is not valid") || + message.includes("must be") || + message.includes("cannot be") || + message.includes("empty") + ) { + return new ValidationApiError(message); + } + + // 4. 🔍 Recurso no encontrado + if (error.name === "NotFoundError" || message.includes("not found")) { + return new NotFoundApiError(message); + } + + // 5. 🔍 Conflicto (por ejemplo, duplicado) + if ( + error.name === "ConflictError" || + error instanceof UniqueConstraintError || + message.includes("already exists") || + message.includes("duplicate key") + ) { + return new ConflictApiError(message); + } + + // 6. 🔍 No autenticado + if (error.name === "UnauthorizedError" || message.includes("unauthorized")) { + return new UnauthorizedApiError(message); + } + + // 7. 🔍 Prohibido + if (error.name === "ForbiddenError" || message.includes("forbidden")) { + return new ForbiddenApiError(message); + } + + // 8. 🔍 Error de conexión o indisponibilidad de servicio + if ( + error instanceof ConnectionError || + message.includes("Database connection lost") || + message.includes("timeout") || + message.includes("ECONNREFUSED") + ) { + return new UnavailableApiError("Service temporarily unavailable."); + } + + // 9. 🔍 Fallback: error no identificado + return new InternalApiError(`Unexpected error: ${message}`); + }, +}; diff --git a/modules/core/src/api/errors/forbidden-api-error.ts b/modules/core/src/api/errors/forbidden-api-error.ts new file mode 100644 index 00000000..c833751f --- /dev/null +++ b/modules/core/src/api/errors/forbidden-api-error.ts @@ -0,0 +1,12 @@ +import { ApiError } from "./api-error"; + +export class ForbiddenApiError extends ApiError { + constructor(detail: string) { + super({ + status: 403, + title: "Forbidden", + detail, + type: "https://httpstatuses.com/403", + }); + } +} \ No newline at end of file diff --git a/modules/core/src/api/errors/index.ts b/modules/core/src/api/errors/index.ts new file mode 100644 index 00000000..6601beee --- /dev/null +++ b/modules/core/src/api/errors/index.ts @@ -0,0 +1,3 @@ +export * from "./domain-validation-error"; +export * from "./error-mapper"; +export * from "./validation-error-collection"; diff --git a/modules/core/src/api/errors/internal-api-error.ts b/modules/core/src/api/errors/internal-api-error.ts new file mode 100644 index 00000000..2c511d0c --- /dev/null +++ b/modules/core/src/api/errors/internal-api-error.ts @@ -0,0 +1,12 @@ +import { ApiError } from "./api-error"; + +export class InternalApiError extends ApiError { + constructor(detail: string) { + super({ + status: 500, + title: "Internal Server Error", + detail, + type: "https://httpstatuses.com/500", + }); + } +} \ No newline at end of file diff --git a/modules/core/src/api/errors/not-found-api-error.ts b/modules/core/src/api/errors/not-found-api-error.ts new file mode 100644 index 00000000..f2cd605d --- /dev/null +++ b/modules/core/src/api/errors/not-found-api-error.ts @@ -0,0 +1,12 @@ +import { ApiError } from "./api-error"; + +export class NotFoundApiError extends ApiError { + constructor(detail: string) { + super({ + status: 404, + title: "Resource Not Found", + detail, + type: "https://httpstatuses.com/404", + }); + } +} \ No newline at end of file diff --git a/modules/core/src/api/errors/unauthorized-api-error.ts b/modules/core/src/api/errors/unauthorized-api-error.ts new file mode 100644 index 00000000..d36453cd --- /dev/null +++ b/modules/core/src/api/errors/unauthorized-api-error.ts @@ -0,0 +1,12 @@ +import { ApiError } from "./api-error"; + +export class UnauthorizedApiError extends ApiError { + constructor(detail: string) { + super({ + status: 401, + title: "Unauthorized", + detail, + type: "https://httpstatuses.com/401", + }); + } +} \ No newline at end of file diff --git a/modules/core/src/api/errors/unavailable-api-error.ts b/modules/core/src/api/errors/unavailable-api-error.ts new file mode 100644 index 00000000..8edf9edd --- /dev/null +++ b/modules/core/src/api/errors/unavailable-api-error.ts @@ -0,0 +1,12 @@ +import { ApiError } from "./api-error"; + +export class UnavailableApiError extends ApiError { + constructor(detail: string) { + super({ + status: 503, + title: "Service Unavailable", + detail, + type: "https://httpstatuses.com/503", + }); + } +} \ No newline at end of file diff --git a/modules/core/src/api/errors/validation-api-error.ts b/modules/core/src/api/errors/validation-api-error.ts new file mode 100644 index 00000000..9498e62b --- /dev/null +++ b/modules/core/src/api/errors/validation-api-error.ts @@ -0,0 +1,13 @@ +import { ApiError } from "./api-error"; + +export class ValidationApiError extends ApiError { + constructor(detail: string, errors?: any[]) { + super({ + status: 422, + title: "Validation Failed", + detail, + type: "https://httpstatuses.com/422", + errors, + }); + } +} \ No newline at end of file diff --git a/modules/core/src/api/errors/validation-error-collection.ts b/modules/core/src/api/errors/validation-error-collection.ts new file mode 100644 index 00000000..890f8b5b --- /dev/null +++ b/modules/core/src/api/errors/validation-error-collection.ts @@ -0,0 +1,33 @@ +/** + * ValidationErrorCollection + * + * Esta clase representa un error de validación que agrega múltiples detalles de errores + * en un formato estructurado. Es útil para manejar múltiples errores de validación + * en una sola respuesta, permitiendo a los clientes entender qué campos fallaron y por qué. + * + * Ejemplo de uso: + * + * const errors: ValidationErrorDetail[] = [ + * { path: "lines[1].unitPrice.amount", message: "Amount must be a positive number" }, + * { path: "lines[1].unitPrice.scale", message: "Scale must be a non-negative integer" }, + * ]; + * const validationError = new ValidationErrorCollection(errors); + * + */ + +export interface ValidationErrorDetail { + path: string; // ejemplo: "lines[1].unitPrice.amount" + message: string; // ejemplo: "Amount must be a positive number" +} + +export class ValidationErrorCollection extends Error { + public readonly details: ValidationErrorDetail[]; + + constructor(details: ValidationErrorDetail[]) { + super("Validation failed"); + Object.setPrototypeOf(this, ValidationErrorCollection.prototype); + + this.name = "ValidationErrorCollection"; + this.details = details; + } +} diff --git a/modules/core/src/api/index.ts b/modules/core/src/api/index.ts index 078bda6c..d140fc71 100644 --- a/modules/core/src/api/index.ts +++ b/modules/core/src/api/index.ts @@ -1,3 +1,4 @@ +export * from "./errors"; export * from "./infrastructure"; export * from "./logger"; export * from "./modules"; diff --git a/modules/core/src/api/infrastructure/express/express-controller.ts b/modules/core/src/api/infrastructure/express/express-controller.ts index 12cb0712..0f3f5eca 100644 --- a/modules/core/src/api/infrastructure/express/express-controller.ts +++ b/modules/core/src/api/infrastructure/express/express-controller.ts @@ -1,7 +1,16 @@ import { Criteria, CriteriaFromUrlConverter } from "@repo/rdx-criteria/server"; import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; -import { ApiError } from "./api-error"; +import { + ApiError, + ConflictApiError, + ForbiddenApiError, + InternalApiError, + NotFoundApiError, + UnauthorizedApiError, + UnavailableApiError, + ValidationApiError, +} from "../../errors"; export abstract class ExpressController { protected req!: Request; //| AuthenticatedRequest | TabContextRequest; @@ -16,140 +25,99 @@ export abstract class ExpressController { protected abstract executeImpl(): Promise; protected ok(dto?: T) { - return dto ? this.res.status(httpStatus.OK).json(dto) : this.res.status(httpStatus.OK).send(); + return dto ? this.res.status(httpStatus.OK).json(dto) : this.res.sendStatus(httpStatus.OK); } protected created(dto?: T) { return dto ? this.res.status(httpStatus.CREATED).json(dto) - : this.res.status(httpStatus.CREATED).send(); + : this.res.sendStatus(httpStatus.CREATED); } protected noContent() { - return this.res.status(httpStatus.NO_CONTENT).send(); + return this.res.sendStatus(httpStatus.NO_CONTENT); } /** - * 🔹 Respuesta para errores de cliente (400 Bad Request) + * Respuesta para errores de cliente (400 Bad Request) */ - public clientError(message: string, errors?: any[] | any) { + protected clientError(message: string, errors?: any[] | any) { return ExpressController.errorResponse( - new ApiError({ - status: 400, - title: "Bad Request", - detail: message, - errors: Array.isArray(errors) ? errors : [errors], - }), + new ValidationApiError(message, Array.isArray(errors) ? errors : [errors]), this.res ); } /** - * 🔹 Respuesta para errores de autenticación (401 Unauthorized) + * Respuesta para errores de autenticación (401 Unauthorized) */ protected unauthorizedError(message?: string) { return ExpressController.errorResponse( - new ApiError({ - status: 401, - title: httpStatus["401"], - name: httpStatus["401_NAME"], - detail: message ?? httpStatus["401_MESSAGE"], - }), + new UnauthorizedApiError(message ?? "Unauthorized"), this.res ); } - /** - * 🔹 Respuesta para errores de autorización (403 Forbidden) + * Respuesta para errores de autorización (403 Forbidden) */ protected forbiddenError(message?: string) { return ExpressController.errorResponse( - new ApiError({ - status: 403, - title: "Forbidden", - detail: message ?? "You do not have permission to perform this action.", - }), + new ForbiddenApiError(message ?? "You do not have permission to perform this action."), this.res ); } /** - * 🔹 Respuesta para recursos no encontrados (404 Not Found) + * Respuesta para recursos no encontrados (404 Not Found) */ protected notFoundError(message: string) { - return ExpressController.errorResponse( - new ApiError({ - status: 404, - title: "Not Found", - detail: message, - }), - this.res - ); + return ExpressController.errorResponse(new NotFoundApiError(message), this.res); } /** - * 🔹 Respuesta para conflictos (409 Conflict) + * Respuesta para conflictos (409 Conflict) */ protected conflictError(message: string, errors?: any[]) { - return ExpressController.errorResponse( - new ApiError({ - status: 409, - title: "Conflict", - detail: message, - errors, - }), - this.res - ); + return ExpressController.errorResponse(new ConflictApiError(message), this.res); } /** - * 🔹 Respuesta para errores de validación de entrada (422 Unprocessable Entity) + * Respuesta para errores de validación de entrada (422 Unprocessable Entity) */ protected invalidInputError(message: string, errors?: any[]) { - return ExpressController.errorResponse( - new ApiError({ - status: 422, - title: httpStatus["422"], - name: httpStatus["422_NAME"], - detail: message ?? httpStatus["422_MESSAGE"], - errors, - }), - this.res - ); + return ExpressController.errorResponse(new ValidationApiError(message, errors), this.res); } /** * Respuesta para errores de servidor no disponible (503 Service Unavailable) - * @param message - * @returns */ protected unavailableError(message?: string) { return ExpressController.errorResponse( - new ApiError({ - status: 503, - title: httpStatus["503"], - name: httpStatus["503_NAME"], - detail: message ?? httpStatus["503_MESSAGE"], - }), + new UnavailableApiError(message ?? "Service temporarily unavailable."), + this.res + ); + } + /** + * Respuesta para errores internos del servidor (500 Internal Server Error) + */ + protected internalServerError(message?: string) { + return ExpressController.errorResponse( + new InternalApiError(message ?? "Internal Server Error"), this.res ); } /** - * 🔹 Respuesta para errores internos del servidor (500 Internal Server Error) + * Respuesta para cualquier error de la API */ - protected internalServerError(message?: string) { - return ExpressController.errorResponse( - new ApiError({ - status: 500, - title: httpStatus["500"], - name: httpStatus["500_NAME"], - detail: message ?? httpStatus["500_MESSAGE"], - }), - this.res - ); + protected handleApiError(apiError: ApiError) { + return ExpressController.errorResponse(apiError, this.res); } + /** + * Método principal que se invoca desde el router de Express. + * Maneja la conversión de la URL a criterios y llama a executeImpl. + */ public execute(req: Request, res: Response, next: NextFunction): void { this.req = req; this.res = res; @@ -161,8 +129,12 @@ export abstract class ExpressController { this.executeImpl(); } catch (error: unknown) { - const _error = error as Error; - this.internalServerError(_error.message); + const err = error as Error; + if (err instanceof ApiError) { + ExpressController.errorResponse(err, this.res); + } else { + ExpressController.errorResponse(new InternalApiError(err.message), this.res); + } } } } diff --git a/modules/core/src/api/infrastructure/express/index.ts b/modules/core/src/api/infrastructure/express/index.ts index 3331f2c3..05f51dad 100644 --- a/modules/core/src/api/infrastructure/express/index.ts +++ b/modules/core/src/api/infrastructure/express/index.ts @@ -1,4 +1,2 @@ -export * from "./api-error"; export * from "./express-controller"; export * from "./middlewares"; -export * from "./validate-request-dto"; diff --git a/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts b/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts index a3dd599e..59742485 100644 --- a/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts +++ b/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from "express"; -import { ApiError } from "../api-error"; +import { ApiError } from "../../../errors/api-error"; export const globalErrorHandler = async ( error: Error, diff --git a/modules/core/src/api/infrastructure/express/middlewares/index.ts b/modules/core/src/api/infrastructure/express/middlewares/index.ts index de655522..0d5d108a 100644 --- a/modules/core/src/api/infrastructure/express/middlewares/index.ts +++ b/modules/core/src/api/infrastructure/express/middlewares/index.ts @@ -1 +1,2 @@ export * from "./global-error-handler"; +export * from "./validate-request"; diff --git a/modules/core/src/api/infrastructure/express/middlewares/validate-request.ts b/modules/core/src/api/infrastructure/express/middlewares/validate-request.ts new file mode 100644 index 00000000..b17d61a4 --- /dev/null +++ b/modules/core/src/api/infrastructure/express/middlewares/validate-request.ts @@ -0,0 +1,63 @@ +// src/common/middlewares/validate-dto.ts +import { ValidationApiError } from "@erp/core/api"; +import { RequestHandler } from "express"; +import { ZodSchema } from "zod/v4"; + +/** + * Middleware genérico para validar un objeto de Express + * (`body`, `query`, o `params`) mediante un esquema Zod. + * + * @param schema Esquema Zod que valida la entrada. + * @param source Parte del request que se valida ('body' | 'query' | 'params'). + * Por defecto, 'body'. + * @param options Opciones adicionales: + * - `sanitize`: Si se debe reescribir `req.body` con los datos validados. + * + * @example + * router.post('/invoices', + * validateZod(CreateInvoiceCommandSchema), // body + * controller + * ); + * + * router.get('/invoices', + * validateZod(ListInvoicesQuerySchema, 'query'), // query + * controller + * ); + * + * router.patch('/invoices/:id/status', + * validateZod(ChangeStatusParamsSchema, 'params'), // params + * controller + * ); + */ + +export type ValidateRequestWithSchemaOptions = { + sanitize?: boolean; // Si se debe reescribir req.body con los datos validados +}; + +export const validateRequest = ( + schema: ZodSchema, + source: T = "body" as T, + options: ValidateRequestWithSchemaOptions = { sanitize: true } +): RequestHandler => { + return async (req, res, next) => { + console.debug(`Validating request ${source} with schema:`, schema); + const result = schema.safeParse(req[source]); + + if (!result.success) { + // Construye errores detallados + const validationErrors = result.error.errors.map((err) => ({ + field: err.path.join("."), + message: err.message, + })); + + return new ValidationApiError("Validation failed", validationErrors); + } + + // Si pasa la validación, opcionalmente reescribe req.body + if (options?.sanitize ?? true) { + req[source] = result.data; + } + + next(); + }; +}; diff --git a/modules/core/src/api/infrastructure/express/validate-request-dto.ts b/modules/core/src/api/infrastructure/express/validate-request-dto.ts deleted file mode 100644 index 7ff5f042..00000000 --- a/modules/core/src/api/infrastructure/express/validate-request-dto.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import httpStatus from "http-status"; -import { ZodSchema } from "zod"; -import { ApiError } from "./api-error"; - -export const validateAndParseBody = - (schema: ZodSchema, options?: { sanitize?: boolean }) => - (req: Request, res: Response, next: NextFunction) => { - const result = schema.safeParse(req.body); - try { - if (!result.success) { - // Construye errores detallados - const validationErrors = result.error.errors.map((err) => ({ - field: err.path.join("."), - message: err.message, - })); - - throw new ApiError({ - status: httpStatus.BAD_REQUEST, //400 - title: "Validation Error", - detail: "Algunos campos no cumplen con los criterios de validación.", - type: "https://example.com/probs/validation-error", - instance: req.originalUrl, - errors: validationErrors, - }); - } - - // Si pasa la validación, opcionalmente reescribe req.body - if (options?.sanitize ?? true) { - req.body = result.data; - } - - next(); - } catch (error: unknown) { - // Si ocurre un error, delega al manejador de errores global - next(error as ApiError); - } - }; diff --git a/modules/core/src/common/dto/index.ts b/modules/core/src/common/dto/index.ts index a02fd240..87908f38 100644 --- a/modules/core/src/common/dto/index.ts +++ b/modules/core/src/common/dto/index.ts @@ -1,5 +1,5 @@ export * from "./error.dto"; -export * from "./list.dto"; +export * from "./list.view.dto"; export * from "./metadata.dto"; export * from "./money.dto"; export * from "./percentage.dto"; diff --git a/modules/core/src/common/dto/list.dto.ts b/modules/core/src/common/dto/list.dto.ts deleted file mode 100644 index 1608bdb8..00000000 --- a/modules/core/src/common/dto/list.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface IListResponseDTO { - page: number; - per_page: number; - total_pages: number; - total_items: number; - items: T[]; -} - -export const isResponseAListDTO = (data: any): data is IListResponseDTO => { - return data && typeof data.total_items === "number"; -}; - -export const existsMoreReponsePages = (response: any): response is IListResponseDTO => { - return isResponseAListDTO(response) && response.page + 1 < response.total_pages; -}; diff --git a/modules/core/src/common/dto/list.view.dto.ts b/modules/core/src/common/dto/list.view.dto.ts new file mode 100644 index 00000000..07dc3ae2 --- /dev/null +++ b/modules/core/src/common/dto/list.view.dto.ts @@ -0,0 +1,18 @@ +import * as z from "zod/v4"; +import { MetadataSchema } from "./metadata.dto"; + +/** + * Crea un esquema Zod que representa un ListViewDTO genérico. + * + * @param itemSchema Esquema Zod del elemento T + * @returns Zod schema para ListViewDTO + */ +export const createListViewSchema = (itemSchema: T) => + z.object({ + page: z.number().int().min(1, "Page must be a positive integer"), + per_page: z.number().int().min(1, "Items per page must be a positive integer"), + total_pages: z.number().int().min(0, "Total pages must be a non-negative integer"), + total_items: z.number().int().min(0, "Total items must be a non-negative integer"), + items: z.array(itemSchema), + metadata: MetadataSchema.optional(), + }); diff --git a/modules/core/src/common/dto/metadata.dto.ts b/modules/core/src/common/dto/metadata.dto.ts index 74974baa..bd1d2afd 100644 --- a/modules/core/src/common/dto/metadata.dto.ts +++ b/modules/core/src/common/dto/metadata.dto.ts @@ -1,16 +1,28 @@ -export interface IMetadataDTO { - entity: string; - version: string; - [key: string]: any; // <- para campos adicionales futuros +import * as z from "zod/v4"; - // Futuros campos opcionales que podrían ser útiles: - // source?: 'api' | 'manual' | 'imported' | string; - // related_id?: string; - // related_entity?: string; - // created_by?: string; - // created_at?: string; - // updated_by?: string; - // updated_at?: string; - // permissions?: Array<'read' | 'edit' | 'delete' | string>; - // visibility?: 'public' | 'private' | 'restricted' | string; -} +export const MetadataSchema = z + .object({ + entity: z.string(), + version: z.string().optional(), + }) + .catchall(z.any()); + +export type MetadataDTO = z.infer; + +// Ejemplo de uso: +// const metadata: IMetadataDTO = { +// entity: "customer_invoice", +// version: "1.0", +// custom_field: "value", +// }; +// +// Futuros campos opcionales que podrían ser útiles: +// source?: 'api' | 'manual' | 'imported' | string; +// related_id?: string; +// related_entity?: string; +// created_by?: string; +// created_at?: string; +// updated_by?: string; +// updated_at?: string; +// permissions?: Array<'read' | 'edit' | 'delete' | string>; +// visibility?: 'public' | 'private' | 'restricted' | string; diff --git a/modules/core/src/common/dto/money.dto.ts b/modules/core/src/common/dto/money.dto.ts index 3d3c1035..1e675f51 100644 --- a/modules/core/src/common/dto/money.dto.ts +++ b/modules/core/src/common/dto/money.dto.ts @@ -1,27 +1,5 @@ -import { Result, RuleValidator } from "@repo/rdx-utils"; -import Joi from "joi"; - -export interface IMoneyDTO { +export type MoneyDTO = { amount: number | null; scale: number; currency_code: string; -} - -export interface IMoneyRequestDTO extends IMoneyDTO {} -export interface IMoneyResponseDTO extends IMoneyDTO {} - -export function ensureMoneyDTOIsValid(money: IMoneyRequestDTO) { - const schema = Joi.object({ - amount: Joi.number(), - scale: Joi.number(), - currencycode: Joi.string(), - }); - - const result = RuleValidator.validate(schema, money); - - if (result.isFailure) { - return Result.fail(result.error); - } - - return Result.ok(true); -} +}; diff --git a/modules/core/src/common/dto/percentage.dto.ts b/modules/core/src/common/dto/percentage.dto.ts index 65d006db..668fd1ca 100644 --- a/modules/core/src/common/dto/percentage.dto.ts +++ b/modules/core/src/common/dto/percentage.dto.ts @@ -1,7 +1,4 @@ -export interface IPercentageDTO { +export type IPercentageDTO = { amount: number | null; scale: number; -} - -export interface IPercentageRequestDTO extends IPercentageDTO {} -export interface IPercentageResponseDTO extends IPercentageDTO {} +}; diff --git a/modules/core/src/common/dto/quantity.dto.ts b/modules/core/src/common/dto/quantity.dto.ts index eda16b07..20e75ea7 100644 --- a/modules/core/src/common/dto/quantity.dto.ts +++ b/modules/core/src/common/dto/quantity.dto.ts @@ -1,25 +1,4 @@ -import { Result, RuleValidator } from "@repo/rdx-utils"; -import Joi from "joi"; - -export interface IQuantityDTO { +export type IQuantityDTO = { amount: number | null; scale: number; -} - -export function ensureQuantityDTOIsValid(quantity: IQuantityRequestDTO) { - const schema = Joi.object({ - amount: Joi.number(), - scale: Joi.number(), - }); - - const result = RuleValidator.validate(schema, quantity); - - if (result.isFailure) { - return Result.fail(result.error); - } - - return Result.ok(true); -} - -export interface IQuantityRequestDTO extends IQuantityDTO {} -export interface IQuantityResponseDTO extends IQuantityDTO {} +}; diff --git a/modules/core/src/web/lib/data-source/datasource.interface.ts b/modules/core/src/web/lib/data-source/datasource.interface.ts index bd17526f..d0a3f3fc 100644 --- a/modules/core/src/web/lib/data-source/datasource.interface.ts +++ b/modules/core/src/web/lib/data-source/datasource.interface.ts @@ -14,9 +14,9 @@ export interface ICustomParams { export interface IDataSource { getBaseUrl(): string; - getList(resource: string, params?: Record): Promise; + getList(resource: string, params?: Record): Promise; getOne(resource: string, id: string | number): Promise; - getMany(resource: string, ids: Array): Promise; + getMany(resource: string, ids: Array): Promise; createOne(resource: string, data: Partial): Promise; updateOne(resource: string, id: string | number, data: Partial): Promise; deleteOne(resource: string, id: string | number): Promise; diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index 84b8148f..0af1e0dc 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -18,23 +18,26 @@ }, "dependencies": { "@erp/core": "workspace:*", + "@hookform/resolvers": "^5.0.1", "@repo/rdx-criteria": "workspace:*", "@repo/rdx-ddd": "workspace:*", - "@repo/rdx-utils": "workspace:*", "@repo/rdx-ui": "workspace:*", + "@repo/rdx-utils": "workspace:*", "@repo/shadcn-ui": "workspace:*", "@tanstack/react-query": "^5.74.11", "ag-grid-community": "^33.3.0", "ag-grid-react": "^33.3.0", + "date-fns": "^4.1.0", "express": "^4.18.2", "i18next": "^25.1.1", "lucide-react": "^0.503.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-hook-form": "^7.58.1", "react-i18next": "^15.5.1", "react-router-dom": "^6.26.0", "sequelize": "^6.37.5", "slugify": "^1.6.6", - "zod": "^3.24.4" + "zod": "^3.25.67" } } diff --git a/modules/customer-invoices/src/api/application/create-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/create-customer-invoice/create-customer-invoice.use-case.ts similarity index 83% rename from modules/customer-invoices/src/api/application/create-customer-invoice.use-case.ts rename to modules/customer-invoices/src/api/application/create-customer-invoice/create-customer-invoice.use-case.ts index 82d51037..cdf07a76 100644 --- a/modules/customer-invoices/src/api/application/create-customer-invoice.use-case.ts +++ b/modules/customer-invoices/src/api/application/create-customer-invoice/create-customer-invoice.use-case.ts @@ -1,17 +1,8 @@ -import { UniqueID, UtcDate } from "@/core/common/domain"; - -import { - type CustomerInvoice, - CustomerInvoiceNumber, - CustomerInvoiceSerie, - CustomerInvoiceStatus, - type ICustomerInvoiceProps, - type ICustomerInvoiceService, -} from "@/contexts/customer-invoices/domain"; -import { ITransactionManager } from "@/core/common/infrastructure/database"; -import { logger } from "@/core/logger"; -import { Result } from "@repo/rdx-utils"; +import { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Transaction } from "sequelize"; import { ICreateCustomerInvoiceRequestDTO } from "../../common/dto"; +import { ICustomerInvoiceProps, ICustomerInvoiceService } from "../domain"; export class CreateCustomerInvoiceUseCase { constructor( @@ -19,23 +10,28 @@ export class CreateCustomerInvoiceUseCase { private readonly transactionManager: ITransactionManager ) {} - public execute( - customerInvoiceID: UniqueID, - dto: ICreateCustomerInvoiceRequestDTO - ): Promise> { - return this.transactionManager.complete(async (transaction) => { + public execute(customerInvoiceID: UniqueID, data: ICreateCustomerInvoiceRequestDTO) { + return this.transactionManager.complete(async (transaction: Transaction) => { try { - const validOrErrors = this.validateCustomerInvoiceData(dto); + /*const validOrErrors = this.validateCustomerInvoiceData(dto); if (validOrErrors.isFailure) { return Result.fail(validOrErrors.error); } - const data = validOrErrors.data; + const data = validOrErrors.data;*/ + + const invoiceProps: ICustomerInvoiceProps = { + customerInvoiceNumber: data.customerInvoice_number, + customerInvoiceSeries: data.customerInvoice_series, + issueDate: data.issue_date, + operationDate: data.operation_date, + customerInvoiceCurrency: data.currency, + }; // Update customerInvoice with dto return await this.customerInvoiceService.createCustomerInvoice( customerInvoiceID, - data, + invoiceProps, transaction ); } catch (error: unknown) { diff --git a/modules/customer-invoices/src/api/application/create-customer-invoice/index.ts b/modules/customer-invoices/src/api/application/create-customer-invoice/index.ts new file mode 100644 index 00000000..e92f8281 --- /dev/null +++ b/modules/customer-invoices/src/api/application/create-customer-invoice/index.ts @@ -0,0 +1 @@ +export * from "./create-customer-invoice.use-case"; diff --git a/modules/customer-invoices/src/api/application/delete-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/delete-customer-invoice/delete-customer-invoice.use-case.ts similarity index 100% rename from modules/customer-invoices/src/api/application/delete-customer-invoice.use-case.ts rename to modules/customer-invoices/src/api/application/delete-customer-invoice/delete-customer-invoice.use-case.ts diff --git a/modules/customer-invoices/src/api/application/delete-customer-invoice/index.ts b/modules/customer-invoices/src/api/application/delete-customer-invoice/index.ts new file mode 100644 index 00000000..abc66bde --- /dev/null +++ b/modules/customer-invoices/src/api/application/delete-customer-invoice/index.ts @@ -0,0 +1 @@ +export * from "./delete-customer-invoice.use-case"; diff --git a/modules/customer-invoices/src/api/application/get-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/get-customer-invoice/get-customer-invoice.use-case.ts similarity index 100% rename from modules/customer-invoices/src/api/application/get-customer-invoice.use-case.ts rename to modules/customer-invoices/src/api/application/get-customer-invoice/get-customer-invoice.use-case.ts diff --git a/modules/customer-invoices/src/api/application/get-customer-invoice/index.ts b/modules/customer-invoices/src/api/application/get-customer-invoice/index.ts new file mode 100644 index 00000000..960446a8 --- /dev/null +++ b/modules/customer-invoices/src/api/application/get-customer-invoice/index.ts @@ -0,0 +1 @@ +export * from "./get-customer-invoice.use-case"; diff --git a/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-from-dto.ts b/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-from-dto.ts new file mode 100644 index 00000000..ab73e6c5 --- /dev/null +++ b/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-from-dto.ts @@ -0,0 +1,54 @@ +import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api"; +import { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { CreateCustomerInvoiceCommandDTO } from "../../../common/dto"; +import { CustomerInvoiceNumber, CustomerInvoiceProps, CustomerInvoiceSerie } from "../../domain"; +import { buildInvoiceItemsFromDTO } from "./build-customer-invoice-items-from-dto"; +import { extractOrPushError } from "./extract-or-push-error"; + +export async function buildInvoiceFromDTO( + dto: CreateCustomerInvoiceCommandDTO +): Promise> { + const errors: ValidationErrorDetail[] = []; + + const invoiceNumber = extractOrPushError( + CustomerInvoiceNumber.create(dto.invoice_number), + "invoice_number", + errors + ); + const invoiceSeries = extractOrPushError( + CustomerInvoiceSerie.create(dto.invoice_series), + "invoice_series", + errors + ); + const issueDate = extractOrPushError(UtcDate.createFromISO(dto.issue_date), "issue_date", errors); + const operationDate = extractOrPushError( + UtcDate.createFromISO(dto.operation_date), + "operation_date", + errors + ); + + // 🔄 Validar y construir los items de factura con helper especializado + const itemsResult = await buildInvoiceItemsFromDTO(dto.items); + if (itemsResult.isFailure) { + return Result.fail(itemsResult.error); + } + + if (errors.length > 0) { + return Result.fail(new ValidationErrorCollection(errors)); + } + + return Result.ok({ + id: UniqueID.create(), + customerId: customerId.data, + invoiceNumber: invoiceNumber.data, + invoiceSeries: invoiceSeries.data, + issueDate: issueDate.data, + operationDate: operationDate.data, + subtotalPrice: subtotalPrice.data, + discount: discount.data, + tax: tax.data, + totalAmount: totalAmount.data, + lines: itemsResult.data, + }); +} diff --git a/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-items-from-dto.ts b/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-items-from-dto.ts new file mode 100644 index 00000000..8e087c4b --- /dev/null +++ b/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-items-from-dto.ts @@ -0,0 +1,83 @@ +import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api"; +import { CreateCustomerInvoiceCommandDTO } from "@erp/customer-invoices/common/dto"; +import { Result } from "@repo/rdx-utils"; +import { + CustomerInvoiceItem, + CustomerInvoiceItemDescription, + CustomerInvoiceItemDiscount, + CustomerInvoiceItemQuantity, + CustomerInvoiceItemUnitPrice, +} from "../../domain"; +import { extractOrPushError } from "./extract-or-push-error"; +import { hasNoUndefinedFields } from "./has-no-undefined-fields"; + +export function buildInvoiceItemsFromDTO( + dtoItems: Pick["items"] +): Result { + const errors: ValidationErrorDetail[] = []; + const items: CustomerInvoiceItem[] = []; + + dtoItems.forEach((item, index) => { + const path = (field: string) => `items[${index}].${field}`; + + const description = extractOrPushError( + CustomerInvoiceItemDescription.create(item.description), + path("description"), + errors + ); + + const quantity = extractOrPushError( + CustomerInvoiceItemQuantity.create({ + amount: item.quantity.amount, + scale: item.quantity.scale, + }), + path("quantity"), + errors + ); + + const unitPrice = extractOrPushError( + CustomerInvoiceItemUnitPrice.create({ + amount: item.unitPrice.amount, + scale: item.unitPrice.scale, + currency_code: item.unitPrice.currency, + }), + path("unit_price"), + errors + ); + + const discount = extractOrPushError( + CustomerInvoiceItemDiscount.create({ + amount: item.discount.amount, + scale: item.discount.scale, + }), + path("discount"), + errors + ); + + if (errors.length === 0) { + const itemProps = { + description: description, + quantity: quantity, + unitPrice: unitPrice, + discount: discount, + }; + + if (hasNoUndefinedFields(itemProps)) { + // Validar y crear el item de factura + const itemOrError = CustomerInvoiceItem.create(itemProps); + + if (itemOrError.isSuccess) { + items.push(itemOrError.data); + } else { + errors.push({ path: `items[${index}]`, message: itemOrError.error.message }); + } + } + } + + if (errors.length > 0) { + return Result.fail(new ValidationErrorCollection(errors)); + } + }); + + return Result.ok(items); +} diff --git a/modules/customer-invoices/src/api/application/helpers/extract-or-push-error.ts b/modules/customer-invoices/src/api/application/helpers/extract-or-push-error.ts new file mode 100644 index 00000000..809eb671 --- /dev/null +++ b/modules/customer-invoices/src/api/application/helpers/extract-or-push-error.ts @@ -0,0 +1,45 @@ +import { DomainValidationError, ValidationErrorDetail } from "@erp/core/api"; +import { Result } from "@repo/rdx-utils"; + +/** + * Extrae un valor de un Result si es válido. + * Si es un fallo, agrega un ValidationErrorDetail al array proporcionado. + * @param result - El resultado a evaluar. + * @param path - La ruta del error para el detalle de validación. + * @param errors - El array donde se agregarán los errores de validación. + * @returns El valor extraído si el resultado es exitoso, o undefined si es un fallo. + * @template T - El tipo de dato esperado en el resultado exitoso. + * @throws {Error} Si el resultado es un fallo y no es una instancia de DomainValidationError. + * @example + * const result = Result.ok(42); + * const value = extractOrPushError(result, 'some.path', []); + * console.log(value); // 42 + * const errorResult = Result.fail(new Error('Something went wrong')); + * const value = extractOrPushError(errorResult, 'some.path', []); + * console.log(value); // undefined + * // errors will contain [{ path: 'some.path', message: 'Something went wrong' }] + * + * @see Result + * @see DomainValidationError + * @see ValidationErrorDetail + + */ +export function extractOrPushError( + result: Result, + path: string, + errors: ValidationErrorDetail[] +): T | undefined { + if (result.isFailure) { + const error = result.error; + + if (error instanceof DomainValidationError) { + errors.push({ path, message: error.detail }); + } else { + errors.push({ path, message: error.message }); + } + + return undefined; + } + + return result.data; +} diff --git a/modules/customer-invoices/src/api/application/helpers/has-no-undefined-fields.ts b/modules/customer-invoices/src/api/application/helpers/has-no-undefined-fields.ts new file mode 100644 index 00000000..d6a2ff3f --- /dev/null +++ b/modules/customer-invoices/src/api/application/helpers/has-no-undefined-fields.ts @@ -0,0 +1,26 @@ +/** + * + * @param obj - El objeto a evaluar. + * @template T - El tipo del objeto. + * @description Verifica si un objeto no tiene campos con valor undefined. + * + * Esta función recorre los valores del objeto y devuelve true si todos los valores son diferentes de undefined. + * Si al menos un valor es undefined, devuelve false. + * + * @example + * const obj = { a: 1, b: 'test', c: null }; + * console.log(hasNoUndefinedFields(obj)); // true + * + * const objWithUndefined = { a: 1, b: undefined, c: null }; + * console.log(hasNoUndefinedFields(objWithUndefined)); // false + * + * @template T - El tipo del objeto. + * @param obj - El objeto a evaluar. + * @returns true si el objeto no tiene campos undefined, false en caso contrario. + */ + +export function hasNoUndefinedFields>( + obj: T +): obj is { [K in keyof T]-?: Exclude } { + return Object.values(obj).every((value) => value !== undefined); +} diff --git a/modules/customer-invoices/src/api/application/helpers/index.ts b/modules/customer-invoices/src/api/application/helpers/index.ts new file mode 100644 index 00000000..711ba4b1 --- /dev/null +++ b/modules/customer-invoices/src/api/application/helpers/index.ts @@ -0,0 +1 @@ +export * from "./build-customer-invoice-from-dto"; diff --git a/modules/customer-invoices/src/api/application/index.ts b/modules/customer-invoices/src/api/application/index.ts index 0b228bae..7401f0ba 100644 --- a/modules/customer-invoices/src/api/application/index.ts +++ b/modules/customer-invoices/src/api/application/index.ts @@ -1,5 +1,5 @@ -//export * from "./create-customer-invoice.use-case"; -//export * from "./delete-customer-invoice.use-case"; -export * from "./get-customer-invoice.use-case"; -export * from "./list-customer-invoices.use-case"; -//export * from "./update-customer-invoice.use-case"; +//export * from "./create-customer-invoice"; +//export * from "./delete-customer-invoice"; +//export * from "./get-customer-invoice"; +export * from "./list-customer-invoices"; +//export * from "./update-customer-invoice"; diff --git a/modules/customer-invoices/src/api/application/list-customer-invoices.use-case.ts b/modules/customer-invoices/src/api/application/list-customer-invoices.use-case.ts deleted file mode 100644 index 88416d6e..00000000 --- a/modules/customer-invoices/src/api/application/list-customer-invoices.use-case.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ITransactionManager } from "@erp/core/api"; -import { Criteria } from "@repo/rdx-criteria/server"; -import { Collection, Result } from "@repo/rdx-utils"; -import { Transaction } from "sequelize"; -import { CustomerInvoice, ICustomerInvoiceService } from "../domain"; - -export class ListCustomerInvoicesUseCase { - constructor( - private readonly customerInvoiceService: ICustomerInvoiceService, - private readonly transactionManager: ITransactionManager - ) {} - - public execute(criteria: Criteria): Promise, Error>> { - return this.transactionManager.complete(async (transaction: Transaction) => { - try { - return await this.customerInvoiceService.findCustomerInvoices(criteria, transaction); - } catch (error: unknown) { - return Result.fail(error as Error); - } - }); - } -} diff --git a/modules/customer-invoices/src/api/application/list-customer-invoices/index.ts b/modules/customer-invoices/src/api/application/list-customer-invoices/index.ts new file mode 100644 index 00000000..af0c7f6e --- /dev/null +++ b/modules/customer-invoices/src/api/application/list-customer-invoices/index.ts @@ -0,0 +1 @@ +export * from "./list-customer-invoices.use-case"; diff --git a/modules/customer-invoices/src/api/application/list-customer-invoices/list-customer-invoices.use-case.ts b/modules/customer-invoices/src/api/application/list-customer-invoices/list-customer-invoices.use-case.ts new file mode 100644 index 00000000..feb17b27 --- /dev/null +++ b/modules/customer-invoices/src/api/application/list-customer-invoices/list-customer-invoices.use-case.ts @@ -0,0 +1,35 @@ +import { ITransactionManager } from "@erp/core/api"; +import { ListCustomerInvoicesResultDTO } from "@erp/customer-invoices/common/dto"; +import { Criteria } from "@repo/rdx-criteria/server"; +import { Result } from "@repo/rdx-utils"; +import { Transaction } from "sequelize"; +import { ICustomerInvoiceService } from "../../domain"; +import { ListCustomerInvoicesPresenter } from "./presenter"; + +export class ListCustomerInvoicesUseCase { + constructor( + private readonly customerInvoiceService: ICustomerInvoiceService, + private readonly transactionManager: ITransactionManager, + private readonly presenter: ListCustomerInvoicesPresenter + ) {} + + public execute(criteria: Criteria): Promise> { + return this.transactionManager.complete(async (transaction: Transaction) => { + try { + const result = await this.customerInvoiceService.findCustomerInvoices( + criteria, + transaction + ); + + if (result.isFailure) { + return Result.fail(result.error); + } + + const dto: ListCustomerInvoicesResultDTO = this.presenter.toDTO(result.data, criteria); + return Result.ok(dto); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/InvoiceParticipant.presenter.ts.bak b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/InvoiceParticipant.presenter.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/list-invoices/presenter/InvoiceParticipant.presenter.ts.bak rename to modules/customer-invoices/src/api/application/list-customer-invoices/presenter/InvoiceParticipant.presenter.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/InvoiceParticipantAddress.presenter.ts.bak b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/InvoiceParticipantAddress.presenter.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/list-invoices/presenter/InvoiceParticipantAddress.presenter.ts.bak rename to modules/customer-invoices/src/api/application/list-customer-invoices/presenter/InvoiceParticipantAddress.presenter.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/index.ts b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/index.ts similarity index 100% rename from modules/customer-invoices/src/api/presentation/list-invoices/presenter/index.ts rename to modules/customer-invoices/src/api/application/list-customer-invoices/presenter/index.ts diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/list-invoices.presenter.ts b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/list-invoices.presenter.ts similarity index 59% rename from modules/customer-invoices/src/api/presentation/list-invoices/presenter/list-invoices.presenter.ts rename to modules/customer-invoices/src/api/application/list-customer-invoices/presenter/list-invoices.presenter.ts index b83cf2f9..b28048f1 100644 --- a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/list-invoices.presenter.ts +++ b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/list-invoices.presenter.ts @@ -1,21 +1,20 @@ -import { IListResponseDTO } from "@erp/core"; +import { ListCustomerInvoicesViewDTO } from "@erp/customer-invoices/common/dto"; import { Criteria } from "@repo/rdx-criteria/server"; import { Collection } from "@repo/rdx-utils"; -import { IListCustomerInvoicesResponseDTO } from "../../../../common/dto"; import { CustomerInvoice } from "../../../domain"; -export interface IListCustomerInvoicesPresenter { +export interface ListCustomerInvoicesPresenter { toDTO: ( customerInvoices: Collection, criteria: Criteria - ) => IListResponseDTO; + ) => ListCustomerInvoicesViewDTO; } -export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = { +export const listCustomerInvoicesPresenter: ListCustomerInvoicesPresenter = { toDTO: ( customerInvoices: Collection, criteria: Criteria - ): IListResponseDTO => { + ): ListCustomerInvoicesViewDTO => { const items = customerInvoices.map((invoice) => { return { id: invoice.id.toPrimitive(), @@ -26,17 +25,17 @@ export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = { issue_date: invoice.issueDate.toISOString(), operation_date: invoice.operationDate.toISOString(), language_code: "ES", - currency: invoice.customerInvoiceCurrency.toString(), - subtotal: invoice.calculateSubtotal().toPrimitive(), - total: invoice.calculateTotal().toPrimitive(), + + subtotal_price: invoice.calculateSubtotal().toPrimitive(), + total_price: invoice.calculateTotal().toPrimitive(), //recipient: CustomerInvoiceParticipantPresenter(customerInvoice.recipient), metadata: { entity: "customer-invoice", }, - } as IListCustomerInvoicesResponseDTO; + }; }); const totalItems = customerInvoices.total(); @@ -47,6 +46,15 @@ export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = { total_pages: Math.ceil(totalItems / criteria.pageSize), total_items: totalItems, items: items, + metadata: { + entity: "customer-invoices", + criteria: criteria.toJSON(), + links: { + self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`, + first: `/api/customer-invoices?page=1&per_page=${criteria.pageSize}`, + last: `/api/customer-invoices?page=${Math.ceil(totalItems / criteria.pageSize)}&per_page=${criteria.pageSize}`, + }, + }, }; }, }; diff --git a/modules/customer-invoices/src/api/application/update-customer-invoice/index.ts b/modules/customer-invoices/src/api/application/update-customer-invoice/index.ts new file mode 100644 index 00000000..002aceac --- /dev/null +++ b/modules/customer-invoices/src/api/application/update-customer-invoice/index.ts @@ -0,0 +1 @@ +export * from "./update-customer-invoice.use-case"; diff --git a/modules/customer-invoices/src/api/application/update-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/update-customer-invoice/update-customer-invoice.use-case.ts similarity index 100% rename from modules/customer-invoices/src/api/application/update-customer-invoice.use-case.ts rename to modules/customer-invoices/src/api/application/update-customer-invoice/update-customer-invoice.use-case.ts diff --git a/modules/customer-invoices/src/api/controllers/create-customer-invoice/create-customer-invoice.ts b/modules/customer-invoices/src/api/controllers/create-customer-invoice/create-customer-invoice.ts new file mode 100644 index 00000000..242fd04c --- /dev/null +++ b/modules/customer-invoices/src/api/controllers/create-customer-invoice/create-customer-invoice.ts @@ -0,0 +1,39 @@ +import { ExpressController, errorMapper } from "@erp/core/api"; +import { CreateCustomerInvoiceCommandDTO } from "../../../common/dto"; + +export class CreateCustomerInvoiceController extends ExpressController { + public constructor( + private readonly createCustomerInvoice: any, // Replace with actual type + private readonly presenter: any // Replace with actual type + ) { + super(); + } + + protected async executeImpl() { + const dto = this.req.body as CreateCustomerInvoiceCommandDTO; + /* + const user = this.req.user; // asumimos middleware authenticateJWT inyecta user + + if (!user || !user.companyId) { + this.unauthorized(res, "Unauthorized: user or company not found"); + return; + } + + // Inyectar empresa del usuario autenticado (ownership) + dto.customerCompanyId = user.companyId; + */ + + const result = await this.createCustomerInvoice.execute(dto); + + if (result.isFailure) { + /*if (error instanceof AggregatedValidationError) { + return this.invalidInputError(error.message, error.details); + }*/ + + const apiError = errorMapper.toApiError(result.error); + return this.handleApiError(apiError); + } + + return this.created(this.presenter.toDTO(result.data)); + } +} diff --git a/modules/customer-invoices/src/api/controllers/create-customer-invoice/index.ts b/modules/customer-invoices/src/api/controllers/create-customer-invoice/index.ts new file mode 100644 index 00000000..aba6da9a --- /dev/null +++ b/modules/customer-invoices/src/api/controllers/create-customer-invoice/index.ts @@ -0,0 +1 @@ +export * from "./create-customer-invoice"; diff --git a/modules/customer-invoices/src/api/presentation/delete-invoice/delete-invoice.controller.ts.bak b/modules/customer-invoices/src/api/controllers/delete-customer-invoice/delete-invoice.controller.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/delete-invoice/delete-invoice.controller.ts.bak rename to modules/customer-invoices/src/api/controllers/delete-customer-invoice/delete-invoice.controller.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/delete-invoice/index.ts.bak b/modules/customer-invoices/src/api/controllers/delete-customer-invoice/index.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/delete-invoice/index.ts.bak rename to modules/customer-invoices/src/api/controllers/delete-customer-invoice/index.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/get-invoice.controller.ts b/modules/customer-invoices/src/api/controllers/get-customer-invoice/get-invoice.controller.ts similarity index 100% rename from modules/customer-invoices/src/api/presentation/get-invoice/get-invoice.controller.ts rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/get-invoice.controller.ts diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/index.ts b/modules/customer-invoices/src/api/controllers/get-customer-invoice/index.ts similarity index 100% rename from modules/customer-invoices/src/api/presentation/get-invoice/index.ts rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/index.ts diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceItem.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceItem.presenter.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceItem.presenter.ts.bak rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceItem.presenter.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceParticipant.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceParticipant.presenter.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceParticipant.presenter.ts.bak rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceParticipant.presenter.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/get-invoice.presenter.ts b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/get-invoice.presenter.ts similarity index 100% rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/get-invoice.presenter.ts rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/get-invoice.presenter.ts diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/index.ts b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/index.ts similarity index 100% rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/index.ts rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/index.ts diff --git a/modules/customer-invoices/src/api/controllers/index.ts b/modules/customer-invoices/src/api/controllers/index.ts new file mode 100644 index 00000000..bf49ebc7 --- /dev/null +++ b/modules/customer-invoices/src/api/controllers/index.ts @@ -0,0 +1,5 @@ +export * from "./create-customer-invoice"; +//export * from "./delete-customer-invoice"; +export * from "./get-customer-invoice"; +export * from "./list-customer-invoices"; +///export * from "./update-customer-invoice"; diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/index.ts b/modules/customer-invoices/src/api/controllers/list-customer-invoices/index.ts similarity index 66% rename from modules/customer-invoices/src/api/presentation/list-invoices/index.ts rename to modules/customer-invoices/src/api/controllers/list-customer-invoices/index.ts index 69d7b827..cc89304f 100644 --- a/modules/customer-invoices/src/api/presentation/list-invoices/index.ts +++ b/modules/customer-invoices/src/api/controllers/list-customer-invoices/index.ts @@ -1,18 +1,22 @@ import { SequelizeTransactionManager } from "@erp/core/api"; import { Sequelize } from "sequelize"; import { ListCustomerInvoicesUseCase } from "../../application"; +import { listCustomerInvoicesPresenter } from "../../application/list-customer-invoices/presenter"; import { CustomerInvoiceService } from "../../domain"; import { CustomerInvoiceRepository, customerInvoiceMapper } from "../../infrastructure"; -import { ListCustomerInvoicesController } from "./list-invoices.controller"; -import { listCustomerInvoicesPresenter } from "./presenter"; +import { ListCustomerInvoicesController } from "./list-customer-invoices.controller"; export const buildListCustomerInvoicesController = (database: Sequelize) => { const transactionManager = new SequelizeTransactionManager(database); const customerInvoiceRepository = new CustomerInvoiceRepository(database, customerInvoiceMapper); const customerInvoiceService = new CustomerInvoiceService(customerInvoiceRepository); - - const useCase = new ListCustomerInvoicesUseCase(customerInvoiceService, transactionManager); const presenter = listCustomerInvoicesPresenter; - return new ListCustomerInvoicesController(useCase, presenter); + const useCase = new ListCustomerInvoicesUseCase( + customerInvoiceService, + transactionManager, + presenter + ); + + return new ListCustomerInvoicesController(useCase); }; diff --git a/modules/customer-invoices/src/api/controllers/list-customer-invoices/list-customer-invoices.controller.ts b/modules/customer-invoices/src/api/controllers/list-customer-invoices/list-customer-invoices.controller.ts new file mode 100644 index 00000000..6c2f8715 --- /dev/null +++ b/modules/customer-invoices/src/api/controllers/list-customer-invoices/list-customer-invoices.controller.ts @@ -0,0 +1,37 @@ +import { ExpressController, errorMapper } from "@erp/core/api"; +import { ListCustomerInvoicesUseCase } from "../../application"; + +export class ListCustomerInvoicesController extends ExpressController { + public constructor(private readonly listCustomerInvoices: ListCustomerInvoicesUseCase) { + super(); + } + + protected async executeImpl() { + const criteria = this.criteria; + + /* + const user = this.req.user; // asumimos middleware authenticateJWT inyecta user + + if (!user || !user.companyId) { + this.unauthorized(res, "Unauthorized: user or company not found"); + return; + } + + // Inyectar empresa del usuario autenticado (ownership) + this.criteria.addFilter("companyId", "=", companyId); + */ + + const result = await this.listCustomerInvoices.execute(criteria); + + if (result.isFailure) { + /*if (error instanceof AggregatedValidationError) { + return this.invalidInputError(error.message, error.details); + }*/ + + const apiError = errorMapper.toApiError(result.error); + return this.handleApiError(apiError); + } + + return this.ok(result.data); + } +} diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/index.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/index.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/update-invoice/index.ts.bak rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/index.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceItem.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceItem.presenter.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceItem.presenter.ts.bak rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceItem.presenter.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceParticipant.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceParticipant.presenter.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceParticipant.presenter.ts.bak rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceParticipant.presenter.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/UpdateInvoice.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/UpdateInvoice.presenter.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/UpdateInvoice.presenter.ts.bak rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/UpdateInvoice.presenter.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/index.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/index.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/index.ts.bak rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/index.ts.bak diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/update-invoice.controller.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/update-invoice.controller.ts.bak similarity index 100% rename from modules/customer-invoices/src/api/presentation/update-invoice/update-invoice.controller.ts.bak rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/update-invoice.controller.ts.bak diff --git a/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts b/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts index 6c2fc7e7..4b02e64e 100644 --- a/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts +++ b/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts @@ -7,7 +7,7 @@ import { CustomerInvoiceStatus, } from "../value-objects"; -export interface ICustomerInvoiceProps { +export interface CustomerInvoiceProps { customerInvoiceNumber: CustomerInvoiceNumber; customerInvoiceSeries: CustomerInvoiceSerie; @@ -69,19 +69,19 @@ export interface ICustomerInvoice { } export class CustomerInvoice - extends AggregateRoot + extends AggregateRoot implements ICustomerInvoice { private _items!: Collection; //protected _status: CustomerInvoiceStatus; - protected constructor(props: ICustomerInvoiceProps, id?: UniqueID) { + protected constructor(props: CustomerInvoiceProps, id?: UniqueID) { super(props, id); this._items = props.items || CustomerInvoiceItems.create(); } - static create(props: ICustomerInvoiceProps, id?: UniqueID): Result { + static create(props: CustomerInvoiceProps, id?: UniqueID): Result { const customerInvoice = new CustomerInvoice(props, id); // Reglas de negocio / validaciones diff --git a/modules/customer-invoices/src/api/domain/errors/index.ts b/modules/customer-invoices/src/api/domain/errors/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customer-invoices/src/api/domain/index.ts b/modules/customer-invoices/src/api/domain/index.ts index 2c5c423d..ed8d70d5 100644 --- a/modules/customer-invoices/src/api/domain/index.ts +++ b/modules/customer-invoices/src/api/domain/index.ts @@ -1,5 +1,6 @@ export * from "./aggregates"; export * from "./entities"; +export * from "./errors"; export * from "./repositories"; export * from "./services"; export * from "./value-objects"; diff --git a/modules/customer-invoices/src/api/domain/services/customer-invoice-service.interface.ts b/modules/customer-invoices/src/api/domain/services/customer-invoice-service.interface.ts index 80f71b9b..50f66775 100644 --- a/modules/customer-invoices/src/api/domain/services/customer-invoice-service.interface.ts +++ b/modules/customer-invoices/src/api/domain/services/customer-invoice-service.interface.ts @@ -1,7 +1,7 @@ import { Criteria } from "@repo/rdx-criteria/server"; import { UniqueID } from "@repo/rdx-ddd"; import { Collection, Result } from "@repo/rdx-utils"; -import { CustomerInvoice, ICustomerInvoiceProps } from "../aggregates"; +import { CustomerInvoice, CustomerInvoiceProps } from "../aggregates"; export interface ICustomerInvoiceService { findCustomerInvoices( @@ -15,13 +15,13 @@ export interface ICustomerInvoiceService { updateCustomerInvoiceById( customerInvoiceId: UniqueID, - data: Partial, + data: Partial, transaction?: any ): Promise>; createCustomerInvoice( customerInvoiceId: UniqueID, - data: ICustomerInvoiceProps, + data: CustomerInvoiceProps, transaction?: any ): Promise>; diff --git a/modules/customer-invoices/src/api/domain/services/customer-invoice.service.ts b/modules/customer-invoices/src/api/domain/services/customer-invoice.service.ts index d9111248..f9b1a42b 100644 --- a/modules/customer-invoices/src/api/domain/services/customer-invoice.service.ts +++ b/modules/customer-invoices/src/api/domain/services/customer-invoice.service.ts @@ -2,7 +2,7 @@ import { Criteria } from "@repo/rdx-criteria/server"; import { UniqueID } from "@repo/rdx-ddd"; import { Collection, Result } from "@repo/rdx-utils"; import { Transaction } from "sequelize"; -import { CustomerInvoice, ICustomerInvoiceProps } from "../aggregates"; +import { CustomerInvoice, CustomerInvoiceProps } from "../aggregates"; import { ICustomerInvoiceRepository } from "../repositories"; import { ICustomerInvoiceService } from "./customer-invoice-service.interface"; @@ -34,7 +34,7 @@ export class CustomerInvoiceService implements ICustomerInvoiceService { async updateCustomerInvoiceById( customerInvoiceId: UniqueID, - data: Partial, + data: Partial, transaction?: Transaction ): Promise> { // Verificar si la factura existe @@ -60,7 +60,7 @@ export class CustomerInvoiceService implements ICustomerInvoiceService { async createCustomerInvoice( customerInvoiceId: UniqueID, - data: ICustomerInvoiceProps, + data: CustomerInvoiceProps, transaction?: Transaction ): Promise> { // Verificar si la factura existe diff --git a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-item-description.ts b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-item-description.ts index b046526b..5f1131c2 100644 --- a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-item-description.ts +++ b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-item-description.ts @@ -1,6 +1,7 @@ +import { DomainValidationError } from "@erp/core/api"; import { ValueObject } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -import { z } from "zod"; +import * as z from "zod/v4"; interface ICustomerInvoiceItemDescriptionProps { value: string; @@ -8,6 +9,8 @@ interface ICustomerInvoiceItemDescriptionProps { export class CustomerInvoiceItemDescription extends ValueObject { private static readonly MAX_LENGTH = 255; + private static readonly FIELD = "invoiceItemDescription"; + private static readonly ERROR_CODE = "INVALID_INVOICE_ITEM_DESCRIPTION"; protected static validate(value: string) { const schema = z @@ -20,10 +23,17 @@ export class CustomerInvoiceItemDescription extends ValueObject { private static readonly MAX_LENGTH = 255; + private static readonly FIELD = "invoiceNumber"; + private static readonly ERROR_CODE = "INVALID_INVOICE_NUMBER"; protected static validate(value: string) { const schema = z .string() .trim() .max(CustomerInvoiceNumber.MAX_LENGTH, { - message: `Name must be at most ${CustomerInvoiceNumber.MAX_LENGTH} characters long`, + message: `String must be at most ${CustomerInvoiceNumber.MAX_LENGTH} characters long`, }); return schema.safeParse(value); } static create(value: string) { - const valueIsValid = CustomerInvoiceNumber.validate(value); + const result = CustomerInvoiceNumber.validate(value); - if (!valueIsValid.success) { - return Result.fail(new Error(valueIsValid.error.errors[0].message)); + if (!result.success) { + const detail = result.error.message; + return Result.fail( + new DomainValidationError( + CustomerInvoiceNumber.ERROR_CODE, + CustomerInvoiceNumber.FIELD, + detail + ) + ); } return Result.ok(new CustomerInvoiceNumber({ value })); } diff --git a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-serie.ts b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-serie.ts index bad007f5..aa38a42d 100644 --- a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-serie.ts +++ b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-serie.ts @@ -1,6 +1,7 @@ +import { DomainValidationError } from "@erp/core/api"; import { ValueObject } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -import { z } from "zod"; +import * as z from "zod/v4"; interface ICustomerInvoiceSerieProps { value: string; @@ -8,22 +9,31 @@ interface ICustomerInvoiceSerieProps { export class CustomerInvoiceSerie extends ValueObject { private static readonly MAX_LENGTH = 255; + private static readonly FIELD = "invoiceSeries"; + private static readonly ERROR_CODE = "INVALID_INVOICE_SERIE"; protected static validate(value: string) { const schema = z .string() .trim() .max(CustomerInvoiceSerie.MAX_LENGTH, { - message: `Name must be at most ${CustomerInvoiceSerie.MAX_LENGTH} characters long`, + message: `String must be at most ${CustomerInvoiceSerie.MAX_LENGTH} characters long`, }); return schema.safeParse(value); } static create(value: string) { - const valueIsValid = CustomerInvoiceSerie.validate(value); + const result = CustomerInvoiceSerie.validate(value); - if (!valueIsValid.success) { - return Result.fail(new Error(valueIsValid.error.errors[0].message)); + if (!result.success) { + const detail = result.error.message; + return Result.fail( + new DomainValidationError( + CustomerInvoiceSerie.ERROR_CODE, + CustomerInvoiceSerie.FIELD, + detail + ) + ); } return Result.ok(new CustomerInvoiceSerie({ value })); } diff --git a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts index 7524df11..3ea0d979 100644 --- a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts +++ b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts @@ -1,3 +1,4 @@ +import { DomainValidationError } from "@erp/core/api"; import { ValueObject } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; @@ -13,6 +14,8 @@ export enum INVOICE_STATUS { } export class CustomerInvoiceStatus extends ValueObject { private static readonly ALLOWED_STATUSES = ["draft", "emitted", "sent", "rejected"]; + private static readonly FIELD = "invoiceStatus"; + private static readonly ERROR_CODE = "INVALID_INVOICE_STATUS"; private static readonly TRANSITIONS: Record = { draft: [INVOICE_STATUS.EMITTED], @@ -23,7 +26,14 @@ export class CustomerInvoiceStatus extends ValueObject { if (!CustomerInvoiceStatus.ALLOWED_STATUSES.includes(value)) { - return Result.fail(new Error(`Estado de la factura no válido: ${value}`)); + const detail = `Estado de la factura no válido: ${value}`; + return Result.fail( + new DomainValidationError( + CustomerInvoiceStatus.ERROR_CODE, + CustomerInvoiceStatus.FIELD, + detail + ) + ); } return Result.ok( diff --git a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts index 03513de7..e6634c79 100644 --- a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts @@ -1,7 +1,11 @@ -import { ILogger, ModuleParams } from "@erp/core/api"; +import { ILogger, ModuleParams, validateRequest } from "@erp/core/api"; import { Application, NextFunction, Request, Response, Router } from "express"; import { Sequelize } from "sequelize"; -import { buildListCustomerInvoicesController } from "../../presentation"; +import { + CreateCustomerInvoiceCommandSchema, + ListCustomerInvoicesQuerySchema, +} from "../../../common/dto"; +import { buildListCustomerInvoicesController } from "../../controllers"; export const customerInvoicesRouter = (params: ModuleParams) => { const { app, database, baseRoutePath, logger } = params as { @@ -17,33 +21,33 @@ export const customerInvoicesRouter = (params: ModuleParams) => { "/", //checkTabContext, //checkUser, + validateRequest(ListCustomerInvoicesQuerySchema, "query"), (req: Request, res: Response, next: NextFunction) => { buildListCustomerInvoicesController(database).execute(req, res, next); } ); - app.use(`${baseRoutePath}/customer-invoices`, routes); - /*routes.get( "/:customerInvoiceId", //checkTabContext, //checkUser, + validateRequest(GetCustomerInvoiceByIdQuerySchema, "query"), (req: Request, res: Response, next: NextFunction) => { buildGetCustomerInvoiceController(database).execute(req, res, next); } );*/ - /*routes.post( + routes.post( "/", - validateAndParseBody(ICreateCustomerInvoiceRequestSchema, { sanitize: false }), //checkTabContext, //checkUser, + validateRequest(CreateCustomerInvoiceCommandSchema), (req: Request, res: Response, next: NextFunction) => { buildCreateCustomerInvoiceController(database).execute(req, res, next); } ); - routes.put( + /*routes.put( "/:customerInvoiceId", validateAndParseBody(IUpdateCustomerInvoiceRequestSchema), checkTabContext, @@ -62,4 +66,6 @@ export const customerInvoicesRouter = (params: ModuleParams) => { buildDeleteCustomerInvoiceController().execute(req, res, next); } );*/ + + app.use(`${baseRoutePath}/customer-invoices`, routes); }; diff --git a/modules/customer-invoices/src/api/infrastructure/mappers/customer-invoice.mapper.ts b/modules/customer-invoices/src/api/infrastructure/mappers/customer-invoice.mapper.ts index 8616461a..fa1039f3 100644 --- a/modules/customer-invoices/src/api/infrastructure/mappers/customer-invoice.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/mappers/customer-invoice.mapper.ts @@ -36,8 +36,8 @@ export class CustomerInvoiceMapper const statusOrError = CustomerInvoiceStatus.create(source.invoice_status); const customerInvoiceSeriesOrError = CustomerInvoiceSerie.create(source.invoice_series); const customerInvoiceNumberOrError = CustomerInvoiceNumber.create(source.invoice_number); - const issueDateOrError = UtcDate.create(source.issue_date); - const operationDateOrError = UtcDate.create(source.operation_date); + const issueDateOrError = UtcDate.createFromISO(source.issue_date); + const operationDateOrError = UtcDate.createFromISO(source.operation_date); const result = Result.combine([ idOrError, diff --git a/modules/customer-invoices/src/api/presentation/index.ts b/modules/customer-invoices/src/api/presentation/index.ts deleted file mode 100644 index 635099c6..00000000 --- a/modules/customer-invoices/src/api/presentation/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -//export * from "./create-customer-invoice"; -//export * from "./delete-customer-invoice"; -export * from "./get-invoice"; -export * from "./list-invoices"; -///export * from "./update-customer-invoice"; diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/list-invoices.controller.ts b/modules/customer-invoices/src/api/presentation/list-invoices/list-invoices.controller.ts deleted file mode 100644 index 4c335bff..00000000 --- a/modules/customer-invoices/src/api/presentation/list-invoices/list-invoices.controller.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ExpressController } from "@erp/core/api"; -import { ListCustomerInvoicesUseCase } from "../../application"; -import { IListCustomerInvoicesPresenter } from "./presenter/list-invoices.presenter"; - -export class ListCustomerInvoicesController extends ExpressController { - public constructor( - private readonly listCustomerInvoices: ListCustomerInvoicesUseCase, - private readonly presenter: IListCustomerInvoicesPresenter - ) { - super(); - } - - protected async executeImpl() { - const criteria = this.criteria; - const customerInvoicesOrError = await this.listCustomerInvoices.execute(criteria); - - if (customerInvoicesOrError.isFailure) { - return this.handleError(customerInvoicesOrError.error); - } - - return this.ok(this.presenter.toDTO(customerInvoicesOrError.data, criteria)); - } - - 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); - } -} diff --git a/modules/customer-invoices/src/common/dto/common/index.ts b/modules/customer-invoices/src/common/dto/common/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customer-invoices/src/common/dto/customer-invoices.request.dto.ts b/modules/customer-invoices/src/common/dto/customer-invoices.request.dto.ts index 130fb36e..b1766dbf 100644 --- a/modules/customer-invoices/src/common/dto/customer-invoices.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/customer-invoices.request.dto.ts @@ -1,16 +1,3 @@ -export type IListCustomerInvoicesRequestDTO = {}; - -export interface ICreateCustomerInvoiceRequestDTO { - id: string; - - customerInvoice_number: string; - customerInvoice_series: string; - issue_date: string; - operation_date: string; - language_code: string; - currency: string; -} - export interface IUpdateCustomerInvoiceRequestDTO { is_freelancer: boolean; name: string; diff --git a/modules/customer-invoices/src/common/dto/customer-invoices.schemas.ts b/modules/customer-invoices/src/common/dto/customer-invoices.schemas.ts index 7c2c6e4f..f4385bd0 100644 --- a/modules/customer-invoices/src/common/dto/customer-invoices.schemas.ts +++ b/modules/customer-invoices/src/common/dto/customer-invoices.schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import * as z from "zod/v4"; export const ICreateCustomerInvoiceRequestSchema = z.object({ id: z.string().uuid(), diff --git a/modules/customer-invoices/src/common/dto/index.ts b/modules/customer-invoices/src/common/dto/index.ts index 44c99a3c..346dac3b 100644 --- a/modules/customer-invoices/src/common/dto/index.ts +++ b/modules/customer-invoices/src/common/dto/index.ts @@ -1,3 +1,2 @@ -export * from "./customer-invoices.request.dto"; -export * from "./customer-invoices.response.dto"; -export * from "./customer-invoices.schemas"; +export * from "./request"; +export * from "./response"; diff --git a/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts b/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts new file mode 100644 index 00000000..98e75053 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts @@ -0,0 +1,31 @@ +import * as z from "zod/v4"; + +export const CreateCustomerInvoiceCommandSchema = z.object({ + id: z.string().uuid(), + invoice_number: z.string().min(1, "Customer invoice number is required"), + invoice_series: z.string().min(1, "Customer invoice series is required"), + issue_date: z.string().datetime({ offset: true, message: "Invalid issue date format" }), + operation_date: z.string().datetime({ offset: true, message: "Invalid operation date format" }), + language_code: z.string().min(2, "Language code must be at least 2 characters long"), + currency: z.string().min(3, "Currency code must be at least 3 characters long"), + items: z.array( + z.object({ + description: z.string().min(1, "Item description is required"), + quantity: z.object({ + amount: z.number().positive("Quantity amount must be positive"), + scale: z.number().int().nonnegative("Quantity scale must be a non-negative integer"), + }), + unitPrice: z.object({ + amount: z.number().positive("Unit price amount must be positive"), + scale: z.number().int().nonnegative("Unit price scale must be a non-negative integer"), + currency: z.string().min(3, "Unit price currency code must be at least 3 characters long"), + }), + discount: z.object({ + amount: z.number().nonnegative("Discount amount cannot be negative"), + scale: z.number().int().nonnegative("Discount scale must be a non-negative integer"), + }), + }) + ), +}); + +export type CreateCustomerInvoiceCommandDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/request/index.ts b/modules/customer-invoices/src/common/dto/request/index.ts new file mode 100644 index 00000000..8abb398f --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/index.ts @@ -0,0 +1,2 @@ +export * from "./create-customer-invoice.command.dto"; +export * from "./list-customer-invoices.query.dto"; diff --git a/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts b/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts new file mode 100644 index 00000000..8d5b59b6 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts @@ -0,0 +1,33 @@ +import * as z from "zod/v4"; + +/** + * DTO que transporta los parámetros de la consulta (paginación, filtros, etc.) + * para la búsqueda de facturas de cliente. + * + * Este DTO es utilizado por el endpoint: + * `GET /customer-invoices` (listado / búsqueda de facturas). + * + */ + +export const ListCustomerInvoicesQuerySchema = z.object({ + page: z.number().int().min(1).default(1), + pageSize: z.number().int().min(1).max(100).default(25), + fromDate: z + .string() + .optional() + .refine((val) => !val || !Number.isNaN(Date.parse(val)), { + message: "Invalid date format for fromDate", + }), + toDate: z + .string() + .optional() + .refine((val) => !val || !Number.isNaN(Date.parse(val)), { + message: "Invalid date format for toDate", + }), + status: z.enum(["DRAFT", "POSTED", "PAID", "CANCELLED"]).default("DRAFT"), + customerId: z.string().optional(), + sortBy: z.enum(["issueDate", "totalAmount", "number"]).default("issueDate"), + sortDir: z.enum(["ASC", "DESC"]).default("DESC"), +}); + +export type ListCustomerInvoicesQueryDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/request/update-customer-invoice.command.dto.ts b/modules/customer-invoices/src/common/dto/request/update-customer-invoice.command.dto.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customer-invoices/src/common/dto/response/customer-invoice-creation.result.dto.ts b/modules/customer-invoices/src/common/dto/response/customer-invoice-creation.result.dto.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customer-invoices/src/common/dto/response/index.ts b/modules/customer-invoices/src/common/dto/response/index.ts new file mode 100644 index 00000000..fed6daf7 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/response/index.ts @@ -0,0 +1 @@ +export * from "./list-customer-invoices.result.dto"; diff --git a/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts b/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts new file mode 100644 index 00000000..0023e1d5 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts @@ -0,0 +1,30 @@ +import { MetadataSchema, createListViewSchema } from "@erp/core"; +import * as z from "zod/v4"; + +export const ListCustomerInvoicesResultSchema = createListViewSchema( + z.object({ + id: z.uuid(), + invoice_status: z.string(), + invoice_number: z.string(), + invoice_series: z.string(), + issue_date: z.iso.datetime({ offset: true }), + operation_date: z.iso.datetime({ offset: true }), + language_code: z.string(), + currency: z.string(), + + subtotal_price: z.object({ + amount: z.number(), + scale: z.number(), + currency_code: z.string(), + }), + total_price: z.object({ + amount: z.number(), + scale: z.number(), + currency_code: z.string(), + }), + + metadata: MetadataSchema.optional(), + }) +); + +export type ListCustomerInvoicesResultDTO = z.infer; diff --git a/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx b/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx index fca94db7..7a0241cc 100644 --- a/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx +++ b/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx @@ -9,7 +9,7 @@ ModuleRegistry.registerModules([AllCommunityModule]); // Core CSS import { AgGridReact } from "ag-grid-react"; -import { useCustomerInvoices } from "../hooks"; +import { useCustomerInvoicesQuery } from "../hooks"; /** * Fetch example Json data @@ -50,7 +50,7 @@ interface IRow { export const CustomerInvoicesGrid = () => { //const { useList } = useCustomerInvoices(); - const { data, isLoading, isPending, isError, error } = useCustomerInvoices({}); + const { data, isLoading, isPending, isError, error } = useCustomerInvoicesQuery({}); // Column Definitions: Defines & controls grid columns. const [colDefs] = useState([ @@ -87,8 +87,6 @@ export const CustomerInvoicesGrid = () => { }; }, []); - console.log(isError, error); - // Container: Defines the grid's theme & dimensions. return (
{ }} > ({}); diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index 455403b5..4afc28de 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -6,7 +6,14 @@ import { Outlet, RouteObject } from "react-router-dom"; const CustomerInvoicesLayout = lazy(() => import("./components").then((m) => ({ default: m.CustomerInvoicesLayout })) ); -const CustomerInvoicesList = lazy(() => import("./pages").then((m) => ({ default: m.CustomerInvoicesList }))); + +const CustomerInvoicesList = lazy(() => + import("./pages").then((m) => ({ default: m.CustomerInvoicesList })) +); + +const CustomerInvoiceAdd = lazy(() => + import("./pages").then((m) => ({ default: m.CustomerInvoiceCreate })) +); //const LogoutPage = lazy(() => import("./app").then((m) => ({ default: m.LogoutPage }))); @@ -17,7 +24,7 @@ const LoginPageWithLanguageSelector = lazy(() => import("./app").then((m) => ({ default: m.LoginPageWithLanguageSelector })) ); -const CustomerInvoiceCreate = lazy(() => import("./app").then((m) => ({ default: m.CustomerInvoiceCreate }))); + const CustomerInvoiceEdit = lazy(() => import("./app").then((m) => ({ default: m.CustomerInvoiceEdit }))); const SettingsEditor = lazy(() => import("./app").then((m) => ({ default: m.SettingsEditor }))); const SettingsLayout = lazy(() => import("./app").then((m) => ({ default: m.SettingsLayout }))); @@ -30,16 +37,16 @@ const CustomerInvoicesList = lazy(() => import("./app").then((m) => ({ default: export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => { return [ { - path: "*", + path: "customer-invoices", element: ( ), children: [ - { path: "", element: }, // index + { path: "", index: true, element: }, // index { path: "list", element: }, - { path: "*", element: }, + { path: "create", element: }, // /*{ path: "create", element: }, diff --git a/modules/customer-invoices/src/web/hooks/index.ts b/modules/customer-invoices/src/web/hooks/index.ts index 1e78c67a..7cfd15d4 100644 --- a/modules/customer-invoices/src/web/hooks/index.ts +++ b/modules/customer-invoices/src/web/hooks/index.ts @@ -1,2 +1,3 @@ -export * from "./customer-invoices-context"; -export * from "./use-customer-invoices"; +export * from "./use-create-customer-invoice-mutation"; +export * from "./use-customer-invoices-context"; +export * from "./use-customer-invoices-query"; diff --git a/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts b/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts new file mode 100644 index 00000000..5a6892b6 --- /dev/null +++ b/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts @@ -0,0 +1,23 @@ +import { useDataSource, useQueryKey } from "@erp/core/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ICreateCustomerInvoiceRequestDTO } from "../../common/dto"; + +export const useCreateCustomerInvoiceMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + const keys = useQueryKey(); + + return useMutation< + ICreateCustomerInvoiceRequestDTO, + Error, + Partial + >({ + mutationFn: (data) => { + console.log(data); + return dataSource.createOne("customer-invoices", data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["customer-invoices"] }); + }, + }); +}; diff --git a/modules/customer-invoices/src/web/hooks/customer-invoices-context.tsx b/modules/customer-invoices/src/web/hooks/use-customer-invoices-context.tsx similarity index 100% rename from modules/customer-invoices/src/web/hooks/customer-invoices-context.tsx rename to modules/customer-invoices/src/web/hooks/use-customer-invoices-context.tsx diff --git a/modules/customer-invoices/src/web/hooks/use-customer-invoices-query.tsx b/modules/customer-invoices/src/web/hooks/use-customer-invoices-query.tsx new file mode 100644 index 00000000..32945b28 --- /dev/null +++ b/modules/customer-invoices/src/web/hooks/use-customer-invoices-query.tsx @@ -0,0 +1,25 @@ +import { IListResponseDTO } from "@erp/core"; +import { useDataSource, useQueryKey } from "@erp/core/client"; +import { useQuery } from "@tanstack/react-query"; +import { IListCustomerInvoicesResponseDTO } from "../../common/dto"; + +// Obtener todas las facturas +export const useCustomerInvoicesQuery = (params: any) => { + const dataSource = useDataSource(); + const keys = useQueryKey(); + + return useQuery({ + queryKey: keys().data().resource("customer-invoices").action("list").params(params).get(), + queryFn: (context) => { + console.log(dataSource.getBaseUrl()); + const { signal } = context; + return dataSource.getList>( + "customer-invoices", + { + signal, + ...params, + } + ); + }, + }); +}; diff --git a/modules/customer-invoices/src/web/hooks/use-customer-invoices.tsx b/modules/customer-invoices/src/web/hooks/use-customer-invoices.tsx deleted file mode 100644 index 2ae0070a..00000000 --- a/modules/customer-invoices/src/web/hooks/use-customer-invoices.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useDataSource, useQueryKey } from "@erp/core/client"; -import { useQuery } from "@tanstack/react-query"; - -// Obtener todas las facturas -export const useCustomerInvoices = (params: any) => { - const dataSource = useDataSource(); - const keys = useQueryKey(); - - return useQuery({ - queryKey: keys().data().resource("invoices").action("list").params(params).get(), - queryFn: (context) => { - console.log(dataSource.getBaseUrl()); - const { signal } = context; - return dataSource.getList("customer-invoices", { - signal, - ...params, - }); - }, - }); -}; diff --git a/modules/customer-invoices/src/web/invoice-routes.tsx b/modules/customer-invoices/src/web/invoice-routes.tsx deleted file mode 100644 index af239765..00000000 --- a/modules/customer-invoices/src/web/invoice-routes.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { ModuleClientParams } from "@erp/core/client"; -import { lazy } from "react"; -import { Outlet, RouteObject } from "react-router-dom"; - -// Lazy load components -const InvoicesLayout = lazy(() => - import("./components").then((m) => ({ default: m.CustomerInvoicesLayout })) -); -const InvoicesList = lazy(() => - import("./pages").then((m) => ({ default: m.CustomerInvoicesList })) -); - -//const LogoutPage = lazy(() => import("./app").then((m) => ({ default: m.LogoutPage }))); - -/*const DealerLayout = lazy(() => import("./app").then((m) => ({ default: m.DealerLayout }))); -const DealersList = lazy(() => import("./app").then((m) => ({ default: m.DealersList }))); - -const LoginPageWithLanguageSelector = lazy(() => - import("./app").then((m) => ({ default: m.LoginPageWithLanguageSelector })) -); - -const InvoiceCreate = lazy(() => import("./app").then((m) => ({ default: m.InvoiceCreate }))); -const InvoiceEdit = lazy(() => import("./app").then((m) => ({ default: m.InvoiceEdit }))); -const SettingsEditor = lazy(() => import("./app").then((m) => ({ default: m.SettingsEditor }))); -const SettingsLayout = lazy(() => import("./app").then((m) => ({ default: m.SettingsLayout }))); -const CatalogLayout = lazy(() => import("./app").then((m) => ({ default: m.CatalogLayout }))); -const CatalogList = lazy(() => import("./app").then((m) => ({ default: m.CatalogList }))); -const DashboardPage = lazy(() => import("./app").then((m) => ({ default: m.DashboardPage }))); -const InvoicesLayout = lazy(() => import("./app").then((m) => ({ default: m.InvoicesLayout }))); -const InvoicesList = lazy(() => import("./app").then((m) => ({ default: m.InvoicesList })));*/ - -export const InvoiceRoutes = (params: ModuleClientParams): RouteObject[] => { - return [ - { - path: "*", - element: ( - - - - ), - children: [ - { path: "", element: }, // index - { path: "list", element: }, - { path: "*", element: }, - - // - /*{ path: "create", element: }, - { path: ":id", element: }, - { path: ":id/edit", element: }, - { path: ":id/delete", element: }, - { path: ":id/view", element: }, - { path: ":id/print", element: }, - { path: ":id/email", element: }, - { path: ":id/download", element: }, - { path: ":id/duplicate", element: }, - { path: ":id/preview", element: },*/ - ], - }, - ]; -}; diff --git a/modules/customer-invoices/src/web/pages/create/create.tsx b/modules/customer-invoices/src/web/pages/create/create.tsx new file mode 100644 index 00000000..254b2a76 --- /dev/null +++ b/modules/customer-invoices/src/web/pages/create/create.tsx @@ -0,0 +1,133 @@ +import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components"; +import { Button } from "@repo/shadcn-ui/components"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { useCreateCustomerInvoiceMutation } from "../../hooks"; +import { MODULE_NAME } from "../../manifest"; +import { InvoiceEditForm } from "./invoice-edit-form"; + +export const CustomerInvoiceCreate = () => { + const { t } = useTranslation(MODULE_NAME); + const navigate = useNavigate(); + + const { mutate, isPending, isError, error } = useCreateCustomerInvoiceMutation(); + + const handleSubmit = (data: any) => { + // Handle form submission logic here + console.log("Form submitted with data:", data); + mutate(data); + + // Navigate to the list page after submission + navigate("/customer-invoices/list"); + }; + + if (isError) { + console.error("Error creating customer invoice:", error); + // Optionally, you can show an error message to the user + } + + // Render the component + // You can also handle loading state if needed + // For example, you can disable the submit button while the mutation is in progress + // const isLoading = useCreateCustomerInvoiceMutation().isLoading; + + // Return the JSX for the component + // You can customize the form and its fields as needed + // For example, you can use a form library like react-hook-form or Formik to handle form state and validation + // Here, we are using a simple form with a submit button + + // Note: Make sure to replace the form fields with your actual invoice fields + // and handle validation as needed. + // This is just a basic example to demonstrate the structure of the component. + + // If you are using a form library, you can pass the handleSubmit function to the form's onSubmit prop + // and use the form library's methods to handle form state and validation. + + // Example of a simple form submission handler + // You can replace this with your actual form handling logic + // const handleSubmit = (event: React.FormEvent) => { + // event.preventDefault(); + // const formData = new FormData(event.currentTarget); + + return ( + <> + + +
+
+

+ {t("customerInvoices.create.title")} +

+

{t("customerInvoices.create.description")}

+
+
+ +
+
+
+ +
+
+ + ); +}; + +/* + return ( + <> +
+
+

+ {t('customerInvoices.list.title' /> +

+

+ {t('CustomerInvoices.list.subtitle' /> +

+
+
+ +
+
+ + +
+
+ + {CustomerInvoiceStatuses.map((s) => ( + + {s.label} + + ))} + +
+ + +
+
+
+ {CustomerInvoiceStatuses.map((s) => ( + + + + ))} +
+ + ); +}; +*/ diff --git a/modules/customer-invoices/src/web/pages/create/index.ts b/modules/customer-invoices/src/web/pages/create/index.ts new file mode 100644 index 00000000..c6262006 --- /dev/null +++ b/modules/customer-invoices/src/web/pages/create/index.ts @@ -0,0 +1 @@ +export * from "./create"; diff --git a/modules/customer-invoices/src/web/pages/create/invoice-edit-form.tsx b/modules/customer-invoices/src/web/pages/create/invoice-edit-form.tsx new file mode 100644 index 00000000..39c99bab --- /dev/null +++ b/modules/customer-invoices/src/web/pages/create/invoice-edit-form.tsx @@ -0,0 +1,908 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useFieldArray, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { + Button, + Calendar, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Label, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, + Textarea, +} from "@repo/shadcn-ui/components"; +import { format } from "date-fns"; +import { es } from "date-fns/locale"; +import { CalendarIcon, PlusIcon, Save, Trash2Icon, X } from "lucide-react"; +import { InvoiceData } from "./types"; +import { formatCurrency } from "./utils"; + +const invoiceSchema = z.object({ + id: z.string(), + invoice_status: z.string(), + invoice_number: z.string().min(1, "Número de factura requerido"), + invoice_series: z.string().min(1, "Serie requerida"), + issue_date: z.string(), + operation_date: z.string(), + language_code: z.string(), + currency: z.string(), + customer_id: z.string().min(1, "ID de cliente requerido"), + items: z + .array( + z.object({ + id_article: z.string(), + description: z.string(), + quantity: z.object({ + amount: z.number().nullable(), + scale: z.number(), + }), + unit_price: z.object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }), + subtotal_price: z.object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }), + discount: z.object({ + amount: z.number().min(0).max(100).nullable(), + scale: z.number(), + }), + discount_price: z.object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }), + total_price: z.object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }), + }) + ) + .min(1, "Al menos un item es requerido"), + subtotal_price: z.object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }), + discount: z.object({ + amount: z.number().nullable(), + scale: z.number(), + }), + discount_price: z.object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }), + before_tax_price: z.object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }), + tax: z.object({ + amount: z.number().nullable(), + scale: z.number(), + }), + tax_price: z.object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }), + total_price: z.object({ + amount: z.number().nullable(), + scale: z.number(), + currency_code: z.string(), + }), + metadata: z.object({ + entity: z.string(), + }), +}); + +const defaultInvoiceData: InvoiceData = { + id: "893b2c74-e80f-4015-b0ed-6111b9c36ad2", + invoice_status: "draft", + invoice_number: "1", + invoice_series: "A", + issue_date: "2025-04-30T00:00:00.000Z", + operation_date: "2025-04-30T00:00:00.000Z", + language_code: "ES", + currency: "EUR", + customer_id: "c1d2e3f4-5678-90ab-cdef-1234567890ab", + items: [ + { + id_article: "", + description: "Item 1", + quantity: { + amount: 100, + scale: 2, + }, + unit_price: { + amount: 100, + scale: 2, + currency_code: "EUR", + }, + subtotal_price: { + amount: 100, + scale: 2, + currency_code: "EUR", + }, + discount: { + amount: 0, + scale: 2, + }, + discount_price: { + amount: 0, + scale: 2, + currency_code: "EUR", + }, + total_price: { + amount: 100, + scale: 2, + currency_code: "EUR", + }, + }, + ], + subtotal_price: { + amount: 0, + scale: 2, + currency_code: "EUR", + }, + discount: { + amount: 0, + scale: 0, + }, + discount_price: { + amount: 0, + scale: 0, + currency_code: "EUR", + }, + before_tax_price: { + amount: 0, + scale: 2, + currency_code: "EUR", + }, + tax: { + amount: 2100, + scale: 2, + }, + tax_price: { + amount: 0, + scale: 2, + currency_code: "EUR", + }, + total_price: { + amount: 0, + scale: 2, + currency_code: "EUR", + }, + metadata: { + entity: "customer-invoice", + }, +}; + +interface InvoiceFormProps { + initialData?: InvoiceData; + isPending?: boolean; + /** + * Callback function to handle form submission. + * @param data - The invoice data submitted by the form. + */ + onSubmit?: (data: InvoiceData) => void; +} + +export const InvoiceEditForm = ({ + initialData = defaultInvoiceData, + onSubmit, + isPending, +}: InvoiceFormProps) => { + const form = useForm({ + resolver: zodResolver(invoiceSchema), + defaultValues: initialData, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }); + + const watchedItems = form.watch("items"); + const watchedTaxRate = form.watch("tax.amount"); + + const addItem = () => { + append({ + id_article: "", + description: "", + quantity: { amount: 100, scale: 2 }, + unit_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") }, + subtotal_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") }, + discount: { amount: 0, scale: 2 }, + discount_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") }, + total_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") }, + }); + }; + + const handleSubmit = (data: InvoiceData) => { + console.log("Datos del formulario:", data); + onSubmit?.(data); + }; + + const handleError = (errors: any) => { + console.error("Errores en el formulario:", errors); + // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario + }; + + const handleCancel = () => { + form.reset(initialData); + }; + + return ( +
+ + {/* Información básica */} + + + Información Básica + Detalles generales de la factura + + + ( + + Estado + + + + )} + /> + + ( + + Serie + + + + + + )} + /> + + ( + + Número + + + + + + )} + /> + + ( + + Fecha de Emisión + + + + + + + + field.onChange(date?.toISOString())} + initialFocus + /> + + + + + )} + /> + + ( + + Fecha de Operación + + + + + + + + field.onChange(date?.toISOString())} + initialFocus + /> + + + + + )} + /> + + ( + + ID Cliente + + + + + + )} + /> + + ( + + Idioma + + + + )} + /> + + ( + + Moneda + + + + )} + /> + + + + {/* Items */} + + +
+ Artículos + Lista de productos o servicios facturados +
+ +
+ + {fields.map((field, index) => ( + +
+
+

Item {index + 1}

+
+ {fields.length > 1 && ( + + )} +
+ +
+ ( + + Código Artículo + + + + + + )} + /> + + ( + + Descripción + +