Facturas de cliente y clientes

This commit is contained in:
David Arranz 2025-08-11 19:49:52 +02:00
parent 0e6ecaad22
commit d7bce7fb56
107 changed files with 3769 additions and 414 deletions

View File

@ -43,6 +43,7 @@
"dependencies": {
"@erp/auth": "workspace:*",
"@erp/core": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"bcrypt": "^5.1.1",
"cls-rtracer": "^2.6.3",
@ -69,7 +70,7 @@
"uuid": "^11.0.5",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "^3.24.1"
"zod": "^3.25.67"
},
"engines": {
"node": ">=22"

View File

@ -7,6 +7,7 @@ const registeredModules: Map<string, IModuleServer> = new Map();
const initializedModules = new Set<string>();
export function registerModule(pkg: IModuleServer) {
console.log(pkg.name);
if (registeredModules.has(pkg.name)) {
throw new Error(`❌ Paquete "${pkg.name}" ya registrado.`);
}

View File

@ -1,8 +1,10 @@
import { authAPIModule } from "@erp/auth/api";
import { customerInvoicesAPIModule } from "@erp/customer-invoices/api";
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
import customersAPIModule from "@erp/customers/api";
import { registerModule } from "./lib";
export const registerModules = () => {
registerModule(authAPIModule);
//registerModule(authAPIModule);
registerModule(customersAPIModule);
registerModule(customerInvoicesAPIModule);
};

View File

@ -0,0 +1,187 @@
# 📝 Guía de Convenciones para Nombres de Schemas
Esta guía define cómo nombrar los Schemas de **Requests** y **Responses** según el tipo de operación y el contexto (recurso único o colección).
---
## 1⃣ Operaciones sobre recurso único
**Patrón**
<Verbo><Entidad><Condición><TipoDeSchema>
- **Verbo**: describe la acción (`Get`, `Delete`, `Update`, `Patch`, `Create`).
- **Entidad**: en singular (`Product`, `Customer`, `Invoice`).
- **Condición**: criterio de selección (`ById`, `BySlug`, `ByEmail`).
- **TipoDeSchema**: origen o propósito (`ParamsSchema`, `CommandSchema`, `QuerySchema`).
**Ejemplos ✅**
- `GetProductByIdParamsSchema`
- `DeleteCustomerByIdParamsSchema`
- `UpdateInvoiceByIdCommandSchema`
**Ejemplos ❌**
- `ProductGetByIdParamsSchema` (orden invertido, menos natural).
- `GetProductsByIdParamsSchema` (plural innecesario en recurso único).
**Lectura natural**: *"Esquema de parámetros para obtener un producto por id"*.
---
## 2⃣ Operaciones sobre colecciones
**Patrón**
<Entidad>List<Criterio/Filtro><TipoDeSchema>
- **Entidad**: en singular (`Product`, `Customer`).
- **List**: indica conjunto/listado.
- **Criterio/Filtro**: tipo de filtrado (`Criteria`, `Query`).
- **TipoDeSchema**: `Schema` o `ResponseSchema` según corresponda.
**Ejemplos ✅**
- `ProductListCriteriaSchema`
- `CustomerListResponseSchema`
- `InvoiceListQuerySchema`
**Ejemplos ❌**
- `ListProductCriteriaSchema` (el orden “ListProduct” suena a verbo y no a colección).
- `ProductsListCriteriaSchema` (plural innecesario en el nombre base).
**Lectura natural**: *"Esquema de criterios para una lista de productos"*.
---
## 3⃣ Reglas generales
1. **Singular siempre** para el nombre base de la entidad.
2. El **verbo solo** en operaciones sobre un recurso único o mutaciones.
3. En listados, el **sustantivo principal primero** (`ProductList`, `CustomerList`), nunca `ListProduct`.
4. Sufijos estándar:
- `CommandSchema` → body de mutaciones (create/update/patch).
- `ParamsSchema` → route params (`/:id`).
- `QuerySchema` → query string no normalizada.
- `CriteriaSchema` → query normalizada con patrón Criteria.
- `ResponseSchema` → respuesta de la API.
5. Mantener **consistencia**: si usas `ProductListCriteriaSchema`, no alternes con `ProductsCriteriaSchema`.
---
## 4⃣ Ejemplos positivos y negativos
| ✅ Correcto | ❌ Incorrecto | Motivo del error |
|------------------------------------------|---------------------------------------------|----------------------------------------------|
| `GetCustomerByIdParamsSchema` | `CustomerGetByIdParamsSchema` | Orden invertido. |
| `ProductListCriteriaSchema` | `ListProductCriteriaSchema` | Orden “ListProduct” no natural en inglés. |
| `CustomerListResponseSchema` | `CustomersListResponseSchema` | Plural innecesario. |
| `DeleteInvoiceByIdParamsSchema` | `DeleteInvoicesByIdParamsSchema` | Plural innecesario en recurso único. |
| `UpdateProductByIdCommandSchema` | `UpdateProductCommandByIdSchema` | `ById` debe ir antes del tipo de esquema. |
---
# Convenciones de nombres para Schemas (Requests y Responses)
## Tabla de rutas ↔ Schemas
| **Método** | **Ruta** | **Caso de uso** | **Request Schema (validación)** | **Response Schema (DTO)** | **Notas** |
|-----------:|---------------------|--------------------------------------------------|--------------------------------------------|-------------------------------------------|-----------|
| GET | `/entities` | Listado con criterios (Criteria normalizado) | `EntityListCriteriaSchema` | `EntityListResponseSchema` | `data: EntitySummary[]`, `pageNumber`, `pageSize`, `totalItems`. |
| GET | `/entities` | Búsqueda/filtrado por query params “raw” | `SearchEntityQuerySchema` | `EntityListResponseSchema` | Úsalo si **no** aplicas patrón Criteria. |
| GET | `/entities/:id` | Obtener por ID | `GetEntityByIdParamsSchema` | `EntityDetailResponseSchema` | Si devuelves el recurso “puro”, puedes usar `EntityResourceSchema`. |
| POST | `/entities` | Crear recurso | `CreateEntityCommandSchema` | `EntityCreatedResponseSchema` **o** `EntityResourceSchema` | Si devuelves 201 + recurso completo, usa `EntityResourceSchema`. |
| PUT | `/entities/:id` | Reemplazar recurso (idempotente) | `UpdateEntityCommandSchema` + `UpdateEntityByIdParamsSchema` | `EntityResourceSchema` | También válido con PATCH para cambios parciales. |
| PATCH | `/entities/:id` | Actualizar parcialmente | `PatchEntityCommandSchema` + `UpdateEntityByIdParamsSchema` | `EntityResourceSchema` | Usa un comando separado si el payload difiere del PUT. |
| DELETE | `/entities/:id` | Eliminar por ID | `DeleteEntityByIdParamsSchema` | — (204 No Content) **o** `EntityDeletedResponseSchema` | 204 recomendado. Si devuelves body, usa `EntityDeletedResponseSchema`. |
---
## Esquemas reutilizables (Responses)
- **Item para listados:**
`EntitySummarySchema`
- **Recurso completo:**
`EntityResourceSchema`
- **Respuestas compuestas:**
- `EntityListResponseSchema``data: EntitySummarySchema[]` + paginación/meta
- `EntityDetailResponseSchema` → típicamente `EntityResourceSchema`
- `EntityCreatedResponseSchema``{ id, createdAt, ... }` si no devuelves el recurso completo
- `EntityDeletedResponseSchema``{ id, deleted: true }` si no usas 204
---
## Convenciones de sufijos
### Requests
- `...CommandSchema`**body** (mutaciones)
- `...ParamsSchema`**route params** (`/:id`)
- `...QuerySchema`**query string** (`?page=...`)
- `...CriteriaSchema` → para queries normalizadas con patrón Criteria
### Responses
- `...ListResponseSchema` → listado paginado
- `...SummarySchema` → item de listado
- `...DetailResponseSchema` → detalle de recurso
- `...ResourceSchema` → representación completa del recurso
- `...CreatedResponseSchema` → recurso recién creado
- `...DeletedResponseSchema` → confirmación de borrado (si no usas 204)
# Ejemplos de nombres de Schemas por entidad
## 1. Customers
### Requests
- `CreateCustomerCommandSchema`
- `UpdateCustomerCommandSchema`
- `PatchCustomerCommandSchema`
- `DeleteCustomerByIdParamsSchema`
- `GetCustomerByIdParamsSchema`
- `CustomerListCriteriaSchema`
- `SearchCustomerQuerySchema`
### Responses
- `CustomerSummarySchema`
- `CustomerResourceSchema`
- `CustomerListResponseSchema`
- `CustomerDetailResponseSchema`
- `CustomerCreatedResponseSchema`
- `CustomerDeletedResponseSchema`
---
## 2. Products
### Requests
- `CreateProductCommandSchema`
- `UpdateProductCommandSchema`
- `PatchProductCommandSchema`
- `DeleteProductByIdParamsSchema`
- `GetProductByIdParamsSchema`
- `ProductListCriteriaSchema`
- `SearchProductQuerySchema`
### Responses
- `ProductSummarySchema`
- `ProductResourceSchema`
- `ProductListResponseSchema`
- `ProductDetailResponseSchema`
- `ProductCreatedResponseSchema`
- `ProductDeletedResponseSchema`
---
## Notas
- Reutilizar esquemas básicos (`EntitySummarySchema`, `EntityResourceSchema`) en respuestas compuestas para cumplir el principio **DRY**.
- **Sufijos**: mantener siempre los sufijos `CommandSchema`, `ParamsSchema`, `QuerySchema`, `CriteriaSchema`, `ListResponseSchema`, `SummarySchema`, `DetailResponseSchema`, `ResourceSchema`, `CreatedResponseSchema`, `DeletedResponseSchema`.
- **Pluralización**: usar singular para el nombre de la entidad en los Schemas (ej. `Customer`, `Product`), incluso si la ruta es plural (`/customers`, `/products`).
- **Carpetas sugeridas**:
modules/
<entity>/
application/
dto/
requests/
responses/
domain/
infrastructure/
api/

View File

@ -11,7 +11,6 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@testing-library/react-hooks": "^8.0.1",
"@types/axios": "^0.14.4",
"@types/dinero.js": "^1.9.4",
"@types/express": "^4.17.21",

View File

@ -0,0 +1,27 @@
import * as z from "zod/v4";
/**
Esquema del objeto normalizado esperado por Criteria.fromPrimitives(...)
No aplica defaults ni correciones: solo valida.
*/
export const FilterPrimitiveSchema = z.object({
// Campos mínimos ya normalizados por el conversor
field: z.string(),
operator: z.string(),
value: z.string(),
});
export const CriteriaSchema = z.object({
filters: z.array(FilterPrimitiveSchema),
// Preferimos omitido; si viene, no puede ser cadena vacía
orderBy: z.string().min(1).optional(),
order: z.enum(["asc", "desc"]),
// Ya son números (normalizados); validaciones lógicas
pageSize: z.number().int().min(1, { message: "pageSize must be a positive integer" }),
pageNumber: z.number().int().min(0, { message: "pageNumber must be a non-negative integer" }),
});
export type CriteriaDTO = z.infer<typeof CriteriaSchema>;

View File

@ -1,3 +1,4 @@
export * from "./critera.dto";
export * from "./error.dto";
export * from "./list.view.dto";
export * from "./metadata.dto";

View File

@ -2,12 +2,12 @@ import * as z from "zod/v4";
import { MetadataSchema } from "./metadata.dto";
/**
* Crea un esquema Zod que representa un ListViewDTO genérico.
* Crea un esquema Zod que representa un resultado ListViewDTO genérico.
*
* @param itemSchema Esquema Zod del elemento T
* @returns Zod schema para ListViewDTO<T>
*/
export const createListViewSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
export const createListViewResultSchema = <T extends z.ZodTypeAny>(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"),

View File

@ -10,7 +10,11 @@
"./globals.css": "./src/web/globals.css"
},
"peerDependencies": {
"dinero.js": "^1.9.1"
"@erp/core": "workspace:*",
"dinero.js": "^1.9.1",
"express": "^4.18.2",
"sequelize": "^6.37.5",
"zod": "^3.25.67"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
@ -27,7 +31,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@erp/core": "workspace:*",
"@erp/customers": "workspace:*",
"@hookform/resolvers": "^5.0.1",
"@repo/rdx-criteria": "workspace:*",
@ -40,7 +43,6 @@
"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",
@ -48,11 +50,9 @@
"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",
"sonner": "^2.0.5",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.4",
"zod": "^3.25.67"
"tw-animate-css": "^1.3.4"
}
}

View File

@ -5,7 +5,6 @@ import {
CreateCustomerInvoiceCommandSchema,
DeleteCustomerInvoiceByIdQuerySchema,
DeleteCustomerInvoiceByIdQuerySchema as GetCustomerInvoiceByIdQuerySchema,
ListCustomerInvoicesQuerySchema,
} from "../../../common/dto";
import {
buildCreateCustomerInvoicesController,
@ -28,7 +27,7 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
"/",
//checkTabContext,
//checkUser,
validateRequest(ListCustomerInvoicesQuerySchema, "params"),
validateRequest(CustomerInvoiceListCriteriaSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
buildListCustomerInvoicesController(database).execute(req, res, next);
}

View File

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

View File

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

View File

@ -1,33 +1,8 @@
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).
*
*/
import { criteriaSchema } from "@erp/core/criteria"; // El esquema genérico validado antes
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 const CustomerInvoiceListCriteriaSchema = criteriaSchema;
export type CustomerInvoiceListCriteria = z.infer<typeof CustomerInvoiceListCriteriaSchema>;
export type ListCustomerInvoicesQueryDTO = z.infer<typeof ListCustomerInvoicesQuerySchema>;

View File

@ -1,7 +1,7 @@
import { MetadataSchema, createListViewSchema } from "@erp/core";
import { MetadataSchema, createListViewResultSchema } from "@erp/core";
import * as z from "zod/v4";
export const ListCustomerInvoicesResultSchema = createListViewSchema(
export const ListCustomerInvoicesResultSchema = createListViewResultSchema(
z.object({
id: z.uuid(),
invoice_status: z.string(),

View File

@ -10,8 +10,17 @@
"./globals.css": "./src/web/globals.css",
"./components": "./src/web/components/index.ts"
},
"peerDependencies": {
"@erp/core": "workspace:*",
"dinero.js": "^1.9.1",
"express": "^4.18.2",
"sequelize": "^6.37.5",
"zod": "^3.25.67"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@hookform/devtools": "^4.4.0",
"@types/dinero.js": "^1.9.4",
"@types/express": "^4.17.21",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
@ -20,7 +29,10 @@
},
"dependencies": {
"@ag-grid-community/locale": "34.0.0",
"@erp/core": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@erp/customers": "workspace:*",
"@hookform/resolvers": "^5.0.1",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
@ -28,24 +40,20 @@
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.74.11",
"@tanstack/react-table": "^8.21.3",
"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-data-table-component": "^7.7.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",
"sonner": "^2.0.5",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"use-debounce": "^10.0.5",
"use-query": "^1.0.2",
"zod": "^3.25.67"
"tw-animate-css": "^1.3.4"
}
}

View File

@ -0,0 +1,57 @@
import { DuplicateEntityError, ITransactionManager } from "@erp/core/api";
import { CreateCustomerCommandDTO } from "@erp/customers/common/dto";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { ICustomerService } from "../../domain";
import { mapDTOToCustomerProps } from "../helpers";
import { CreateCustomersPresenter } from "./presenter";
export class CreateCustomerUseCase {
constructor(
private readonly service: ICustomerService,
private readonly transactionManager: ITransactionManager,
private readonly presenter: CreateCustomersPresenter
) {}
public execute(dto: CreateCustomerCommandDTO) {
const invoicePropsOrError = mapDTOToCustomerProps(dto);
if (invoicePropsOrError.isFailure) {
return Result.fail(invoicePropsOrError.error);
}
const { props, id } = invoicePropsOrError.data;
const invoiceOrError = this.service.build(props, id);
if (invoiceOrError.isFailure) {
return Result.fail(invoiceOrError.error);
}
const newInvoice = invoiceOrError.data;
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const duplicateCheck = await this.service.existsById(id, transaction);
if (duplicateCheck.isFailure) {
return Result.fail(duplicateCheck.error);
}
if (duplicateCheck.data) {
return Result.fail(new DuplicateEntityError("Customer", id.toString()));
}
const result = await this.service.save(newInvoice, transaction);
if (result.isFailure) {
return Result.fail(result.error);
}
const viewDTO = this.presenter.toDTO(newInvoice);
return Result.ok(viewDTO);
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,2 @@
export * from "./create-customer.use-case";
export * from "./presenter";

View File

@ -0,0 +1,27 @@
import { Customer } from "@erp/customers/api/domain";
import { CustomersCreationResultDTO } from "@erp/customers/common/dto";
export class CreateCustomersPresenter {
public toDTO(invoice: Customer): CustomersCreationResultDTO {
return {
id: invoice.id.toPrimitive(),
invoice_status: invoice.status.toString(),
invoice_number: invoice.invoiceNumber.toString(),
invoice_series: invoice.invoiceSeries.toString(),
issue_date: invoice.issueDate.toISOString(),
operation_date: invoice.operationDate.toISOString(),
language_code: "ES",
currency: "EUR",
//subtotal_price: invoice.calculateSubtotal().toPrimitive(),
//total_price: invoice.calculateTotal().toPrimitive(),
//recipient: CustomerParticipantPresenter(customer.recipient),
metadata: {
entity: "customer",
},
};
}
}

View File

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

View File

@ -0,0 +1,40 @@
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
import { DeleteCustomerByIdQueryDTO } from "@erp/customers/common/dto";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { ICustomerService } from "../../domain";
export class DeleteCustomerUseCase {
constructor(
private readonly service: ICustomerService,
private readonly transactionManager: ITransactionManager
) {}
public execute(dto: DeleteCustomerByIdQueryDTO) {
const idOrError = UniqueID.create(dto.id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const id = idOrError.data;
return this.transactionManager.complete(async (transaction) => {
try {
const existsCheck = await this.service.existsById(id, transaction);
if (existsCheck.isFailure) {
return Result.fail(existsCheck.error);
}
if (!existsCheck.data) {
return Result.fail(new EntityNotFoundError("Customer", id.toString()));
}
return await this.service.deleteById(id, transaction);
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

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

View File

@ -0,0 +1,36 @@
import { ITransactionManager } from "@erp/core/api";
import { GetCustomerByIdQueryDTO } from "@erp/customers/common/dto";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { ICustomerService } from "../../domain";
import { GetCustomerPresenter } from "./presenter";
export class GetCustomerUseCase {
constructor(
private readonly service: ICustomerService,
private readonly transactionManager: ITransactionManager,
private readonly presenter: GetCustomerPresenter
) {}
public execute(dto: GetCustomerByIdQueryDTO) {
const idOrError = UniqueID.create(dto.id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
return this.transactionManager.complete(async (transaction) => {
try {
const invoiceOrError = await this.service.getById(idOrError.data, transaction);
if (invoiceOrError.isFailure) {
return Result.fail(invoiceOrError.error);
}
const getDTO = this.presenter.toDTO(invoiceOrError.data);
return Result.ok(getDTO);
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

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

View File

@ -0,0 +1,16 @@
import { CustomerItem } from "#/server/domain";
import { IInvoicingContext } from "#/server/intrastructure";
import { Collection } from "@rdx/core";
export const customerItemPresenter = (items: Collection<CustomerItem>, context: IInvoicingContext) =>
items.totalCount > 0
? items.items.map((item: CustomerItem) => ({
description: item.description.toString(),
quantity: item.quantity.toString(),
unit_measure: "",
unit_price: item.unitPrice.toPrimitive() as IMoney_Response_DTO,
subtotal: item.calculateSubtotal().toPrimitive() as IMoney_Response_DTO,
tax_amount: item.calculateTaxAmount().toPrimitive() as IMoney_Response_DTO,
total: item.calculateTotal().toPrimitive() as IMoney_Response_DTO,
}))
: [];

View File

@ -0,0 +1,26 @@
import { ICustomerParticipant } from "@/contexts/invoicing/domain";
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
import { ICreateCustomer_Participant_Response_DTO } from "@shared/contexts";
import { CustomerParticipantAddressPresenter } from "./CustomerParticipantAddress.presenter";
export const CustomerParticipantPresenter = async (
participant: ICustomerParticipant,
context: IInvoicingContext,
): Promise<ICreateCustomer_Participant_Response_DTO | undefined> => {
return {
id: participant.id.toString(),
tin: participant.tin.toString(),
first_name: participant.firstName.toString(),
last_name: participant.lastName.toString(),
company_name: participant.companyName.toString(),
billing_address: await CustomerParticipantAddressPresenter(
participant.billingAddress!,
context,
),
shipping_address: await CustomerParticipantAddressPresenter(
participant.shippingAddress!,
context,
),
};
};

View File

@ -0,0 +1,19 @@
import { CustomerParticipantAddress } from "@/contexts/invoicing/domain";
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
import { ICreateCustomer_AddressParticipant_Response_DTO } from "@shared/contexts";
export const CustomerParticipantAddressPresenter = async (
address: CustomerParticipantAddress,
context: IInvoicingContext,
): Promise<ICreateCustomer_AddressParticipant_Response_DTO> => {
return {
id: address.id.toString(),
street: address.street.toString(),
city: address.city.toString(),
postal_code: address.postalCode.toString(),
province: address.province.toString(),
country: address.country.toString(),
email: address.email.toString(),
phone: address.phone.toString(),
};
};

View File

@ -0,0 +1,65 @@
import { GetCustomerByIdResultDTO } from "../../../../common/dto";
import { Customer } from "../../../domain";
export interface GetCustomerPresenter {
toDTO: (customer: Customer) => GetCustomerByIdResultDTO;
}
export const getCustomerPresenter: GetCustomerPresenter = {
toDTO: (customer: Customer): GetCustomerByIdResultDTO => ({
id: customer.id.toPrimitive(),
invoice_status: customer.status.toString(),
invoice_number: customer.invoiceNumber.toString(),
invoice_series: customer.invoiceSeries.toString(),
issue_date: customer.issueDate.toDateString(),
operation_date: customer.operationDate.toDateString(),
language_code: "ES",
currency: customer.currency,
metadata: {
entity: "customers",
},
//subtotal: customer.calculateSubtotal().toPrimitive(),
//total: customer.calculateTotal().toPrimitive(),
/*items:
customer.items.size() > 0
? customer.items.map((item: CustomerItem) => ({
description: item.description.toString(),
quantity: item.quantity.toPrimitive(),
unit_measure: "",
unit_price: item.unitPrice.toPrimitive(),
subtotal: item.calculateSubtotal().toPrimitive(),
//tax_amount: item.calculateTaxAmount().toPrimitive(),
total: item.calculateTotal().toPrimitive(),
}))
: [],*/
//sender: {}, //await CustomerParticipantPresenter(customer.senderId, context),
/*recipient: await CustomerParticipantPresenter(customer.recipient, context),
items: customerItemPresenter(customer.items, context),
payment_term: {
payment_type: "",
due_date: "",
},
due_amount: {
currency: customer.currency.toString(),
precision: 2,
amount: 0,
},
custom_fields: [],
metadata: {
create_time: "",
last_updated_time: "",
delete_time: "",
},*/
}),
};

View File

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

View File

@ -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<T>(
result: Result<T, Error>,
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;
}

View File

@ -0,0 +1,50 @@
/**
*
* @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<T extends Record<string, any>>(
obj: T
): obj is { [K in keyof T]-?: Exclude<T[K], undefined> } {
return Object.values(obj).every((value) => value !== undefined);
}
/**
*
* @description Verifica si un objeto tiene campos con valor undefined.
* Esta función es el complemento de `hasNoUndefinedFields`.
*
* @example
* const obj = { a: 1, b: 'test', c: null };
* console.log(hasUndefinedFields(obj)); // false
*
* const objWithUndefined = { a: 1, b: undefined, c: null };
* console.log(hasUndefinedFields(objWithUndefined)); // true
*
* @template T - El tipo del objeto.
* @param obj - El objeto a evaluar.
* @returns true si el objeto tiene al menos un campo undefined, false en caso contrario.
*
*/
export function hasUndefinedFields<T extends Record<string, any>>(
obj: T
): obj is { [K in keyof T]-?: Exclude<T[K], undefined> } {
return !hasNoUndefinedFields(obj);
}

View File

@ -0,0 +1 @@
export * from "./map-dto-to-customer-props";

View File

@ -0,0 +1,83 @@
import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api";
import { CreateCustomerCommandDTO } from "@erp/customers/common/dto";
import { Result } from "@repo/rdx-utils";
import {
CustomerItem,
CustomerItemDescription,
CustomerItemDiscount,
CustomerItemQuantity,
CustomerItemUnitPrice,
} from "../../domain";
import { extractOrPushError } from "./extract-or-push-error";
import { hasNoUndefinedFields } from "./has-no-undefined-fields";
export function mapDTOToCustomerItemsProps(
dtoItems: Pick<CreateCustomerCommandDTO, "items">["items"]
): Result<CustomerItem[], ValidationErrorCollection> {
const errors: ValidationErrorDetail[] = [];
const items: CustomerItem[] = [];
dtoItems.forEach((item, index) => {
const path = (field: string) => `items[${index}].${field}`;
const description = extractOrPushError(
CustomerItemDescription.create(item.description),
path("description"),
errors
);
const quantity = extractOrPushError(
CustomerItemQuantity.create({
amount: item.quantity.amount,
scale: item.quantity.scale,
}),
path("quantity"),
errors
);
const unitPrice = extractOrPushError(
CustomerItemUnitPrice.create({
amount: item.unitPrice.amount,
scale: item.unitPrice.scale,
currency_code: item.unitPrice.currency,
}),
path("unit_price"),
errors
);
const discount = extractOrPushError(
CustomerItemDiscount.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 = CustomerItem.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);
}

View File

@ -0,0 +1,78 @@
import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CreateCustomerCommandDTO } from "../../../common/dto";
import { CustomerNumber, CustomerProps, CustomerSerie, CustomerStatus } from "../../domain";
import { extractOrPushError } from "./extract-or-push-error";
import { mapDTOToCustomerItemsProps } from "./map-dto-to-customer-items-props";
/**
* Convierte el DTO a las props validadas (CustomerProps).
* No construye directamente el agregado.
*
* @param dto - DTO con los datos de la factura de cliente
* @returns
*
*/
export function mapDTOToCustomerProps(dto: CreateCustomerCommandDTO) {
const errors: ValidationErrorDetail[] = [];
const invoiceId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
const invoiceNumber = extractOrPushError(
CustomerNumber.create(dto.invoice_number),
"invoice_number",
errors
);
const invoiceSeries = extractOrPushError(
CustomerSerie.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
);
//const currency = extractOrPushError(Currency.(dto.currency), "currency", errors);
const currency = dto.currency;
// 🔄 Validar y construir los items de factura con helper especializado
const itemsResult = mapDTOToCustomerItemsProps(dto.items);
if (itemsResult.isFailure) {
return Result.fail(itemsResult.error);
}
if (errors.length > 0) {
return Result.fail(new ValidationErrorCollection(errors));
}
const invoiceProps: CustomerProps = {
invoiceNumber: invoiceNumber!,
invoiceSeries: invoiceSeries!,
issueDate: issueDate!,
operationDate: operationDate!,
status: CustomerStatus.createDraft(),
currency,
};
return Result.ok({ id: invoiceId!, props: invoiceProps });
/*if (hasNoUndefinedFields(invoiceProps)) {
const invoiceOrError = Customer.create(invoiceProps, invoiceId);
if (invoiceOrError.isFailure) {
return Result.fail(invoiceOrError.error);
}
return Result.ok(invoiceOrError.data);
}
return Result.fail(
new ValidationErrorCollection([
{ path: "", message: "Error building from DTO: Some fields are undefined" },
])
);*/
}

View File

@ -0,0 +1,5 @@
export * from "./create-customer";
export * from "./delete-customer";
export * from "./get-customer";
export * from "./list-customers";
//export * from "./update-customer";

View File

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

View File

@ -0,0 +1,33 @@
import { ITransactionManager } from "@erp/core/api";
import { ListCustomersResultDTO } from "@erp/customers/common/dto";
import { Criteria } from "@repo/rdx-criteria/server";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { ICustomerService } from "../../domain";
import { ListCustomersPresenter } from "./presenter";
export class ListCustomersUseCase {
constructor(
private readonly customerService: ICustomerService,
private readonly transactionManager: ITransactionManager,
private readonly presenter: ListCustomersPresenter
) {}
public execute(criteria: Criteria): Promise<Result<ListCustomersResultDTO, Error>> {
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const result = await this.customerService.findByCriteria(criteria, transaction);
if (result.isFailure) {
console.error(result.error);
return Result.fail(result.error);
}
const dto: ListCustomersResultDTO = this.presenter.toDTO(result.data, criteria);
return Result.ok(dto);
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

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

View File

@ -0,0 +1,54 @@
import { Criteria } from "@repo/rdx-criteria/server";
import { Collection } from "@repo/rdx-utils";
import { CustomerListResponsetDTO } from "../../../../common/dto";
import { Customer } from "../../../domain";
export interface ListCustomersPresenter {
toDTO: (customers: Collection<Customer>, criteria: Criteria) => CustomerListResponsetDTO;
}
export const listCustomersPresenter: ListCustomersPresenter = {
toDTO: (customers: Collection<Customer>, criteria: Criteria): CustomerListResponsetDTO => {
const items = customers.map((invoice) => {
return {
id: invoice.id.toPrimitive(),
invoice_status: invoice.status.toString(),
invoice_number: invoice.invoiceNumber.toString(),
invoice_series: invoice.invoiceSeries.toString(),
issue_date: invoice.issueDate.toISOString(),
operation_date: invoice.operationDate.toISOString(),
language_code: "ES",
currency: "EUR",
subtotal_price: invoice.calculateSubtotal().toPrimitive(),
total_price: invoice.calculateTotal().toPrimitive(),
//recipient: CustomerParticipantPresenter(customer.recipient),
metadata: {
entity: "customer",
},
};
});
const totalItems = customers.total();
return {
page: criteria.pageNumber,
per_page: criteria.pageSize,
total_pages: Math.ceil(totalItems / criteria.pageSize),
total_items: totalItems,
items: items,
metadata: {
entity: "customers",
criteria: criteria.toJSON(),
//links: {
// self: `/api/customers?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`,
// first: `/api/customers?page=1&per_page=${criteria.pageSize}`,
// last: `/api/customers?page=${Math.ceil(totalItems / criteria.pageSize)}&per_page=${criteria.pageSize}`,
//},
},
};
},
};

View File

@ -0,0 +1,2 @@
//export * from "./participantAddressFinder";
//export * from "./participantFinder";

View File

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

View File

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

View File

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

View File

@ -0,0 +1,401 @@
import { UniqueID } from "@/core/common/domain";
import { ITransactionManager } from "@/core/common/infrastructure/database";
import { Result } from "@repo/rdx-utils";
import { IUpdateCustomerRequestDTO } from "../../common/dto";
import { Customer, ICustomerService } from "../domain";
export class CreateCustomerUseCase {
constructor(
private readonly customerService: ICustomerService,
private readonly transactionManager: ITransactionManager
) {}
public execute(
customerID: UniqueID,
dto: Partial<IUpdateCustomerRequestDTO>
): Promise<Result<Customer, Error>> {
return this.transactionManager.complete(async (transaction) => {
return Result.fail(new Error("No implementado"));
/*
try {
const validOrErrors = this.validateCustomerData(dto);
if (validOrErrors.isFailure) {
return Result.fail(validOrErrors.error);
}
const data = validOrErrors.data;
// Update customer with dto
return await this.customerService.updateCustomerById(customerID, data, transaction);
} catch (error: unknown) {
logger.error(error as Error);
return Result.fail(error as Error);
}
*/
});
}
/* private validateCustomerData(
dto: Partial<IUpdateCustomerRequestDTO>
): Result<Partial<ICustomerProps>, Error> {
const errors: Error[] = [];
const validatedData: Partial<ICustomerProps> = {};
// Create customer
let customer_status = CustomerStatus.create(customerDTO.status).object;
if (customer_status.isEmpty()) {
customer_status = CustomerStatus.createDraft();
}
let customer_series = CustomerSeries.create(customerDTO.customer_series).object;
if (customer_series.isEmpty()) {
customer_series = CustomerSeries.create(customerDTO.customer_series).object;
}
let issue_date = CustomerDate.create(customerDTO.issue_date).object;
if (issue_date.isEmpty()) {
issue_date = CustomerDate.createCurrentDate().object;
}
let operation_date = CustomerDate.create(customerDTO.operation_date).object;
if (operation_date.isEmpty()) {
operation_date = CustomerDate.createCurrentDate().object;
}
let customerCurrency = Currency.createFromCode(customerDTO.currency).object;
if (customerCurrency.isEmpty()) {
customerCurrency = Currency.createDefaultCode().object;
}
let customerLanguage = Language.createFromCode(customerDTO.language_code).object;
if (customerLanguage.isEmpty()) {
customerLanguage = Language.createDefaultCode().object;
}
const items = new Collection<CustomerItem>(
customerDTO.items?.map(
(item) =>
CustomerSimpleItem.create({
description: Description.create(item.description).object,
quantity: Quantity.create(item.quantity).object,
unitPrice: UnitPrice.create({
amount: item.unit_price.amount,
currencyCode: item.unit_price.currency,
precision: item.unit_price.precision,
}).object,
}).object
)
);
if (!customer_status.isDraft()) {
throw Error("Error al crear una factura que no es borrador");
}
return DraftCustomer.create(
{
customerSeries: customer_series,
issueDate: issue_date,
operationDate: operation_date,
customerCurrency,
language: customerLanguage,
customerNumber: CustomerNumber.create(undefined).object,
//notes: Note.create(customerDTO.notes).object,
//senderId: UniqueID.create(null).object,
recipient,
items,
},
customerId
);
} */
}
/* export type UpdateCustomerResponseOrError =
| Result<never, IUseCaseError> // Misc errors (value objects)
| Result<Customer, never>; // Success!
export class UpdateCustomerUseCase2
implements
IUseCase<{ id: UniqueID; data: IUpdateCustomer_DTO }, Promise<UpdateCustomerResponseOrError>>
{
private _context: IInvoicingContext;
private _adapter: ISequelizeAdapter;
private _repositoryManager: IRepositoryManager;
constructor(context: IInvoicingContext) {
this._context = context;
this._adapter = context.adapter;
this._repositoryManager = context.repositoryManager;
}
private getRepository<T>(name: string) {
return this._repositoryManager.getRepository<T>(name);
}
private handleValidationFailure(
validationError: Error,
message?: string
): Result<never, IUseCaseError> {
return Result.fail<IUseCaseError>(
UseCaseError.create(
UseCaseError.INVALID_INPUT_DATA,
message ? message : validationError.message,
validationError
)
);
}
async execute(request: {
id: UniqueID;
data: IUpdateCustomer_DTO;
}): Promise<UpdateCustomerResponseOrError> {
const { id, data: customerDTO } = request;
// Validaciones
const customerDTOOrError = ensureUpdateCustomer_DTOIsValid(customerDTO);
if (customerDTOOrError.isFailure) {
return this.handleValidationFailure(customerDTOOrError.error);
}
const transaction = this._adapter.startTransaction();
const customerRepoBuilder = this.getRepository<ICustomerRepository>("Customer");
let customer: Customer | null = null;
try {
await transaction.complete(async (t) => {
customer = await customerRepoBuilder({ transaction: t }).getById(id);
});
if (customer === null) {
return Result.fail<IUseCaseError>(
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, `Customer not found`, {
id: request.id.toString(),
entity: "customer",
})
);
}
return Result.ok<Customer>(customer);
} catch (error: unknown) {
const _error = error as Error;
if (customerRepoBuilder().isRepositoryError(_error)) {
return this.handleRepositoryError(error as BaseError, customerRepoBuilder());
} else {
return this.handleUnexceptedError(error);
}
}
// Recipient validations
const recipientIdOrError = ensureParticipantIdIsValid(
customerDTO?.recipient?.id,
);
if (recipientIdOrError.isFailure) {
return this.handleValidationFailure(
recipientIdOrError.error,
"Recipient ID not valid",
);
}
const recipientId = recipientIdOrError.object;
const recipientBillingIdOrError = ensureParticipantAddressIdIsValid(
customerDTO?.recipient?.billing_address_id,
);
if (recipientBillingIdOrError.isFailure) {
return this.handleValidationFailure(
recipientBillingIdOrError.error,
"Recipient billing address ID not valid",
);
}
const recipientBillingId = recipientBillingIdOrError.object;
const recipientShippingIdOrError = ensureParticipantAddressIdIsValid(
customerDTO?.recipient?.shipping_address_id,
);
if (recipientShippingIdOrError.isFailure) {
return this.handleValidationFailure(
recipientShippingIdOrError.error,
"Recipient shipping address ID not valid",
);
}
const recipientShippingId = recipientShippingIdOrError.object;
const recipientContact = await this.findContact(
recipientId,
recipientBillingId,
recipientShippingId,
);
if (!recipientContact) {
return this.handleValidationFailure(
new Error(`Recipient with ID ${recipientId.toString()} does not exist`),
);
}
// Crear customer
const customerOrError = await this.tryUpdateCustomerInstance(
customerDTO,
customerIdOrError.object,
//senderId,
//senderBillingId,
//senderShippingId,
recipientContact,
);
if (customerOrError.isFailure) {
const { error: domainError } = customerOrError;
let errorCode = "";
let message = "";
switch (domainError.code) {
case Customer.ERROR_CUSTOMER_WITHOUT_NAME:
errorCode = UseCaseError.INVALID_INPUT_DATA;
message =
"El cliente debe ser una compañía o tener nombre y apellidos.";
break;
default:
errorCode = UseCaseError.UNEXCEPTED_ERROR;
message = "";
break;
}
return Result.fail<IUseCaseError>(
UseCaseError.create(errorCode, message, domainError),
);
}
return this.saveCustomer(customerOrError.object);
}
private async tryUpdateCustomerInstance(customerDTO, customerId, recipient) {
// Create customer
let customer_status = CustomerStatus.create(customerDTO.status).object;
if (customer_status.isEmpty()) {
customer_status = CustomerStatus.createDraft();
}
let customer_series = CustomerSeries.create(customerDTO.customer_series).object;
if (customer_series.isEmpty()) {
customer_series = CustomerSeries.create(customerDTO.customer_series).object;
}
let issue_date = CustomerDate.create(customerDTO.issue_date).object;
if (issue_date.isEmpty()) {
issue_date = CustomerDate.createCurrentDate().object;
}
let operation_date = CustomerDate.create(customerDTO.operation_date).object;
if (operation_date.isEmpty()) {
operation_date = CustomerDate.createCurrentDate().object;
}
let customerCurrency = Currency.createFromCode(customerDTO.currency).object;
if (customerCurrency.isEmpty()) {
customerCurrency = Currency.createDefaultCode().object;
}
let customerLanguage = Language.createFromCode(customerDTO.language_code).object;
if (customerLanguage.isEmpty()) {
customerLanguage = Language.createDefaultCode().object;
}
const items = new Collection<CustomerItem>(
customerDTO.items?.map(
(item) =>
CustomerSimpleItem.create({
description: Description.create(item.description).object,
quantity: Quantity.create(item.quantity).object,
unitPrice: UnitPrice.create({
amount: item.unit_price.amount,
currencyCode: item.unit_price.currency,
precision: item.unit_price.precision,
}).object,
}).object
)
);
if (!customer_status.isDraft()) {
throw Error("Error al crear una factura que no es borrador");
}
return DraftCustomer.create(
{
customerSeries: customer_series,
issueDate: issue_date,
operationDate: operation_date,
customerCurrency,
language: customerLanguage,
customerNumber: CustomerNumber.create(undefined).object,
//notes: Note.create(customerDTO.notes).object,
//senderId: UniqueID.create(null).object,
recipient,
items,
},
customerId
);
}
private async findContact(
contactId: UniqueID,
billingAddressId: UniqueID,
shippingAddressId: UniqueID
) {
const contactRepoBuilder = this.getRepository<IContactRepository>("Contact");
const contact = await contactRepoBuilder().getById2(
contactId,
billingAddressId,
shippingAddressId
);
return contact;
}
private async saveCustomer(customer: DraftCustomer) {
const transaction = this._adapter.startTransaction();
const customerRepoBuilder = this.getRepository<ICustomerRepository>("Customer");
try {
await transaction.complete(async (t) => {
const customerRepo = customerRepoBuilder({ transaction: t });
await customerRepo.save(customer);
});
return Result.ok<DraftCustomer>(customer);
} catch (error: unknown) {
const _error = error as Error;
if (customerRepoBuilder().isRepositoryError(_error)) {
return this.handleRepositoryError(error as BaseError, customerRepoBuilder());
} else {
return this.handleUnexceptedError(error);
}
}
}
private handleUnexceptedError(error): Result<never, IUseCaseError> {
return Result.fail<IUseCaseError>(
UseCaseError.create(UseCaseError.UNEXCEPTED_ERROR, error.message, error)
);
}
private handleRepositoryError(
error: BaseError,
repository: ICustomerRepository
): Result<never, IUseCaseError> {
const { message, details } = repository.handleRepositoryError(error);
return Result.fail<IUseCaseError>(
UseCaseError.create(UseCaseError.REPOSITORY_ERROR, message, details)
);
}
}
*/

View File

@ -0,0 +1,34 @@
import { ExpressController, errorMapper } from "@erp/core/api";
import { CreateCustomerCommandDTO } from "../../../common/dto";
import { CreateCustomerUseCase } from "../../application";
export class CreateCustomerController extends ExpressController {
public constructor(private readonly createCustomer: CreateCustomerUseCase) {
super();
}
protected async executeImpl() {
const dto = this.req.body as CreateCustomerCommandDTO;
/*
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.createCustomer.execute(dto);
if (result.isFailure) {
console.log(result.error);
const apiError = errorMapper.toApiError(result.error);
return this.handleApiError(apiError);
}
return this.created(result.data);
}
}

View File

@ -0,0 +1,17 @@
import { SequelizeTransactionManager } from "@erp/core/api";
import { Sequelize } from "sequelize";
import { CreateCustomerUseCase, CreateCustomersPresenter } from "../../application/";
import { CustomerService } from "../../domain";
import { CustomerMapper } from "../../infrastructure";
import { CreateCustomerController } from "./create-customer";
export const buildCreateCustomersController = (database: Sequelize) => {
const transactionManager = new SequelizeTransactionManager(database);
const customerRepository = new customerRepository(database, new CustomerMapper());
const customerService = new CustomerService(customerRepository);
const presenter = new CreateCustomersPresenter();
const useCase = new CreateCustomerUseCase(customerService, transactionManager, presenter);
return new CreateCustomerController(useCase);
};

View File

@ -0,0 +1,30 @@
import { ExpressController, errorMapper } from "@erp/core/api";
import { DeleteCustomerUseCase } from "../../application";
export class DeleteCustomerController extends ExpressController {
public constructor(private readonly deleteCustomer: DeleteCustomerUseCase) {
super();
}
async executeImpl(): Promise<any> {
const { id } = this.req.params;
/*
const user = this.req.user; // asumimos middleware authenticateJWT inyecta user
if (!user || !user.companyId) {
this.unauthorized(res, "Unauthorized: user or company not found");
return;
}
*/
const result = await this.deleteCustomer.execute({ id });
if (result.isFailure) {
const apiError = errorMapper.toApiError(result.error);
return this.handleApiError(apiError);
}
return this.ok(result.data);
}
}

View File

@ -0,0 +1,16 @@
import { SequelizeTransactionManager } from "@erp/core/api";
import { Sequelize } from "sequelize";
import { DeleteCustomerUseCase } from "../../application";
import { CustomerService } from "../../domain";
import { CustomerMapper } from "../../infrastructure";
import { DeleteCustomerController } from "./delete-invoice.controller";
export const buildDeleteCustomerController = (database: Sequelize) => {
const transactionManager = new SequelizeTransactionManager(database);
const customerRepository = new customerRepository(database, new CustomerMapper());
const customerService = new CustomerService(customerRepository);
const useCase = new DeleteCustomerUseCase(customerService, transactionManager);
return new DeleteCustomerController(useCase);
};

View File

@ -0,0 +1,30 @@
import { ExpressController, errorMapper } from "@erp/core/api";
import { GetCustomerUseCase } from "../../application";
export class GetCustomerController extends ExpressController {
public constructor(private readonly getCustomer: GetCustomerUseCase) {
super();
}
protected async executeImpl() {
const { id } = this.req.params;
/*
const user = this.req.user; // asumimos middleware authenticateJWT inyecta user
if (!user || !user.companyId) {
this.unauthorized(res, "Unauthorized: user or company not found");
return;
}
*/
const result = await this.getCustomer.execute({ id });
if (result.isFailure) {
const apiError = errorMapper.toApiError(result.error);
return this.handleApiError(apiError);
}
return this.ok(result.data);
}
}

View File

@ -0,0 +1,17 @@
import { SequelizeTransactionManager } from "@erp/core/api";
import { Sequelize } from "sequelize";
import { GetCustomerUseCase, getCustomerPresenter } from "../../application";
import { CustomerService } from "../../domain";
import { customerMapper } from "../../infrastructure";
import { GetCustomerController } from "./get-invoice.controller";
export const buildGetCustomerController = (database: Sequelize) => {
const transactionManager = new SequelizeTransactionManager(database);
const repository = new CustomerRepository(database, customerMapper);
const customerService = new CustomerService(customerRepository);
const presenter = getCustomerPresenter;
const useCase = new GetCustomerUseCase(customerService, transactionManager, presenter);
return new GetCustomerController(useCase);
};

View File

@ -0,0 +1,5 @@
export * from "./create-customer";
export * from "./delete-customer";
export * from "./get-customer";
export * from "./list-customers";
///export * from "./update-customer";

View File

@ -0,0 +1,18 @@
import { SequelizeTransactionManager } from "@erp/core/api";
import { Sequelize } from "sequelize";
import { ListCustomersUseCase } from "../../application";
import { listCustomersPresenter } from "../../application/list-customers/presenter";
import { CustomerService } from "../../domain";
import { CustomerRepository, customerMapper } from "../../infrastructure";
import { ListCustomersController } from "./list-customers.controller";
export const buildListCustomersController = (database: Sequelize) => {
const transactionManager = new SequelizeTransactionManager(database);
const repository = new CustomerRepository(database, customerMapper);
const customerService = new CustomerService(repository);
const presenter = listCustomersPresenter;
const useCase = new ListCustomersUseCase(customerService, transactionManager, presenter);
return new ListCustomersController(useCase);
};

View File

@ -0,0 +1,33 @@
import { ExpressController, errorMapper } from "@erp/core/api";
import { ListCustomersUseCase } from "../../application";
export class ListCustomersController extends ExpressController {
public constructor(private readonly listCustomers: ListCustomersUseCase) {
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.listCustomers.execute(criteria);
if (result.isFailure) {
const apiError = errorMapper.toApiError(result.error);
return this.handleApiError(apiError);
}
return this.ok(result.data);
}
}

View File

@ -0,0 +1,57 @@
import { IInvoicingContext } from "#/server/intrastructure";
import { CustomerRepository } from "#/server/intrastructure/Customer.repository";
export const updateCustomerController = (context: IInvoicingContext) => {
const adapter = context.adapter;
const repoManager = context.repositoryManager;
repoManager.registerRepository("Customer", (params = { transaction: null }) => {
const { transaction } = params;
return new CustomerRepository({
transaction,
adapter,
mapper: createCustomerMapper(context),
});
});
repoManager.registerRepository("Participant", (params = { transaction: null }) => {
const { transaction } = params;
return new CustomerParticipantRepository({
transaction,
adapter,
mapper: createCustomerParticipantMapper(context),
});
});
repoManager.registerRepository("ParticipantAddress", (params = { transaction: null }) => {
const { transaction } = params;
return new CustomerParticipantAddressRepository({
transaction,
adapter,
mapper: createCustomerParticipantAddressMapper(context),
});
});
repoManager.registerRepository("Contact", (params = { transaction: null }) => {
const { transaction } = params;
return new ContactRepository({
transaction,
adapter,
mapper: createContactMapper(context),
});
});
const updateCustomerUseCase = new UpdateCustomerUseCase(context);
return new UpdateCustomerController(
{
useCase: updateCustomerUseCase,
presenter: updateCustomerPresenter,
},
context
);
};

View File

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

View File

@ -0,0 +1,26 @@
import { ICustomerParticipant } from "@/contexts/invoicing/domain";
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
import { IUpdateCustomer_Participant_Response_DTO } from "@shared/contexts";
import { CustomerParticipantAddressPresenter } from "./CustomerParticipantAddress.presenter";
export const CustomerParticipantPresenter = (
participant: ICustomerParticipant,
context: IInvoicingContext,
): IUpdateCustomer_Participant_Response_DTO | undefined => {
return {
id: participant.id.toString(),
tin: participant.tin.toString(),
first_name: participant.firstName.toString(),
last_name: participant.lastName.toString(),
company_name: participant.companyName.toString(),
billing_address: CustomerParticipantAddressPresenter(
participant.billingAddress!,
context,
),
shipping_address: CustomerParticipantAddressPresenter(
participant.shippingAddress!,
context,
),
};
};

View File

@ -0,0 +1,19 @@
import { CustomerParticipantAddress } from "@/contexts/invoicing/domain";
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
import { IUpdateCustomer_AddressParticipant_Response_DTO } from "@shared/contexts";
export const CustomerParticipantAddressPresenter = (
address: CustomerParticipantAddress,
context: IInvoicingContext,
): IUpdateCustomer_AddressParticipant_Response_DTO => {
return {
id: address.id.toString(),
street: address.street.toString(),
city: address.city.toString(),
postal_code: address.postalCode.toString(),
province: address.province.toString(),
country: address.country.toString(),
email: address.email.toString(),
phone: address.phone.toString(),
};
};

View File

@ -0,0 +1,33 @@
import { Customer } from "@/contexts/invoicing/domain";
import { IInvoicingContext } from "@/contexts/invoicing/intrastructure/InvoicingContext";
import { IUpdateCustomer_Response_DTO } from "@shared/contexts";
import { customerItemPresenter } from "./CustomerItem.presenter";
import { CustomerParticipantPresenter } from "./CustomerParticipant.presenter";
export interface IUpdateCustomerPresenter {
map: (customer: Customer, context: IInvoicingContext) => IUpdateCustomer_Response_DTO;
}
export const updateCustomerPresenter: IUpdateCustomerPresenter = {
map: (customer: Customer, context: IInvoicingContext): IUpdateCustomer_Response_DTO => {
return {
id: customer.id.toString(),
customer_status: customer.status.toString(),
customer_number: customer.customerNumber.toString(),
customer_series: customer.customerSeries.toString(),
issue_date: customer.issueDate.toISO8601(),
operation_date: customer.operationDate.toISO8601(),
language_code: customer.language.toString(),
currency: customer.currency.toString(),
subtotal: customer.calculateSubtotal().toPrimitive(),
total: customer.calculateTotal().toPrimitive(),
//sender: {}, //await CustomerParticipantPresenter(customer.senderId, context),
recipient: CustomerParticipantPresenter(customer.recipient, context),
items: customerItemPresenter(customer.items, context),
};
},
};

View File

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

View File

@ -0,0 +1,72 @@
import { IInvoicingContext } from "#/server/intrastructure";
import { ExpressController } from "@rdx/core";
import { IUpdateCustomerPresenter } from "./presenter";
export class UpdateCustomerController extends ExpressController {
private useCase: UpdateCustomerUseCase2;
private presenter: IUpdateCustomerPresenter;
private context: IInvoicingContext;
constructor(
props: {
useCase: UpdateCustomerUseCase;
presenter: IUpdateCustomerPresenter;
},
context: IInvoicingContext
) {
super();
const { useCase, presenter } = props;
this.useCase = useCase;
this.presenter = presenter;
this.context = context;
}
async executeImpl(): Promise<any> {
const { customerId } = this.req.params;
const request: IUpdateCustomer_DTO = this.req.body;
if (RuleValidator.validate(RuleValidator.RULE_NOT_NULL_OR_UNDEFINED, customerId).isFailure) {
return this.invalidInputError("Customer Id param is required!");
}
const idOrError = UniqueID.create(customerId);
if (idOrError.isFailure) {
return this.invalidInputError("Invalid customer Id param!");
}
try {
const result = await this.useCase.execute({
id: idOrError.object,
data: request,
});
if (result.isFailure) {
const { error } = result;
switch (error.code) {
case UseCaseError.NOT_FOUND_ERROR:
return this.notFoundError("Customer not found", error);
case UseCaseError.INVALID_INPUT_DATA:
return this.invalidInputError(error.message);
case UseCaseError.UNEXCEPTED_ERROR:
return this.internalServerError(result.error.message, result.error);
case UseCaseError.REPOSITORY_ERROR:
return this.conflictError(result.error, result.error.details);
default:
return this.clientError(result.error.message);
}
}
const customer = <Customer>result.object;
return this.ok<IUpdateCustomer_Response_DTO>(this.presenter.map(customer, this.context));
} catch (e: unknown) {
return this.fail(e as IServerError);
}
}
}

View File

@ -0,0 +1,130 @@
import {
AggregateRoot,
EmailAddress,
PhoneNumber,
PostalAddress,
TINNumber,
UniqueID,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
export interface CustomerProps {
reference: string;
isFreelancer: boolean;
name: string;
tin: TINNumber;
address: PostalAddress;
email: EmailAddress;
phone: PhoneNumber;
legalRecord: string;
defaultTax: number;
status: string;
langCode: string;
currencyCode: string;
tradeName: Maybe<string>;
website: Maybe<string>;
fax: Maybe<PhoneNumber>;
}
export interface ICustomer {
id: UniqueID;
reference: string;
name: string;
tin: TINNumber;
address: PostalAddress;
email: EmailAddress;
phone: PhoneNumber;
legalRecord: string;
defaultTax: number;
langCode: string;
currencyCode: string;
tradeName: Maybe<string>;
fax: Maybe<PhoneNumber>;
website: Maybe<string>;
isCustomer: boolean;
isFreelancer: boolean;
isActive: boolean;
}
export class Customer extends AggregateRoot<CustomerProps> implements ICustomer {
static create(props: CustomerProps, id?: UniqueID): Result<Customer, Error> {
const contact = new Customer(props, id);
// Reglas de negocio / validaciones
// ...
// ...
// 🔹 Disparar evento de dominio "CustomerAuthenticatedEvent"
//const { contact } = props;
//user.addDomainEvent(new CustomerAuthenticatedEvent(id, contact.toString()));
return Result.ok(contact);
}
get reference() {
return this.props.reference;
}
get name() {
return this.props.name;
}
get tradeName() {
return this.props.tradeName;
}
get tin(): TINNumber {
return this.props.tin;
}
get address(): PostalAddress {
return this.props.address;
}
get email(): EmailAddress {
return this.props.email;
}
get phone(): PhoneNumber {
return this.props.phone;
}
get fax(): Maybe<PhoneNumber> {
return this.props.fax;
}
get website() {
return this.props.website;
}
get legalRecord() {
return this.props.legalRecord;
}
get defaultTax() {
return this.props.defaultTax;
}
get langCode() {
return this.props.langCode;
}
get currencyCode() {
return this.props.currencyCode;
}
get isCustomer(): boolean {
return !this.props.isFreelancer;
}
get isFreelancer(): boolean {
return this.props.isFreelancer;
}
get isActive(): boolean {
return this.props.status === "active";
}
}

View File

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

View File

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

View File

@ -0,0 +1,50 @@
import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { Customer } from "../aggregates";
export interface ICustomerRepository {
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
/**
*
* Persiste una nueva factura o actualiza una existente.
*
* @param customer - El agregado a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
*/
save(customer: Customer, transaction: any): Promise<Result<Customer, Error>>;
/**
*
* Busca una factura por su identificador único.
* @param id - UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
*/
findById(id: UniqueID, transaction: any): Promise<Result<Customer, Error>>;
/**
*
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer[], Error>
*
* @see Criteria
*/
findByCriteria(
criteria: Criteria,
transaction: any
): Promise<Result<Collection<Customer>, Error>>;
/**
*
* Elimina o marca como eliminada una factura.
* @param id - UUID de la factura a eliminar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>>;
}

View File

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

View File

@ -0,0 +1,33 @@
import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { Customer, CustomerProps } from "../aggregates";
export interface ICustomerService {
build(props: CustomerProps, id?: UniqueID): Result<Customer, Error>;
save(invoice: Customer, transaction: any): Promise<Result<Customer, Error>>;
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
findByCriteria(
criteria: Criteria,
transaction?: any
): Promise<Result<Collection<Customer>, Error>>;
getById(id: UniqueID, transaction?: any): Promise<Result<Customer>>;
updateById(
id: UniqueID,
data: Partial<CustomerProps>,
transaction?: any
): Promise<Result<Customer, Error>>;
createCustomer(
id: UniqueID,
data: CustomerProps,
transaction?: any
): Promise<Result<Customer, Error>>;
deleteById(id: UniqueID, transaction?: any): Promise<Result<void, Error>>;
}

View File

@ -0,0 +1,149 @@
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 { Customer, CustomerProps } from "../aggregates";
import { ICustomerRepository } from "../repositories";
import { ICustomerService } from "./customer-service.interface";
export class CustomerService implements ICustomerService {
constructor(private readonly repository: ICustomerRepository) {}
/**
* Construye un nuevo agregado Customer a partir de props validadas.
*
* @param props - Las propiedades ya validadas para crear la factura.
* @param id - Identificador UUID de la factura (opcional).
* @returns Result<Customer, Error> - El agregado construido o un error si falla la creación.
*/
build(props: CustomerProps, id?: UniqueID): Result<Customer, Error> {
return Customer.create(props, id);
}
/**
* Guarda una instancia de Customer en persistencia.
*
* @param invoice - El agregado a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - El agregado guardado o un error si falla la operación.
*/
async save(invoice: Customer, transaction: any): Promise<Result<Customer, Error>> {
const saved = await this.repository.save(invoice, transaction);
return saved.isSuccess ? Result.ok(invoice) : Result.fail(saved.error);
}
/**
*
* Comprueba si existe o no en persistencia una factura con el ID proporcionado
*
* @param id - Identificador UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<Boolean, Error> - Existe la factura o no.
*/
async existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>> {
return this.repository.existsById(id, transaction);
}
/**
* Obtiene una colección de facturas que cumplen con los filtros definidos en un objeto Criteria.
*
* @param criteria - Objeto con condiciones de filtro, paginación y orden.
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<Customer>, Error> - Colección de facturas o error.
*/
async findByCriteria(
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<Customer>, Error>> {
const customersOrError = await this.repository.findByCriteria(criteria, transaction);
if (customersOrError.isFailure) {
console.error(customersOrError.error);
return Result.fail(customersOrError.error);
}
// Solo devolver usuarios activos
//const allCustomers = customersOrError.data.filter((customer) => customer.isActive);
//return Result.ok(new Collection(allCustomers));
return customersOrError;
}
/**
* Recupera una factura por su identificador único.
*
* @param id - Identificador UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - Factura encontrada o error.
*/
async getById(id: UniqueID, transaction?: Transaction): Promise<Result<Customer>> {
return await this.repository.findById(id, transaction);
}
/**
* Actualiza parcialmente una factura existente con nuevos datos.
*
* @param id - Identificador de la factura a actualizar.
* @param changes - Subconjunto de props válidas para aplicar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - Factura actualizada o error.
*/
async updateById(
customerId: UniqueID,
changes: Partial<CustomerProps>,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
// Verificar si la factura existe
const customerOrError = await this.repository.findById(customerId, transaction);
if (customerOrError.isFailure) {
return Result.fail(new Error("Customer not found"));
}
return Result.fail(new Error("No implementado"));
/*const updatedCustomerOrError = Customer.update(customerOrError.data, data);
if (updatedCustomerOrError.isFailure) {
return Result.fail(
new Error(`Error updating customer: ${updatedCustomerOrError.error.message}`)
);
}
const updateCustomer = updatedCustomerOrError.data;
await this.repo.update(updateCustomer, transaction);
return Result.ok(updateCustomer);*/
}
async createCustomer(
customerId: UniqueID,
data: CustomerProps,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
// Verificar si la factura existe
const customerOrError = await this.repository.findById(customerId, transaction);
if (customerOrError.isSuccess) {
return Result.fail(new Error("Customer exists"));
}
const newCustomerOrError = Customer.create(data, customerId);
if (newCustomerOrError.isFailure) {
return Result.fail(new Error(`Error creating customer: ${newCustomerOrError.error.message}`));
}
const newCustomer = newCustomerOrError.data;
await this.repository.create(newCustomer, transaction);
return Result.ok(newCustomer);
}
/**
* Elimina (o marca como eliminada) una factura según su ID.
*
* @param id - Identificador UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error> - Resultado de la operación.
*/
async deleteById(id: UniqueID, transaction?: Transaction): Promise<Result<void, Error>> {
return this.repository.deleteById(id, transaction);
}
}

View File

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

View File

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

View File

@ -0,0 +1,48 @@
import { DomainValidationError } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
interface ICustomerNumberProps {
value: string;
}
export class CustomerNumber extends ValueObject<ICustomerNumberProps> {
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(CustomerNumber.MAX_LENGTH, {
message: `String must be at most ${CustomerNumber.MAX_LENGTH} characters long`,
});
return schema.safeParse(value);
}
static create(value: string) {
const result = CustomerNumber.validate(value);
if (!result.success) {
const detail = result.error.message;
return Result.fail(
new DomainValidationError(CustomerNumber.ERROR_CODE, CustomerNumber.FIELD, detail)
);
}
return Result.ok(new CustomerNumber({ value }));
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.getValue();
}
toPrimitive() {
return this.getValue();
}
}

View File

@ -0,0 +1,56 @@
import { DomainValidationError } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
interface ICustomerSerieProps {
value: string;
}
export class CustomerSerie extends ValueObject<ICustomerSerieProps> {
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(CustomerSerie.MAX_LENGTH, {
message: `String must be at most ${CustomerSerie.MAX_LENGTH} characters long`,
});
return schema.safeParse(value);
}
static create(value: string) {
const result = CustomerSerie.validate(value);
if (!result.success) {
const detail = result.error.message;
return Result.fail(
new DomainValidationError(CustomerSerie.ERROR_CODE, CustomerSerie.FIELD, detail)
);
}
return Result.ok(new CustomerSerie({ value }));
}
static createNullable(value?: string): Result<Maybe<CustomerSerie>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.none<CustomerSerie>());
}
return CustomerSerie.create(value).map((value) => Maybe.some(value));
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.getValue();
}
toPrimitive() {
return this.getValue();
}
}

View File

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

View File

@ -0,0 +1,4 @@
export * from "./customer-address-type";
export * from "./customer-number";
export * from "./customer-serie";
export * from "./customer-status";

View File

@ -1,5 +1,6 @@
import { IModuleServer, ModuleParams } from "@erp/core/api";
//import { customerInvoicesRouter, models } from "./infrastructure";
import { customersRouter, models } from "./infrastructure";
//import { customersRouter, models } from "./infrastructure";
export const customersAPIModule: IModuleServer = {
name: "customers",
@ -9,7 +10,7 @@ export const customersAPIModule: IModuleServer = {
init(params: ModuleParams) {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
//customerInvoicesRouter(params);
customersRouter(params);
logger.info("🚀 Customers module initialized", { label: "customers" });
},
registerDependencies(params) {
@ -18,7 +19,7 @@ export const customersAPIModule: IModuleServer = {
label: "customers",
});
return {
//models,
models,
services: {
/*...*/
},

View File

@ -0,0 +1,78 @@
import { ILogger, ModuleParams, validateRequest } from "@erp/core/api";
import { Application, NextFunction, Request, Response, Router } from "express";
import { Sequelize } from "sequelize";
import {
CreateCustomerCommandSchema,
CustomerListCriteriaSchema,
DeleteCustomerByIdQuerySchema,
GetCustomerByIdQuerySchema,
} from "../../../common/dto";
import {
buildCreateCustomersController,
buildDeleteCustomerController,
buildGetCustomerController,
buildListCustomersController,
} from "../../controllers";
export const customersRouter = (params: ModuleParams) => {
const { app, database, baseRoutePath, logger } = params as {
app: Application;
database: Sequelize;
baseRoutePath: string;
logger: ILogger;
};
const routes: Router = Router({ mergeParams: true });
routes.get(
"/",
//checkTabContext,
//checkUser,
validateRequest(CustomerListCriteriaSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
buildListCustomersController(database).execute(req, res, next);
}
);
routes.get(
"/:id",
//checkTabContext,
//checkUser,
validateRequest(GetCustomerByIdQuerySchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
buildGetCustomerController(database).execute(req, res, next);
}
);
routes.post(
"/",
//checkTabContext,
//checkUser,
validateRequest(CreateCustomerCommandSchema),
(req: Request, res: Response, next: NextFunction) => {
buildCreateCustomersController(database).execute(req, res, next);
}
);
/*routes.put(
"/:customerId",
validateAndParseBody(IUpdateCustomerRequestSchema),
checkTabContext,
//checkUser,
(req: Request, res: Response, next: NextFunction) => {
buildUpdateCustomerController().execute(req, res, next);
}
);*/
routes.delete(
"/:id",
//checkTabContext,
//checkUser,
validateRequest(DeleteCustomerByIdQuerySchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
buildDeleteCustomerController(database).execute(req, res, next);
}
);
app.use(`${baseRoutePath}/customers`, routes);
};

View File

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

View File

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

View File

@ -0,0 +1,128 @@
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { InferCreationAttributes } from "sequelize";
import {
Customer,
CustomerItem,
CustomerItemDescription,
CustomerItemDiscount,
CustomerItemQuantity,
CustomerItemUnitPrice,
} from "../../domain";
import { CustomerItemCreationAttributes, CustomerItemModel, CustomerModel } from "../sequelize";
export interface ICustomerItemMapper
extends ISequelizeMapper<CustomerItemModel, CustomerItemCreationAttributes, CustomerItem> {}
export class CustomerItemMapper
extends SequelizeMapper<CustomerItemModel, CustomerItemCreationAttributes, CustomerItem>
implements ICustomerItemMapper
{
public mapToDomain(
source: CustomerItemModel,
params?: MapperParamsType
): Result<CustomerItem, Error> {
const { sourceParent } = params as { sourceParent: CustomerModel };
// Validación y creación de ID único
const idOrError = UniqueID.create(source.item_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
// Validación y creación de descripción
const descriptionOrError = CustomerItemDescription.create(source.description || "");
if (descriptionOrError.isFailure) {
return Result.fail(descriptionOrError.error);
}
// Validación y creación de cantidad
const quantityOrError = CustomerItemQuantity.create({
amount: source.quantity_amount,
scale: source.quantity_scale,
});
if (quantityOrError.isFailure) {
return Result.fail(quantityOrError.error);
}
// Validación y creación de precio unitario
const unitPriceOrError = CustomerItemUnitPrice.create({
amount: source.unit_price_amount,
scale: source.unit_price_scale,
currency_code: sourceParent.invoice_currency,
});
if (unitPriceOrError.isFailure) {
return Result.fail(unitPriceOrError.error);
}
// Validación y creación de descuento
const discountOrError = CustomerItemDiscount.create({
amount: source.discount_amount || 0,
scale: source.discount_scale || 0,
});
if (discountOrError.isFailure) {
return Result.fail(discountOrError.error);
}
// Combinación de resultados
const result = Result.combine([
idOrError,
descriptionOrError,
quantityOrError,
unitPriceOrError,
discountOrError,
]);
if (result.isFailure) {
return Result.fail(result.error);
}
// Creación del objeto de dominio
return CustomerItem.create(
{
description: descriptionOrError.data,
quantity: quantityOrError.data,
unitPrice: unitPriceOrError.data,
discount: discountOrError.data,
},
idOrError.data
);
}
public mapToPersistence(
source: CustomerItem,
params?: MapperParamsType
): InferCreationAttributes<CustomerItemModel, {}> {
const { index, sourceParent } = params as {
index: number;
sourceParent: Customer;
};
const lineData = {
parent_id: undefined,
invoice_id: sourceParent.id.toPrimitive(),
item_type: "simple",
position: index,
item_id: source.id.toPrimitive(),
description: source.description.toPrimitive(),
quantity_amount: source.quantity.toPrimitive().amount,
quantity_scale: source.quantity.toPrimitive().scale,
unit_price_amount: source.unitPrice.toPrimitive().amount,
unit_price_scale: source.unitPrice.toPrimitive().scale,
subtotal_amount: source.subtotalPrice.toPrimitive().amount,
subtotal_scale: source.subtotalPrice.toPrimitive().scale,
discount_amount: source.discount.toPrimitive().amount,
discount_scale: source.discount.toPrimitive().scale,
total_amount: source.totalPrice.toPrimitive().amount,
total_scale: source.totalPrice.toPrimitive().scale,
};
return lineData;
}
}

View File

@ -0,0 +1,97 @@
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Customer, CustomerNumber, CustomerSerie, CustomerStatus } from "../../domain";
import { CustomerCreationAttributes, CustomerModel } from "../sequelize";
import { CustomerItemMapper } from "./customer-item.mapper";
export interface ICustomerMapper
extends ISequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer> {}
export class CustomerMapper
extends SequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer>
implements ICustomerMapper
{
private customerItemMapper: CustomerItemMapper;
constructor() {
super();
this.customerItemMapper = new CustomerItemMapper(); // Instanciar el mapper de items
}
public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result<Customer, Error> {
const idOrError = UniqueID.create(source.id);
const statusOrError = CustomerStatus.create(source.invoice_status);
const customerSeriesOrError = CustomerSerie.create(source.invoice_series);
const customerNumberOrError = CustomerNumber.create(source.invoice_number);
const issueDateOrError = UtcDate.createFromISO(source.issue_date);
const operationDateOrError = UtcDate.createFromISO(source.operation_date);
const result = Result.combine([
idOrError,
statusOrError,
customerSeriesOrError,
customerNumberOrError,
issueDateOrError,
operationDateOrError,
]);
if (result.isFailure) {
return Result.fail(result.error);
}
// Mapear los items de la factura
const itemsOrErrors = this.customerItemMapper.mapArrayToDomain(source.items, {
sourceParent: source,
...params,
});
if (itemsOrErrors.isFailure) {
return Result.fail(itemsOrErrors.error);
}
const customerCurrency = source.invoice_currency || "EUR";
return Customer.create(
{
status: statusOrError.data,
invoiceSeries: customerSeriesOrError.data,
invoiceNumber: customerNumberOrError.data,
issueDate: issueDateOrError.data,
operationDate: operationDateOrError.data,
currency: customerCurrency,
items: itemsOrErrors.data,
},
idOrError.data
);
}
public mapToPersistence(source: Customer, params?: MapperParamsType): CustomerCreationAttributes {
const subtotal = source.calculateSubtotal();
const total = source.calculateTotal();
const items = this.customerItemMapper.mapCollectionToPersistence(source.items, params);
return {
id: source.id.toString(),
invoice_status: source.status.toPrimitive(),
invoice_series: source.invoiceSeries.toPrimitive(),
invoice_number: source.invoiceNumber.toPrimitive(),
issue_date: source.issueDate.toPrimitive(),
operation_date: source.operationDate.toPrimitive(),
invoice_language: "es",
invoice_currency: source.currency || "EUR",
subtotal_amount: subtotal.amount,
subtotal_scale: subtotal.scale,
total_amount: total.amount,
total_scale: total.scale,
items,
};
}
}
const customerMapper: CustomerMapper = new CustomerMapper();
export { customerMapper };

View File

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

View File

@ -0,0 +1,175 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Sequelize,
} from "sequelize";
export type CustomerCreationAttributes = InferCreationAttributes<CustomerModel, {}> & {};
export class CustomerModel extends Model<
InferAttributes<CustomerModel>,
InferCreationAttributes<CustomerModel>
> {
// To avoid table creation
/*static async sync(): Promise<any> {
return Promise.resolve();
}*/
declare id: string;
declare reference: CreationOptional<string>;
declare is_freelancer: boolean;
declare name: string;
declare trade_name: CreationOptional<string>;
declare tin: string;
declare street: string;
declare city: string;
declare state: string;
declare postal_code: string;
declare country: string;
declare email: string;
declare phone: string;
declare fax: CreationOptional<string>;
declare website: CreationOptional<string>;
declare legal_record: string;
declare default_tax: number;
declare status: string;
declare lang_code: string;
declare currency_code: string;
}
export default (sequelize: Sequelize) => {
CustomerModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
reference: {
type: DataTypes.STRING,
allowNull: false,
},
is_freelancer: {
type: DataTypes.BOOLEAN,
allowNull: false,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
trade_name: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
tin: {
type: DataTypes.STRING,
allowNull: false,
},
street: {
type: DataTypes.STRING,
allowNull: false,
},
city: {
type: DataTypes.STRING,
allowNull: false,
},
state: {
type: DataTypes.STRING,
allowNull: false,
},
postal_code: {
type: DataTypes.STRING,
allowNull: false,
},
country: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
allowNull: false,
validate: {
isEmail: true,
},
},
phone: {
type: DataTypes.STRING,
allowNull: false,
},
fax: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
website: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
validate: {
isUrl: true,
},
},
legal_record: {
type: DataTypes.TEXT,
allowNull: false,
},
default_tax: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2100,
},
lang_code: {
type: DataTypes.STRING(2),
allowNull: false,
defaultValue: "es",
},
currency_code: {
type: new DataTypes.STRING(3),
allowNull: false,
defaultValue: "EUR",
},
status: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "active",
},
},
{
sequelize,
tableName: "customers",
paranoid: true, // softs deletes
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [
{ name: "email_idx", fields: ["email"], unique: true },
{ name: "reference_idx", fields: ["reference"], unique: true },
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return CustomerModel;
};

View File

@ -0,0 +1,121 @@
import { SequelizeRepository, errorMapper } from "@erp/core/api";
import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { Sequelize, Transaction } from "sequelize";
import { Customer, ICustomerRepository } from "../../domain";
import { ICustomerMapper } from "../mappers/customer.mapper";
import { CustomerModel } from "./customer.model";
export class CustomerRepository
extends SequelizeRepository<Customer>
implements ICustomerRepository
{
//private readonly model: typeof CustomerModel;
private readonly mapper!: ICustomerMapper;
constructor(database: Sequelize, mapper: ICustomerMapper) {
super(database);
//Customer = database.model("Customer") as typeof CustomerModel;
this.mapper = mapper;
}
async existsById(id: UniqueID, transaction?: Transaction): Promise<Result<boolean, Error>> {
try {
const result = await this._exists(CustomerModel, "id", id.toString(), transaction);
return Result.ok(Boolean(result));
} catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err));
}
}
/**
*
* Persiste una nueva factura o actualiza una existente.
*
* @param invoice - El agregado a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
*/
async save(invoice: Customer, transaction: Transaction): Promise<Result<Customer, Error>> {
try {
const data = this.mapper.mapToPersistence(invoice);
await CustomerModel.upsert(data, { transaction });
return Result.ok(invoice);
} catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err));
}
}
/**
*
* Busca una factura por su identificador único.
* @param id - UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
*/
async findById(id: UniqueID, transaction: Transaction): Promise<Result<Customer, Error>> {
try {
const rawData = await this._findById(CustomerModel, id.toString(), { transaction });
if (!rawData) {
return Result.fail(new Error(`Invoice with id ${id} not found.`));
}
return this.mapper.mapToDomain(rawData);
} catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err));
}
}
/**
*
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer[], Error>
*
* @see Criteria
*/
public async findByCriteria(
criteria: Criteria,
transaction: Transaction
): Promise<Result<Collection<Customer>, Error>> {
try {
const converter = new CriteriaToSequelizeConverter();
const query = converter.convert(criteria);
console.debug({ criteria, transaction, query, CustomerModel });
const instances = await CustomerModel.findAll({
...query,
transaction,
});
console.debug(instances);
return this.mapper.mapArrayToDomain(instances);
} catch (err: unknown) {
console.error(err);
return Result.fail(errorMapper.toDomainError(err));
}
}
/**
*
* Elimina o marca como eliminada una factura.
* @param id - UUID de la factura a eliminar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>> {
try {
await this._deleteById(CustomerModel, id, false, transaction);
return Result.ok<void>();
} catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err));
}
}
}

View File

@ -0,0 +1,7 @@
import customerModelInit from "./customer.model";
export * from "./customer.model";
export * from "./customer.repository";
// Array de inicializadores para que registerModels() lo use
export const models = [customerModelInit];

View File

@ -0,0 +1,36 @@
import * as z from "zod/v4";
export const CreateCustomerCommandSchema = z.object({
id: z.uuid(),
invoice_status: z.string(),
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" }),
description: z.string(),
language_code: z.string().min(2, "Language code must be at least 2 characters long"),
currency_code: z.string().min(3, "Currency code must be at least 3 characters long"),
notes: z.string().optional(),
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"),
}),
unit_price: 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_code: 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 CreateCustomerCommandDTO = z.infer<typeof CreateCustomerCommandSchema>;

View File

@ -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 /customers` (listado / búsqueda de facturas).
*
*/
export const CustomerListCriteriaSchema = 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 ListCustomersQueryDTO = z.infer<typeof CustomerListCriteriaSchema>;

View File

@ -0,0 +1,13 @@
import * as z from "zod/v4";
/**
* Este DTO es utilizado por el endpoint:
* `DELETE /customers/:id` (eliminar una factura por ID).
*
*/
export const DeleteCustomerByIdParamsSchema = z.object({
id: z.string(),
});
export type DeleteCustomerByIdParamsDTO = z.infer<typeof DeleteCustomerByIdParamsSchema>;

View File

@ -0,0 +1,13 @@
import * as z from "zod/v4";
/**
* Este DTO es utilizado por el endpoint:
* `GET /customers/:id` (consultar una factura por ID).
*
*/
export const GetCustomerByIdParamsSchema = z.object({
id: z.string(),
});
export type GetCustomerByIdParamsDTO = z.infer<typeof GetCustomerByIdParamsSchema>;

View File

@ -1,39 +0,0 @@
import * as z from "zod/v4";
/**
* DTO que transporta los parámetros de la consulta (paginación, filtros, etc.)
* para la búsqueda de clientes.
*
* Este DTO es utilizado por el endpoint:
* `GET /customers` (listado / búsqueda de clientes).
*
*/
const operatorEnum = z.enum([
"CONTAINS",
"NOT_CONTAINS",
"NOT_EQUALS",
"GREATER_THAN",
"GREATER_THAN_OR_EQUAL",
"LOWER_THAN",
"LOWER_THAN_OR_EQUAL",
"EQUALS",
]);
const filterSchema = z.object({
field: z.string(),
operator: operatorEnum,
value: z.string(),
});
export const ListCustomersQuerySchema = z.object({
filters: z.array(filterSchema).optional(),
pageSize: z.coerce.number().int().positive().optional(),
pageNumber: z.coerce.number().int().nonnegative().optional(),
orderBy: z.string().optional(),
order: z.enum(["asc", "desc"]).default("asc").optional(),
});
export type ListCustomersQueryDTO = z.infer<typeof ListCustomersQuerySchema>;

View File

@ -0,0 +1,17 @@
import { MetadataSchema } from "@erp/core";
import * as z from "zod/v4";
export const CustomerCreatedResponseSchema = 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(),
metadata: MetadataSchema.optional(),
});
export type CustomerCreatedResponseDTO = z.infer<typeof CustomerCreatedResponseSchema>;

View File

@ -1,14 +1,14 @@
import { MetadataSchema, createListViewSchema } from "@erp/core";
import { MetadataSchema, createListViewResultSchema } from "@erp/core";
import * as z from "zod/v4";
export const ListCustomersResultSchema = createListViewSchema(
export const CustomerListResponseSchema = createListViewResultSchema(
z.object({
id: z.uuid(),
reference: z.string().optional(),
id: z.string(),
reference: z.string(),
is_freelancer: z.boolean(),
name: z.string(),
trade_name: z.string().optional(),
trade_name: z.string(),
tin: z.string(),
street: z.string(),
@ -17,8 +17,12 @@ export const ListCustomersResultSchema = createListViewSchema(
postal_code: z.string(),
country: z.string(),
email: z.email(),
email: z.string(),
phone: z.string(),
fax: z.string(),
website: z.string(),
legal_record: z.string(),
default_tax: z.number(),
status: z.string(),
@ -29,4 +33,4 @@ export const ListCustomersResultSchema = createListViewSchema(
})
);
export type ListCustomersResultDTO = z.infer<typeof ListCustomersResultSchema>;
export type CustomerListResponsetDTO = z.infer<typeof CustomerListResponseSchema>;

View File

@ -0,0 +1,17 @@
import { MetadataSchema } from "@erp/core";
import * as z from "zod/v4";
export const GetCustomerByIdResultSchema = 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(),
metadata: MetadataSchema.optional(),
});
export type GetCustomerByIdResultDTO = z.infer<typeof GetCustomerByIdResultSchema>;

View File

@ -1 +1 @@
export * from "./list-customers.result.dto";
export * from "./customer-list.response.dto";

View File

@ -3,7 +3,7 @@ import DataTable, { TableColumn } from "react-data-table-component";
import { useDebounce } from "use-debounce";
import { buildTextFilters } from "@erp/core/client";
import { ListCustomersResultDTO } from "@erp/customer-invoices/common/dto";
import { ListCustomersResultDTO } from "@erp/customers/common/dto";
import {
Badge,
Button,
@ -137,11 +137,16 @@ export const ClientSelector = () => {
pageNumber,
});
const handleSelectClient = (event): void => {
event.preventDefault();
setOpen(true);
};
return (
<div className='space-y-4 max-w-2xl'>
<div className='space-y-1'>
<Label>Cliente</Label>
<Button variant='outline' className='w-full justify-start' onClick={() => setOpen(true)}>
<Button variant='outline' className='w-full justify-start' onClick={handleSelectClient}>
<User className='h-4 w-4 mr-2' />
{selectedCustomer ? selectedCustomer.name : "Seleccionar cliente"}
</Button>
@ -202,9 +207,9 @@ export const ClientSelector = () => {
}}
pagination
paginationServer
paginationPerPage={perPage}
paginationTotalRows={data?.total ?? 0}
onChangePage={(p) => setPage(p)}
paginationPerPage={pageSize}
paginationTotalRows={data?.total_items ?? 0}
onChangePage={(p) => setPageNumber(p)}
highlightOnHover
pointerOnHover
noDataComponent='No se encontraron resultados'

View File

@ -0,0 +1,88 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../i18n";
import { formatCurrency } from "../pages/create/utils";
export const CustomerPricesCard = () => {
const { t } = useTranslation();
const { register, formState, control, watch } = useFormContext();
/*const pricesWatch = useWatch({ control, name: ["subtotal_price", "discount", "tax"] });
const totals = calculateQuoteTotals(pricesWatch);
const subtotal_price = formatNumber(totals.subtotalPrice);
const discount_price = formatNumber(totals.discountPrice);
const tax_price = formatNumber(totals.taxesPrice);
const total_price = formatNumber(totals.totalPrice);*/
const currency_symbol = watch("currency");
return (
<Card>
<CardHeader>
<CardTitle>Impuestos y Totales</CardTitle>
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
</CardHeader>
<CardContent className='flex flex-row items-end gap-2 p-4'>
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-1 font-semibold text-right text-muted-foreground'>
<CardDescription className='text-sm'>
{t("form_fields.subtotal_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
{formatCurrency(watch("subtotal_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
<div className='grid gap-1 font-medium text-muted-foreground'>
<CardDescription className='text-sm'>{t("form_fields.discount.label")}</CardDescription>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm text-right'>
{t("form_fields.discount_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
{"-"} {formatCurrency(watch("discount_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
<div className='grid gap-1 font-medium text-muted-foreground'>
<CardDescription className='text-sm'>{t("form_fields.tax.label")}</CardDescription>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm text-right'>
{t("form_fields.tax_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end gap-1 text-2xl tabular-nums'>
{formatCurrency(watch("tax_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>{" "}
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-0'>
<CardDescription className='text-sm font-semibold text-right text-foreground'>
{t("form_fields.total_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end gap-1 text-3xl tabular-nums'>
{formatCurrency(watch("total_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,65 @@
import { Badge } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { forwardRef } from "react";
import { useTranslation } from "../i18n";
export type CustomerStatus = "draft" | "emitted" | "sent" | "received" | "rejected";
export type CustomerStatusBadgeProps = {
status: string; // permitir cualquier valor
className?: string;
};
const statusColorConfig: Record<CustomerStatus, { badge: string; dot: string }> = {
draft: {
badge:
"bg-gray-600/10 dark:bg-gray-600/20 hover:bg-gray-600/10 text-gray-500 border-gray-600/60",
dot: "bg-gray-500",
},
emitted: {
badge:
"bg-amber-600/10 dark:bg-amber-600/20 hover:bg-amber-600/10 text-amber-500 border-amber-600/60",
dot: "bg-amber-500",
},
sent: {
badge:
"bg-cyan-600/10 dark:bg-cyan-600/20 hover:bg-cyan-600/10 text-cyan-500 border-cyan-600/60 shadow-none rounded-full",
dot: "bg-cyan-500",
},
received: {
badge:
"bg-emerald-600/10 dark:bg-emerald-600/20 hover:bg-emerald-600/10 text-emerald-500 border-emerald-600/60",
dot: "bg-emerald-500",
},
rejected: {
badge: "bg-red-600/10 dark:bg-red-600/20 hover:bg-red-600/10 text-red-500 border-red-600/60",
dot: "bg-red-500",
},
};
export const CustomerStatusBadge = forwardRef<HTMLDivElement, CustomerStatusBadgeProps>(
({ status, className, ...props }, ref) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as CustomerStatus;
const config = statusColorConfig[normalizedStatus];
const commonClassName =
"transition-colors duration-200 cursor-pointer shadow-none rounded-full";
if (!config) {
return (
<Badge ref={ref} className={cn(commonClassName, className)} {...props}>
{status}
</Badge>
);
}
return (
<Badge className={cn(commonClassName, config.badge, className)} {...props}>
<div className={cn("h-1.5 w-1.5 rounded-full mr-2", config.dot)} />
{t(`status.${status}`)}
</Badge>
);
}
);
CustomerStatusBadge.displayName = "CustomerStatusBadge";

View File

@ -0,0 +1,73 @@
import { MultiSelect } from "@repo/rdx-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../i18n";
const taxesList = [
{ label: "IVA 21%", value: "iva_21", group: "IVA" },
{ label: "IVA 10%", value: "iva_10", group: "IVA" },
{ label: "IVA 7,5%", value: "iva_7_5", group: "IVA" },
{ label: "IVA 5%", value: "iva_5", group: "IVA" },
{ label: "IVA 4%", value: "iva_4", group: "IVA" },
{ label: "IVA 2%", value: "iva_2", group: "IVA" },
{ label: "IVA 0%", value: "iva_0", group: "IVA" },
{ label: "Exenta", value: "iva_exenta", group: "IVA" },
{ label: "No sujeto", value: "iva_no_sujeto", group: "IVA" },
{ label: "Iva Intracomunitario Bienes", value: "iva_intracomunitario_bienes", group: "IVA" },
{ label: "Iva Intracomunitario Servicio", value: "iva_intracomunitario_servicio", group: "IVA" },
{ label: "Exportación", value: "iva_exportacion", group: "IVA" },
{ label: "Inv. Suj. Pasivo", value: "iva_inversion_sujeto_pasivo", group: "IVA" },
{ label: "Retención 35%", value: "retencion_35", group: "Retención" },
{ label: "Retención 19%", value: "retencion_19", group: "Retención" },
{ label: "Retención 15%", value: "retencion_15", group: "Retención" },
{ label: "Retención 7%", value: "retencion_7", group: "Retención" },
{ label: "Retención 2%", value: "retencion_2", group: "Retención" },
{ label: "REC 5,2%", value: "rec_5_2", group: "Recargo de equivalencia" },
{ label: "REC 1,75%", value: "rec_1_75", group: "Recargo de equivalencia" },
{ label: "REC 1,4%", value: "rec_1_4", group: "Recargo de equivalencia" },
{ label: "REC 1%", value: "rec_1", group: "Recargo de equivalencia" },
{ label: "REC 0,62%", value: "rec_0_62", group: "Recargo de equivalencia" },
{ label: "REC 0,5%", value: "rec_0_5", group: "Recargo de equivalencia" },
{ label: "REC 0,26%", value: "rec_0_26", group: "Recargo de equivalencia" },
{ label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" },
];
interface CustomerTaxesMultiSelect {
value: string[];
onChange: (selectedValues: string[]) => void;
[key: string]: any; // Allow other props to be passed
}
export const CustomerTaxesMultiSelect = (props: CustomerTaxesMultiSelect) => {
const { value, onChange, ...otherProps } = props;
const { t } = useTranslation();
const handleOnChange = (selectedValues: string[]) => {
onChange(selectedValues);
};
const handleValidateOption = (candidateValue: string) => {
const exists = (value || []).some((item) => item.startsWith(candidateValue.substring(0, 3)));
if (exists) {
alert(t("components.customer_invoice_taxes_multi_select.invalid_tax_selection"));
}
return exists === false;
};
return (
<div className={cn("w-full", "max-w-md")}>
<MultiSelect
options={taxesList}
onValueChange={handleOnChange}
onValidateOption={handleValidateOption}
defaultValue={value}
placeholder={t("components.customer_invoice_taxes_multi_select.placeholder")}
variant='inverted'
animation={0}
maxCount={3}
{...otherProps}
/>
</div>
);
};

View File

@ -0,0 +1,6 @@
import { PropsWithChildren } from "react";
import { CustomersProvider } from "../context";
export const CustomersLayout = ({ children }: PropsWithChildren) => {
return <CustomersProvider>{children}</CustomersProvider>;
};

View File

@ -0,0 +1,83 @@
import { useState } from "react";
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
// Grid
import type { ColDef, GridOptions, ValueFormatterParams } from "ag-grid-community";
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
ModuleRegistry.registerModules([AllCommunityModule]);
import { MoneyDTO } from "@erp/core";
import { formatDate, formatMoney } from "@erp/core/client";
// Core CSS
import { AgGridReact } from "ag-grid-react";
import { useCustomersQuery } from "../hooks";
import { useTranslation } from "../i18n";
import { CustomerStatusBadge } from "./customer-status-badge";
// Create new GridExample component
export const CustomersListGrid = () => {
const { t } = useTranslation();
const { data, isLoading, isPending, isError, error } = useCustomersQuery({});
// Column Definitions: Defines & controls grid columns.
const [colDefs] = useState<ColDef[]>([
{
field: "invoice_status",
filter: true,
headerName: t("pages.list.grid_columns.invoice_status"),
cellRenderer: (params: ValueFormatterParams) => {
return <CustomerStatusBadge status={params.value} />;
},
},
{ field: "invoice_number", headerName: t("pages.list.grid_columns.invoice_number") },
{ field: "invoice_series", headerName: t("pages.list.grid_columns.invoice_series") },
{
field: "issue_date",
headerName: t("pages.list.grid_columns.issue_date"),
valueFormatter: (params: ValueFormatterParams) => {
return formatDate(params.value);
},
},
{
field: "total_price",
headerName: t("pages.list.grid_columns.total_price"),
valueFormatter: (params: ValueFormatterParams) => {
const rawValue: MoneyDTO = params.value;
return formatMoney(rawValue);
},
},
]);
const gridOptions: GridOptions = {
columnDefs: colDefs,
defaultColDef: {
editable: true,
flex: 1,
minWidth: 100,
filter: false,
sortable: false,
resizable: true,
},
pagination: true,
paginationPageSize: 10,
paginationPageSizeSelector: [10, 20, 30, 50],
localeText: AG_GRID_LOCALE_ES,
rowSelection: { mode: "multiRow" },
};
// Container: Defines the grid's theme & dimensions.
return (
<div
className='ag-theme-alpine'
style={{
height: "100%",
width: "100%",
}}
>
<AgGridReact rowData={data?.items ?? []} loading={isLoading || isPending} {...gridOptions} />
</div>
);
};

View File

@ -0,0 +1,55 @@
import { PropsWithChildren, createContext } from "react";
/**
*
* 💡 Posibles usos del InvoicingContext
*
* Este contexto se diseña para encapsular estado y lógica compartida dentro del
* bounded context de facturación (facturas), proporcionando acceso global a datos
* o funciones relevantes para múltiples vistas (listado, detalle, edición, etc).
*
* Usos recomendados:
*
* 1. 🔎 Gestión de filtros globales:
* - Permite que los filtros aplicados en el listado de facturas se conserven
* cuando el usuario navega a otras vistas (detalle, edición) y luego regresa.
* - Mejora la experiencia de usuario evitando la necesidad de reestablecer filtros.
*
* 2. 🛡 Gestión de permisos o configuración de acciones disponibles:
* - Permite definir qué acciones están habilitadas para el usuario actual
* (crear, editar, eliminar).
* - Útil para mostrar u ocultar botones de acción en diferentes pantallas.
*
* 3. 🧭 Control del layout:
* - Si el layout tiene elementos dinámicos (tabs, breadcrumb, loading global),
* este contexto puede coordinar su estado desde componentes hijos.
* - Ejemplo: seleccionar una pestaña activa que aplica en todas las subrutas.
*
* 4. 📦 Cacheo liviano de datos compartidos:
* - Puede almacenar la última factura abierta, borradores de edición,
* o referencias temporales para operaciones CRUD sin tener que usar la URL.
*
* 5. 🚀 Coordinación de side-effects:
* - Permite exponer funciones comunes como `refetch`, `resetFilters`,
* o `notifyInvoiceChanged`, usadas desde cualquier subcomponente del dominio.
*
* Alternativas:
* - Si el estado compartido es muy mutable, grande o requiere persistencia,
* podría ser preferible usar Zustand o Redux Toolkit.
* - No usar contextos para valores que cambian frecuentemente en tiempo real,
* ya que pueden causar renders innecesarios.
*
*
*/
export type CustomersContextType = {};
export type CustomersContextParamsType = {
//service: CustomerService;
};
export const CustomersContext = createContext<CustomersContextType>({});
export const CustomersProvider = ({ children }: PropsWithChildren) => {
return <CustomersContext.Provider value={{}}>{children}</CustomersContext.Provider>;
};

View File

@ -0,0 +1,61 @@
import { ModuleClientParams } from "@erp/core/client";
import { lazy } from "react";
import { Outlet, RouteObject } from "react-router-dom";
// Lazy load components
const CustomersLayout = lazy(() =>
import("./components").then((m) => ({ default: m.CustomersLayout }))
);
const CustomersList = lazy(() => import("./pages").then((m) => ({ default: m.CustomersList })));
const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreate })));
//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 CustomerEdit = lazy(() => import("./app").then((m) => ({ default: m.CustomerEdit })));
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 CustomersLayout = lazy(() => import("./app").then((m) => ({ default: m.CustomersLayout })));
const CustomersList = lazy(() => import("./app").then((m) => ({ default: m.CustomersList })));*/
export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
return [
{
path: "customers",
element: (
<CustomersLayout>
<Outlet context={params} />
</CustomersLayout>
),
children: [
{ path: "", index: true, element: <CustomersList /> }, // index
{ path: "list", element: <CustomersList /> },
{ path: "create", element: <CustomerAdd /> },
//
/*{ path: "create", element: <CustomersList /> },
{ path: ":id", element: <CustomersList /> },
{ path: ":id/edit", element: <CustomersList /> },
{ path: ":id/delete", element: <CustomersList /> },
{ path: ":id/view", element: <CustomersList /> },
{ path: ":id/print", element: <CustomersList /> },
{ path: ":id/email", element: <CustomersList /> },
{ path: ":id/download", element: <CustomersList /> },
{ path: ":id/duplicate", element: <CustomersList /> },
{ path: ":id/preview", element: <CustomersList /> },*/
],
},
];
};

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