This commit is contained in:
David Arranz 2026-07-14 20:39:31 +02:00
parent 47dffacf03
commit 0129c5963c
59 changed files with 864 additions and 425 deletions

View File

@ -0,0 +1 @@
export * from "../../../../../modules/core/src/web/lib/data-source/errors";

View File

@ -26,6 +26,10 @@
"noUncheckedSideEffectImports": true,
"allowUnreachableCode": true
},
"include": ["src"],
"include": [
"src",
"../../modules/core/src/web/lib/data-source/errors",
"../../modules/core/src/web/lib/data-source/axios/normalize-axios-error.ts"
],
"exclude": ["node_modules"]
}

View File

@ -24,7 +24,6 @@ export class CreatePaymentMethodUseCase {
public execute(params: CreatePaymentMethodUseCaseInput) {
const { dto, companyId } = params;
console.log("Executing CreatePaymentMethodUseCase with params:", params);
const mappedPropsResult = this.deps.dtoMapper.map(dto, { companyId });
if (mappedPropsResult.isFailure) {

View File

@ -10,7 +10,7 @@ import type {
export class TaxDefinitionPublicModelMapper {
public toPublicModel(taxDefinition: TaxDefinition): TaxDefinitionPublicModel {
console.log(taxDefinition.description)
return {
id: taxDefinition.id,
companyId: Maybe.some(taxDefinition.companyId),

View File

@ -80,8 +80,6 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
const allowedSurchargeCodes = extractOrPushError(
maybeFromNullableResult(raw.allowed_surcharge_codes, (v) => {
console.log(v);
const values = v.split(";");
if (!Array.isArray(values))
return Result.fail(new Error("Invalid allowed_surcharge_codes"));

View File

@ -59,6 +59,7 @@ export abstract class ExpressController {
await this.executeImpl();
} catch (error: unknown) {
console.error("Error in ExpressController.execute:", error);
this.handleError(error as Error);
}
}

View File

@ -0,0 +1,74 @@
// packages/rdx-ui/src/components/error-state.tsx
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { AlertCircle, RefreshCw } from "lucide-react";
import type React from "react";
export interface ErrorStateProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
title: React.ReactNode;
description?: React.ReactNode;
reference?: string;
retryLabel?: string;
isRetrying?: boolean;
onRetry?: () => void;
}
export const ErrorState = ({
title,
description,
reference,
retryLabel = "Reintentar",
isRetrying = false,
onRetry,
className,
...props
}: ErrorStateProps) => {
return (
<div
aria-live="polite"
className={cn(
"flex min-h-64 flex-col items-center justify-center rounded-lg border border-destructive/30 bg-destructive/5 px-6 py-10 text-center",
className
)}
role="alert"
{...props}
>
<div
aria-hidden="true"
className="mb-4 flex size-11 items-center justify-center rounded-full bg-destructive/10 text-destructive"
>
<AlertCircle className="size-5" />
</div>
<h2 className="text-base font-semibold text-foreground">{title}</h2>
{description ? (
<div className="mt-2 max-w-xl text-sm text-muted-foreground">{description}</div>
) : null}
{reference ? (
<p className="mt-3 text-xs text-muted-foreground">
Referencia:{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.75rem] text-foreground">
{reference}
</code>
</p>
) : null}
{onRetry ? (
<Button
className="mt-5"
disabled={isRetrying}
onClick={onRetry}
type="button"
variant="outline"
>
<RefreshCw aria-hidden="true" className={cn("size-4", isRetrying && "animate-spin")} />
<span aria-live="polite">{isRetrying ? "Reintentando…" : retryLabel}</span>
</Button>
) : null}
</div>
);
};

View File

@ -1,4 +1,5 @@
export * from "./error-alert";
export * from "./error-state";
export * from "./form";
export * from "./not-found-card";
export * from "./page-header";

View File

@ -3,13 +3,23 @@ import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import type { ICustomParams, IDataSource } from "../datasource.interface";
import { defaultAxiosRequestConfig } from "./create-axios-instance";
import { normalizeAxiosError } from "./normalize-axios-error";
const executeRequest = async <R>(request: () => Promise<AxiosResponse<R>>): Promise<R> => {
try {
const response = await request();
return response.data;
} catch (error) {
throw normalizeAxiosError(error);
}
};
export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
if (!client) {
throw new Error("[Axios] Se esperaba una instancia de Axios.");
}
if (!client.getUri || typeof client.getUri !== "function") {
if (typeof client.getUri !== "function") {
throw new Error("[Axios] La instancia proporcionada no es una instancia de Axios válida.");
}
@ -21,117 +31,83 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
getBaseUrl: () => client.getUri(),
getList: async <R>(resource: string, params?: Record<string, unknown>): Promise<R> => {
const { signal, ...rest } = (params ?? {}) as Record<string, unknown> & {
const { signal, ...queryParams } = (params ?? {}) as Record<string, unknown> & {
signal?: AbortSignal;
};
const res = await client.get<R>(resource, {
signal,
params: rest,
});
return res.data;
return executeRequest(() =>
client.get<R>(resource, {
signal,
params: queryParams,
})
);
},
getOne: async <R>(
resource: string,
id: string | number,
params?: Record<string, unknown>
): Promise<R> => {
const res = await client.get<R>(`${resource}/${id}`, params);
return res.data;
},
): Promise<R> =>
executeRequest(() => client.get<R>(`${resource}/${id}`, params as AxiosRequestConfig)),
getMany: async <R>(resource: string, ids: Array<string | number>): Promise<R> => {
const res = await client.get<R>(resource, {
params: { ids },
});
return res.data;
},
getMany: async <R>(resource: string, ids: Array<string | number>): Promise<R> =>
executeRequest(() =>
client.get<R>(resource, {
params: { ids },
})
),
createOne: async <TData, R>(
resource: string,
data: TData,
params?: Record<string, unknown>
): Promise<R> => {
const res = await client.post<R, AxiosResponse<R>, TData>(
resource,
data,
params as AxiosRequestConfig<TData>
);
return res.data;
},
): Promise<R> =>
executeRequest(() =>
client.post<R, AxiosResponse<R>, TData>(resource, data, params as AxiosRequestConfig<TData>)
),
updateOne: async <TData, R>(
resource: string,
id: string | number,
data: TData,
params?: Record<string, unknown>
): Promise<R> => {
const url = `${resource}/${id}`;
const res = await client.put<R, AxiosResponse<R>, TData>(
url,
data,
params as AxiosRequestConfig<TData>
);
return res.data;
},
): Promise<R> =>
executeRequest(() =>
client.put<R, AxiosResponse<R>, TData>(
`${resource}/${id}`,
data,
params as AxiosRequestConfig<TData>
)
),
deleteOne: async <R = void>(
resource: string,
id: string | number,
params?: Record<string, unknown>
): Promise<R> => {
const res = await client.delete<R>(`${resource}/${id}`, params);
return res.data;
},
): Promise<R> =>
executeRequest(() => client.delete<R>(`${resource}/${id}`, params as AxiosRequestConfig)),
custom: async <TData, R>(customParams: ICustomParams<TData>): Promise<R> => {
const { url, path, method, responseType, headers, signal, data, ...payload } = customParams;
const requestUrl = path ? `${client.getUri()}/${path}` : url;
if (!requestUrl) throw new Error('"url" or "path" param is missing');
if (!requestUrl) {
throw new Error('"url" or "path" param is missing');
}
const config: AxiosRequestConfig<TData> = {
url: requestUrl,
method,
responseType,
signal,
...payload,
...defaultAxiosRequestConfig,
...payload,
headers,
data,
};
let customResponse: AxiosResponse<R, TData>;
switch (method) {
case "put":
case "post":
case "patch":
customResponse = await client.request<R, AxiosResponse<R>, TData>({
...config,
data,
});
break;
case "delete":
customResponse = await client.delete<R, AxiosResponse<R>, TData>(requestUrl, {
...config,
});
break;
default:
customResponse = await client.get<R, AxiosResponse<R>, TData>(requestUrl, {
...config,
});
break;
}
return customResponse.data;
return executeRequest(() => client.request<R, AxiosResponse<R>, TData>(config));
},
};
};

View File

@ -0,0 +1,88 @@
// modules/core/src/web/lib/data-source/axios/normalize-axios-error.ts
import axios, { AxiosError } from "axios";
import {
ApiClientError,
type ApiClientErrorKind,
isApiProblemDetails,
} from "../../../../../../../apps/web/src/lib/data-source";
const getErrorKind = (status: number): ApiClientErrorKind => {
switch (status) {
case 401:
return "unauthorized";
case 403:
return "forbidden";
case 404:
return "not-found";
case 409:
return "conflict";
case 422:
return "validation";
case 503:
return "unavailable";
default:
return status >= 500 ? "server" : "unknown";
}
};
const normalizeProblemError = (error: AxiosError, data: unknown): ApiClientError => {
if (!isApiProblemDetails(data)) {
return new ApiClientError({
kind: error.response?.status && error.response.status >= 500 ? "server" : "unknown",
status: error.response?.status,
message: "The server returned an unexpected error response.",
cause: error,
});
}
return new ApiClientError({
kind: getErrorKind(data.status),
status: data.status,
title: data.title,
detail: data.detail,
code: data.code,
source: data.source,
instance: data.instance,
correlationId: data.correlationId,
issues: data.errors,
problem: data,
message: data.detail || data.title,
cause: error,
});
};
export const normalizeAxiosError = (error: unknown): Error => {
if (!axios.isAxiosError(error)) {
return error instanceof Error
? error
: new ApiClientError({
kind: "unknown",
message: "An unexpected error occurred.",
cause: error,
});
}
if (error.code === AxiosError.ERR_CANCELED) {
return error;
}
if (error.code === AxiosError.ECONNABORTED) {
return new ApiClientError({
kind: "timeout",
message: "The request timed out.",
cause: error,
});
}
if (!error.response) {
return new ApiClientError({
kind: "network",
message: "The server could not be reached.",
cause: error,
});
}
return normalizeProblemError(error, error.response.data);
};

View File

@ -0,0 +1,65 @@
// modules/core/src/web/lib/data-source/errors/api-client-error.ts
import type { ApiProblemDetails, ApiProblemIssue, ApiProblemSource } from "./api-problem-details";
export type ApiClientErrorKind =
| "unauthorized"
| "forbidden"
| "not-found"
| "conflict"
| "validation"
| "unavailable"
| "server"
| "network"
| "timeout"
| "unknown";
export interface ApiClientErrorOptions {
kind: ApiClientErrorKind;
message: string;
status?: number;
title?: string;
detail?: string;
code?: string;
source?: ApiProblemSource;
instance?: string;
correlationId?: string;
issues?: ApiProblemIssue[];
problem?: ApiProblemDetails;
cause?: unknown;
}
export class ApiClientError extends Error {
readonly kind: ApiClientErrorKind;
readonly status?: number;
readonly title?: string;
readonly detail?: string;
readonly code?: string;
readonly source?: ApiProblemSource;
readonly instance?: string;
readonly correlationId?: string;
readonly issues: readonly ApiProblemIssue[];
readonly problem?: ApiProblemDetails;
constructor(options: ApiClientErrorOptions) {
super(options.message, { cause: options.cause });
this.name = "ApiClientError";
this.kind = options.kind;
this.status = options.status;
this.title = options.title;
this.detail = options.detail;
this.code = options.code;
this.source = options.source;
this.instance = options.instance;
this.correlationId = options.correlationId;
this.issues = options.issues ?? [];
this.problem = options.problem;
Object.setPrototypeOf(this, ApiClientError.prototype);
}
}
export const isApiClientError = (error: unknown): error is ApiClientError =>
error instanceof ApiClientError;

View File

@ -0,0 +1,26 @@
export type ApiProblemPathSegment = string | number;
export interface ApiProblemIssue {
path?: ApiProblemPathSegment[];
code?: string;
message: string;
origin?: string;
format?: string;
}
export type ApiProblemSource = "request" | "response" | "domain" | "infrastructure";
export interface ApiProblemDetails {
type: string;
title: string;
status: number;
detail: string;
instance?: string;
method?: string;
correlationId?: string;
code?: string;
source?: ApiProblemSource;
errors?: ApiProblemIssue[];
}

View File

@ -0,0 +1,92 @@
import { isApiClientError } from "./api-client-error";
export interface UserErrorMessage {
title: string;
description: string;
reference?: string;
}
export const getUserErrorMessage = (error: unknown): UserErrorMessage => {
if (!isApiClientError(error)) {
return {
title: "No se ha podido completar la operación",
description: "Se ha producido un error inesperado.",
};
}
const reference = error.correlationId;
switch (error.kind) {
case "unauthorized":
return {
title: "La sesión no es válida",
description: "Vuelve a iniciar sesión para continuar.",
reference,
};
case "forbidden":
return {
title: "Acceso no permitido",
description: "No tienes permisos para realizar esta operación.",
reference,
};
case "not-found":
return {
title: "Recurso no encontrado",
description: "El recurso solicitado ya no existe o no está disponible.",
reference,
};
case "conflict":
return {
title: "No se puede completar la operación",
description: error.detail || "El estado actual del recurso impide realizarla.",
reference,
};
case "validation":
if (error.source === "request") {
return {
title: "Revisa los datos introducidos",
description: "Algunos datos enviados no son válidos.",
reference,
};
}
return {
title: "No se han podido procesar los datos",
description:
"El servidor ha devuelto información inconsistente. Inténtalo de nuevo o comunica la incidencia.",
reference,
};
case "network":
return {
title: "Sin conexión con el servidor",
description: "Comprueba la conexión de red e inténtalo de nuevo.",
};
case "timeout":
return {
title: "La operación está tardando demasiado",
description: "El servidor no ha respondido dentro del tiempo esperado.",
};
case "unavailable":
return {
title: "Servicio temporalmente no disponible",
description: "Inténtalo de nuevo dentro de unos instantes.",
reference,
};
case "server":
case "unknown":
default:
return {
title: "No se ha podido completar la operación",
description: "Se ha producido un error interno. Inténtalo de nuevo.",
reference,
};
}
};

View File

@ -0,0 +1,4 @@
export * from "./api-client-error";
export * from "./api-problem-details";
export * from "./get-user-error-message";
export * from "./is-api-problem-details";

View File

@ -0,0 +1,47 @@
import type {
ApiProblemDetails,
ApiProblemIssue,
ApiProblemPathSegment,
} from "./api-problem-details";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const isPathSegment = (value: unknown): value is ApiProblemPathSegment =>
typeof value === "string" || typeof value === "number";
const isApiProblemIssue = (value: unknown): value is ApiProblemIssue => {
if (!isRecord(value) || typeof value.message !== "string") {
return false;
}
if (value.path !== undefined && !(Array.isArray(value.path) && value.path.every(isPathSegment))) {
return false;
}
return true;
};
export const isApiProblemDetails = (value: unknown): value is ApiProblemDetails => {
if (!isRecord(value)) {
return false;
}
if (
typeof value.type !== "string" ||
typeof value.title !== "string" ||
typeof value.status !== "number" ||
typeof value.detail !== "string"
) {
return false;
}
if (
value.errors !== undefined &&
!(Array.isArray(value.errors) && value.errors.every(isApiProblemIssue))
) {
return false;
}
return true;
};

View File

@ -1,3 +1,4 @@
export * from "./axios";
export * from "./build-text-filters";
export * from "./datasource.interface";
export * from "./errors";

View File

@ -4,9 +4,9 @@ import type { Maybe } from "@repo/rdx-utils";
import type {
InvoiceAmount,
InvoiceNumber,
InvoiceRecipient,
InvoiceSerie,
InvoiceStatus,
IssuedInvoiceRecipient,
VerifactuRecord,
} from "../../../domain";
@ -14,7 +14,6 @@ export type IssuedInvoiceSummary = {
id: UniqueID;
companyId: UniqueID;
isProforma: boolean;
invoiceNumber: InvoiceNumber;
status: InvoiceStatus;
series: Maybe<InvoiceSerie>;
@ -26,7 +25,7 @@ export type IssuedInvoiceSummary = {
description: Maybe<string>;
customerId: UniqueID;
recipient: InvoiceRecipient;
recipient: IssuedInvoiceRecipient;
languageCode: LanguageCode;
currencyCode: CurrencyCode;

View File

@ -31,16 +31,15 @@ export class IssuedInvoiceSummarySnapshotBuilder implements IIssuedInvoiceSummar
return {
id: invoice.id.toString(),
company_id: invoice.companyId.toString(),
is_proforma: invoice.isProforma,
invoice_number: invoice.invoiceNumber.toString(),
status: invoice.status.toPrimitive(),
series: maybeToEmptyString(invoice.series, (v) => v.toString()),
invoice_date: invoice.invoiceDate.toDateString(),
operation_date: maybeToEmptyString(invoice.operationDate, (v) => v.toDateString()),
reference: maybeToEmptyString(invoice.reference, (v) => v.toString()),
description: maybeToEmptyString(invoice.description, (v) => v.toString()),
operation_date: maybeToNullable(invoice.operationDate, (v) => v.toDateString()),
reference: maybeToNullable(invoice.reference, (v) => v.toString()),
description: maybeToNullable(invoice.description, (v) => v.toString()),
customer_id: invoice.customerId.toString(),

View File

@ -4,20 +4,19 @@
export interface IIssuedInvoiceSummarySnapshot {
id: string;
company_id: string;
is_proforma: boolean;
invoice_number: string;
status: string;
series: string;
invoice_date: string;
operation_date: string;
operation_date: string | null;
language_code: string;
currency_code: string;
reference: string | null;
description: string;
description: string | null;
customer_id: string;
recipient: {

View File

@ -15,12 +15,12 @@ import { Maybe, NumberHelper, Result } from "@repo/rdx-utils";
import type { CreateProformaRequestDTO } from "../../../../common";
import {
type InvoiceRecipient,
InvoiceSerie,
InvoiceStatus,
ItemAmount,
ItemDescription,
ItemQuantity,
type ProformaRecipient,
} from "../../../domain";
import type {
ProformaCreateInputProps,
@ -64,8 +64,8 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
errors
);
const invoiceDate = extractOrPushError(
UtcDate.createFromISO(dto.invoice_date),
const proformaDate = extractOrPushError(
UtcDate.createFromISO(dto.proforma_date),
"invoice_date",
errors
);
@ -142,11 +142,11 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
//invoiceNumber: invoiceNumber!,
series: series!,
proformaDate: invoiceDate!,
proformaDate: proformaDate!,
operationDate: operationDate!,
customerId: customerId!,
recipient: Maybe.none<InvoiceRecipient>(),
recipient: Maybe.none<ProformaRecipient>(),
reference: reference!,
description: description!,
@ -169,6 +169,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
props,
});
} catch (err: unknown) {
console.error(err);
return Result.fail(new DomainError("Proforma props mapping failed", { cause: err }));
}
}

View File

@ -12,7 +12,7 @@ import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.
* - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio.
*/
export type ProformaCreateInputProps = Omit<IProformaCreateProps, "items" | "invoiceNumber"> & {
export type ProformaCreateInputProps = Omit<IProformaCreateProps, "items" | "proformaReference"> & {
items: ProformaItemCreateInputProps[];
};

View File

@ -77,8 +77,6 @@ export class ProformaUpdater implements IProformaUpdater {
}): Promise<Result<ProformaPatchProps, Error>> {
const { patch, companyId, currentInvoiceDate } = params;
console.log(patch);
if (patch.taxRegimeCode !== undefined) {
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({
companyId,

View File

@ -10,7 +10,6 @@ export interface IProformaSummarySnapshotBuilder
export class ProformaSummarySnapshotBuilder implements IProformaSummarySnapshotBuilder {
toOutput(proforma: ProformaSummary): ProformaSummaryDTO {
const recipient = proforma.recipient.toObjectString();
console.log("Proforma => ", proforma);
return {
id: proforma.id.toString(),

View File

@ -19,48 +19,85 @@ export class IssuedInvoiceItemModel extends Model<
InferAttributes<IssuedInvoiceItemModel>,
InferCreationAttributes<IssuedInvoiceItemModel, { omit: "issued_invoice" }>
> {
declare id: string;
declare item_id: string;
declare issued_invoice_id: string;
declare position: number;
declare description: CreationOptional<string | null>;
declare quantity_value: CreationOptional<number | null>;
declare quantity_scale: number;
declare unit_amount_value: CreationOptional<number | null>;
declare unit_amount_scale: number;
// Subtotal (cantidad * importe unitario)
declare subtotal_amount_value: number;
declare subtotal_amount_scale: number;
// Discount percentage
declare item_discount_percentage_value: CreationOptional<number | null>;
declare item_discount_percentage_scale: number;
// Discount amount
declare item_discount_amount_value: number;
declare item_discount_amount_scale: number;
// Porcentaje de descuento global proporcional a esta línea.
declare global_discount_percentage_value: number;
declare global_discount_percentage_scale: number;
// Importe del descuento global para esta línea
declare global_discount_amount_value: number;
declare global_discount_amount_scale: number;
// Suma de los dos descuentos: el de la linea + el global
declare total_discount_amount_value: number;
declare total_discount_amount_scale: number;
// Taxable amount (base imponible tras los dos descuentos)
declare taxable_amount_value: number;
declare taxable_amount_scale: number;
// IVA percentage
declare iva_code: CreationOptional<string | null>;
declare iva_percentage_value: CreationOptional<number | null>;
declare iva_percentage_scale: number;
// IVA amount
declare iva_amount_value: number;
declare iva_amount_scale: number;
// Recargo de equivalencia percentage
declare rec_code: CreationOptional<string | null>;
declare rec_percentage_value: CreationOptional<number | null>;
declare rec_percentage_scale: number;
// Recargo de equivalencia amount
declare rec_amount_value: number;
declare rec_amount_scale: number;
// Retention percentage
declare retention_code: CreationOptional<string | null>;
declare retention_percentage_value: CreationOptional<number | null>;
declare retention_percentage_scale: number;
declare retention_amount_value: number;
declare retention_amount_scale: number;
// Total taxes amount / taxes total
declare taxes_amount_value: number;
declare taxes_amount_scale: number;
// Total
declare total_amount_value: number;
declare total_amount_scale: number;
// Relaciones
declare issued_invoice: NonAttribute<IssuedInvoiceModel>;
static associate(database: Sequelize) {
@ -88,10 +125,9 @@ export class IssuedInvoiceItemModel extends Model<
export default (database: Sequelize) => {
IssuedInvoiceItemModel.init(
{
id: {
item_id: {
type: DataTypes.UUID,
primaryKey: true,
field: "item_id",
charset: "utf8mb4",
collate: "utf8mb4_bin",
} as any,
@ -112,96 +148,117 @@ export default (database: Sequelize) => {
allowNull: true,
defaultValue: null,
},
quantity_value: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
quantity_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
},
unit_amount_value: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
unit_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
subtotal_amount_value: {
type: new DataTypes.BIGINT(),
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
},
subtotal_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
item_discount_percentage_value: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
item_discount_percentage_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
},
item_discount_amount_value: {
type: new DataTypes.BIGINT(),
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
},
item_discount_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
global_discount_percentage_value: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 0,
},
global_discount_percentage_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
},
global_discount_amount_value: {
type: new DataTypes.BIGINT(),
allowNull: false,
defaultValue: 0,
},
global_discount_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
total_discount_amount_value: {
type: new DataTypes.BIGINT(),
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
},
total_discount_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
taxable_amount_value: {
type: new DataTypes.BIGINT(),
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
},
taxable_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
// IVA %
iva_code: {
type: DataTypes.STRING(40),
allowNull: true,
@ -217,6 +274,7 @@ export default (database: Sequelize) => {
allowNull: false,
defaultValue: 2,
},
iva_amount_value: {
type: DataTypes.BIGINT,
allowNull: false,
@ -227,11 +285,14 @@ export default (database: Sequelize) => {
allowNull: false,
defaultValue: 4,
},
// REC %
rec_code: {
type: DataTypes.STRING(40),
allowNull: true,
defaultValue: null,
},
rec_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
@ -242,6 +303,7 @@ export default (database: Sequelize) => {
allowNull: false,
defaultValue: 2,
},
rec_amount_value: {
type: DataTypes.BIGINT,
allowNull: false,
@ -252,11 +314,14 @@ export default (database: Sequelize) => {
allowNull: false,
defaultValue: 4,
},
// Retención %
retention_code: {
type: DataTypes.STRING(40),
allowNull: true,
defaultValue: null,
},
retention_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
@ -267,6 +332,7 @@ export default (database: Sequelize) => {
allowNull: false,
defaultValue: 2,
},
retention_amount_value: {
type: DataTypes.BIGINT,
allowNull: false,
@ -277,21 +343,25 @@ export default (database: Sequelize) => {
allowNull: false,
defaultValue: 4,
},
taxes_amount_value: {
type: new DataTypes.BIGINT(),
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
},
taxes_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
total_amount_value: {
type: new DataTypes.BIGINT(),
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
},
total_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
@ -302,11 +372,15 @@ export default (database: Sequelize) => {
sequelize: database,
modelName: "IssuedInvoiceItemModel",
tableName: "issued_invoice_items",
charset: "utf8mb4",
collate: "utf8mb4_unicode_ci",
underscored: true,
indexes: [{ fields: ["issued_invoice_id"] }, { fields: ["issued_invoice_id", "position"] }],
whereMergeStrategy: "and",
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {
order: [["position", "ASC"]],
},

View File

@ -51,43 +51,68 @@ export class IssuedInvoiceModel extends Model<
declare reference: CreationOptional<string | null>;
declare description: string;
declare notes: CreationOptional<string | null>;
declare language_code: CreationOptional<string>;
declare currency_code: CreationOptional<string>;
// Método de pago
declare payment_method_id: CreationOptional<string | null>;
declare payment_method_name: CreationOptional<string | null>;
declare payment_method_description: CreationOptional<string | null>;
// Plazo de pago
declare payment_term_id: CreationOptional<string | null>;
declare payment_term_name: CreationOptional<string | null>;
declare payment_term_description: CreationOptional<string | null>;
declare payment_term_snapshot_json: CreationOptional<string | null>;
// Régimen fiscal
declare tax_regime_code: string;
declare tax_regime_description: string;
// Subtotal
declare subtotal_amount_value: number;
declare subtotal_amount_scale: number;
// Items discount amount (suma de descuentos individuales por ítem)
declare items_discount_amount_value: number;
declare items_discount_amount_scale: number;
// Global/header discount percentage
declare global_discount_percentage_value: number;
declare global_discount_percentage_scale: number;
// Global/header discount amount
declare global_discount_amount_value: number;
declare global_discount_amount_scale: number;
// Total discount amount (subtotal - descuentos)
declare total_discount_amount_value: number;
declare total_discount_amount_scale: number;
// Taxable amount (base imponible)
declare taxable_amount_value: number;
declare taxable_amount_scale: number;
declare iva_amount_value: CreationOptional<number | null>;
// IVA amount
declare iva_amount_value: number;
declare iva_amount_scale: number;
declare rec_amount_value: CreationOptional<number | null>;
// Recargo de equivalencia amount
declare rec_amount_value: number;
declare rec_amount_scale: number;
declare retention_amount_value: CreationOptional<number | null>;
// Retention amount
declare retention_amount_value: number;
declare retention_amount_scale: number;
// Total taxes amount / taxes total
declare taxes_amount_value: number;
declare taxes_amount_scale: number;
// Total
declare total_amount_value: number;
declare total_amount_scale: number;

View File

@ -76,18 +76,18 @@ export class ProformaModel extends Model<
declare total_amount_scale: number;
declare customer_id: string;
declare customer_tin: CreationOptional<string | null>;
/*declare customer_tin: CreationOptional<string | null>;
declare customer_name: CreationOptional<string | null>;
declare customer_street: CreationOptional<string | null>;
declare customer_street2: CreationOptional<string | null>;
declare customer_city: CreationOptional<string | null>;
declare customer_province: CreationOptional<string | null>;
declare customer_postal_code: CreationOptional<string | null>;
declare customer_country: CreationOptional<string | null>;
declare customer_country: CreationOptional<string | null>;*/
declare items: NonAttribute<ProformaItemModel[]>;
declare taxes: NonAttribute<ProformaTaxModel[]>;
declare current_customer?: NonAttribute<CustomerModel | null>;
declare current_customer: NonAttribute<CustomerModel>;
declare linked_invoice?: NonAttribute<IssuedInvoiceModel | null>;
static associate(database: Sequelize) {
@ -352,7 +352,7 @@ export default (database: Sequelize) => {
collate: "utf8mb4_bin",
} as any,
customer_tin: {
/*customer_tin: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
@ -391,7 +391,7 @@ export default (database: Sequelize) => {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
},*/
},
{
sequelize: database,

View File

@ -52,7 +52,7 @@ export const issuedInvoicesRouter = (params: StartParams) => {
);
router.get(
"/:invoice_id/report",
"/:invoice_id/report/pdf",
//checkTabContext,
validateRequest(ReportIssueInvoiceByIdQueryRequestSchema, "query"),
validateRequest(ReportIssueInvoiceByIdParamsRequestSchema, "params"),

View File

@ -1,6 +1,6 @@
export * from "./sequelize-issued-invoice-domain.mapper";
export * from "./sequelize-issued-invoice-v2-domain.mapper";
export * from "./sequelize-issued-invoice-v2-item-domain.mapper";
/*export * from "./sequelize-issued-invoice-v2-item-domain.mapper";
export * from "./sequelize-issued-invoice-v2-recipient-domain.mapper";
export * from "./sequelize-issued-invoice-v2-taxes-domain.mapper";
export * from "./sequelize-issued-invoice-v2-verifactu-domain.mapper";
*/

View File

@ -98,7 +98,11 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
"reference",
errors
);
const description = extractOrPushError(Result.ok(String(raw.description ?? "")), "description", errors);
const description = extractOrPushError(
Result.ok(String(raw.description ?? "")),
"description",
errors
);
const notes = extractOrPushError(
maybeFromNullableResult(raw.notes, (value) => TextValue.create(value)),
"notes",
@ -124,18 +128,18 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
}
const taxRegime = extractOrPushError(
!isNullishOrEmpty(raw.tax_regime_code)
? InvoiceTaxRegime.create({
code: String(raw.tax_regime_code),
description: String(raw.tax_regime_description ?? raw.tax_regime_code),
})
: Result.fail(
isNullishOrEmpty(raw.tax_regime_code)
? Result.fail(
new DomainValidationError(
"MISSING_TAX_REGIME",
"tax_regime",
"Issued invoice requires persisted tax regime"
)
),
)
: InvoiceTaxRegime.create({
code: String(raw.tax_regime_code),
description: String(raw.tax_regime_description ?? raw.tax_regime_code),
}),
"tax_regime",
errors
);
@ -167,7 +171,10 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
errors
);
const totalDiscountAmount = extractOrPushError(
InvoiceAmount.create({ value: raw.total_discount_amount_value, currency_code: currencyCode?.code }),
InvoiceAmount.create({
value: raw.total_discount_amount_value,
currency_code: currencyCode?.code,
}),
"total_discount_amount_value",
errors
);
@ -187,7 +194,10 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
errors
);
const retentionAmount = extractOrPushError(
InvoiceAmount.create({ value: raw.retention_amount_value, currency_code: currencyCode?.code }),
InvoiceAmount.create({
value: raw.retention_amount_value,
currency_code: currencyCode?.code,
}),
"retention_amount_value",
errors
);
@ -233,8 +243,13 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
};
}
public mapToDomain(raw: IssuedInvoiceModel, params?: MapperParamsType): Result<IssuedInvoice, Error> {
public mapToDomain(
raw: IssuedInvoiceModel,
params?: MapperParamsType
): Result<IssuedInvoice, Error> {
try {
console.debug(raw);
const errors: ValidationErrorDetail[] = [];
const attributes = this._mapAttributesToDomain(raw, { errors, ...params });
const recipientResult = this._recipientMapper.mapToDomain(raw, {
@ -319,23 +334,27 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper<
params?: MapperParamsType
): Result<IssuedInvoiceCreationAttributes, Error> {
const errors: ValidationErrorDetail[] = [];
const itemsResult = this._itemsMapper.mapToPersistenceArray(source.items, {
errors,
parent: source,
...params,
});
if (itemsResult.isFailure) errors.push({ path: "items", message: itemsResult.error.message });
const taxesResult = this._taxesMapper.mapToPersistenceArray(source.taxes, {
errors,
parent: source,
...params,
});
if (taxesResult.isFailure) errors.push({ path: "taxes", message: taxesResult.error.message });
const recipient = this._recipientMapper.mapToPersistence(source.recipient, {
errors,
parent: source,
...params,
});
const verifactuResult = this._verifactuMapper.mapToPersistence(source.verifactu, {
errors,
parent: source,

View File

@ -13,13 +13,14 @@ import {
maybeFromNullableOrEmptyString,
maybeFromNullableResult,
maybeToNullable,
maybeToNullableString,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceCreateProps,
type IssuedInvoice,
type IIssuedInvoiceItemCreateProps,
type IssuedInvoice,
IssuedInvoiceItem,
ItemAmount,
ItemDescription,
@ -56,7 +57,11 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
attributes: Partial<IIssuedInvoiceCreateProps>;
};
const itemId = extractOrPushError(UniqueID.create(raw.id), `items[${index}].id`, errors);
const itemId = extractOrPushError(
UniqueID.create(raw.item_id),
`items[${index}].item_id`,
errors
);
const description = extractOrPushError(
maybeFromNullableResult(raw.description, (v) => ItemDescription.create(v)),
`items[${index}].description`,
@ -75,7 +80,10 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors
);
const subtotalAmount = extractOrPushError(
ItemAmount.create({ value: raw.subtotal_amount_value, currency_code: attributes.currencyCode?.code }),
ItemAmount.create({
value: raw.subtotal_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`items[${index}].subtotal_amount_value`,
errors
);
@ -130,7 +138,10 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors
);
const ivaAmount = extractOrPushError(
ItemAmount.create({ value: raw.iva_amount_value, currency_code: attributes.currencyCode?.code }),
ItemAmount.create({
value: raw.iva_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`items[${index}].iva_amount_value`,
errors
);
@ -141,13 +152,18 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors
);
const recAmount = extractOrPushError(
ItemAmount.create({ value: raw.rec_amount_value, currency_code: attributes.currencyCode?.code }),
ItemAmount.create({
value: raw.rec_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`items[${index}].rec_amount_value`,
errors
);
const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code);
const retentionPercentage = extractOrPushError(
maybeFromNullableResult(raw.retention_percentage_value, (value) => TaxPercentage.create({ value })),
maybeFromNullableResult(raw.retention_percentage_value, (value) =>
TaxPercentage.create({ value })
),
`items[${index}].retention_percentage_value`,
errors
);
@ -160,12 +176,18 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
errors
);
const taxesAmount = extractOrPushError(
ItemAmount.create({ value: raw.taxes_amount_value, currency_code: attributes.currencyCode?.code }),
ItemAmount.create({
value: raw.taxes_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`items[${index}].taxes_amount_value`,
errors
);
const totalAmount = extractOrPushError(
ItemAmount.create({ value: raw.total_amount_value, currency_code: attributes.currencyCode?.code }),
ItemAmount.create({
value: raw.total_amount_value,
currency_code: attributes.currencyCode?.code,
}),
`items[${index}].total_amount_value`,
errors
);
@ -258,18 +280,25 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
};
return Result.ok({
id: source.id.toPrimitive(),
item_id: source.id.toPrimitive(),
issued_invoice_id: parent.id.toPrimitive(),
position: index,
description: maybeToNullable(source.description, (v) => v.toPrimitive()),
quantity_value: maybeToNullable(source.quantity, (v) => v.toPrimitive().value),
quantity_scale:
maybeToNullable(source.quantity, (v) => v.toPrimitive().scale) ?? ItemQuantity.DEFAULT_SCALE,
maybeToNullable(source.quantity, (v) => v.toPrimitive().scale) ??
ItemQuantity.DEFAULT_SCALE,
unit_amount_value: maybeToNullable(source.unitAmount, (v) => v.toPrimitive().value),
unit_amount_scale:
maybeToNullable(source.unitAmount, (v) => v.toPrimitive().scale) ?? ItemAmount.DEFAULT_SCALE,
maybeToNullable(source.unitAmount, (v) => v.toPrimitive().scale) ??
ItemAmount.DEFAULT_SCALE,
subtotal_amount_value: source.subtotalAmount.toPrimitive().value,
subtotal_amount_scale: source.subtotalAmount.toPrimitive().scale,
item_discount_percentage_value: maybeToNullable(
source.itemDiscountPercentage,
(v) => v.toPrimitive().value
@ -277,36 +306,62 @@ export class SequelizeIssuedInvoiceV2ItemDomainMapper extends SequelizeDomainMap
item_discount_percentage_scale:
maybeToNullable(source.itemDiscountPercentage, (v) => v.toPrimitive().scale) ??
DiscountPercentage.DEFAULT_SCALE,
item_discount_amount_value: source.itemDiscountAmount.toPrimitive().value,
item_discount_amount_scale: source.itemDiscountAmount.toPrimitive().scale,
global_discount_percentage_value: source.globalDiscountPercentage.toPrimitive().value,
global_discount_percentage_scale: source.globalDiscountPercentage.toPrimitive().scale,
global_discount_amount_value: source.globalDiscountAmount.toPrimitive().value,
global_discount_amount_scale: source.globalDiscountAmount.toPrimitive().scale,
total_discount_amount_value: source.totalDiscountAmount.toPrimitive().value,
total_discount_amount_scale: source.totalDiscountAmount.toPrimitive().scale,
taxable_amount_value: source.taxableAmount.toPrimitive().value,
taxable_amount_scale: source.taxableAmount.toPrimitive().scale,
iva_code: source.ivaCode ?? null,
iva_percentage_value: source.ivaPercentage?.toPrimitive().value ?? null,
iva_percentage_scale: source.ivaPercentage?.toPrimitive().scale ?? TaxPercentage.DEFAULT_SCALE,
iva_amount_value: source.ivaAmount.toPrimitive().value,
iva_amount_scale: source.ivaAmount.toPrimitive().scale,
rec_code: source.recCode ?? null,
rec_percentage_value: source.recPercentage?.toPrimitive().value ?? null,
rec_percentage_scale: source.recPercentage?.toPrimitive().scale ?? TaxPercentage.DEFAULT_SCALE,
rec_amount_value: source.recAmount.toPrimitive().value,
rec_amount_scale: source.recAmount.toPrimitive().scale,
retention_code: source.retentionCode ?? null,
retention_percentage_value: source.retentionPercentage?.toPrimitive().value ?? null,
// IVA
iva_code: maybeToNullableString(source.ivaCode),
iva_percentage_value: maybeToNullable(source.ivaPercentage, (v) => v.toPrimitive().value),
iva_percentage_scale:
maybeToNullable(source.ivaPercentage, (v) => v.toPrimitive().scale) ?? 2,
iva_amount_value: source.ivaAmount.value,
iva_amount_scale: source.ivaAmount.scale,
// REC
rec_code: maybeToNullableString(source.recCode),
rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value),
rec_percentage_scale:
maybeToNullable(source.recPercentage, (v) => v.toPrimitive().scale) ?? 2,
rec_amount_value: source.recAmount.value,
rec_amount_scale: source.recAmount.scale,
// RET
retention_code: maybeToNullableString(source.retentionCode),
retention_percentage_value: maybeToNullable(
source.retentionPercentage,
(v) => v.toPrimitive().value
),
retention_percentage_scale:
source.retentionPercentage?.toPrimitive().scale ?? TaxPercentage.DEFAULT_SCALE,
retention_amount_value: source.retentionAmount.toPrimitive().value,
retention_amount_scale: source.retentionAmount.toPrimitive().scale,
maybeToNullable(source.retentionPercentage, (v) => v.toPrimitive().scale) ?? 2,
retention_amount_value: source.retentionAmount.value,
retention_amount_scale: source.retentionAmount.scale,
//
taxes_amount_value: source.taxesAmount.toPrimitive().value,
taxes_amount_scale: source.taxesAmount.toPrimitive().scale,
//
total_amount_value: source.totalAmount.toPrimitive().value,
total_amount_scale: source.totalAmount.toPrimitive().scale,
});
} satisfies IssuedInvoiceItemCreationAttributes);
}
}

View File

@ -28,15 +28,31 @@ export class SequelizeIssuedInvoiceV2RecipientDomainMapper {
attributes: Partial<IIssuedInvoiceCreateProps>;
};
const customerName = extractOrPushError(Name.create(source.customer_name!), "customer_name", errors);
const customerTin = extractOrPushError(TINNumber.create(source.customer_tin!), "customer_tin", errors);
const customerStreet = extractOrPushError(Street.create(source.customer_street!), "customer_street", errors);
const customerName = extractOrPushError(
Name.create(source.customer_name!),
"customer_name",
errors
);
const customerTin = extractOrPushError(
TINNumber.create(source.customer_tin!),
"customer_tin",
errors
);
const customerStreet = extractOrPushError(
Street.create(source.customer_street!),
"customer_street",
errors
);
const customerStreet2 = extractOrPushError(
maybeFromNullableResult(source.customer_street2, (value) => Street.create(value)),
"customer_street2",
errors
);
const customerCity = extractOrPushError(City.create(source.customer_city!), "customer_city", errors);
const customerCity = extractOrPushError(
City.create(source.customer_city!),
"customer_city",
errors
);
const customerProvince = extractOrPushError(
maybeFromNullableResult(source.customer_province, (value) => Province.create(value)),
"customer_province",
@ -75,7 +91,7 @@ export class SequelizeIssuedInvoiceV2RecipientDomainMapper {
return Result.ok(createResult.data);
}
mapToPersistence(source: IssuedInvoiceRecipient) {
mapToPersistence(source: IssuedInvoiceRecipient, params?: MapperParamsType) {
return {
customer_tin: source.tin.toString(),
customer_name: source.name.toPrimitive(),

View File

@ -1,3 +1,2 @@
export * from "./sequelize-issued-invoice-summary.mapper";
export * from "./sequelize-issued-invoice-v2-summary.mapper";
export * from "./sequelize-issued-invoice-v2-recipient-summary.mapper";
//export * from "./sequelize-issued-invoice-v2-recipient-summary.mapper";

View File

@ -21,8 +21,8 @@ import {
} from "../../../../../../domain";
import type { IssuedInvoiceModel } from "../../../../../common";
import { SequelizeVerifactuRecordSummaryMapper } from "./sequelize-verifactu-record-summary.mapper";
import { SequelizeIssuedInvoiceV2RecipientSummaryMapper } from "./sequelize-issued-invoice-v2-recipient-summary.mapper";
import { SequelizeVerifactuRecordSummaryMapper } from "./sequelize-verifactu-record-summary.mapper";
export class SequelizeIssuedInvoiceV2SummaryMapper extends SequelizeQueryMapper<
IssuedInvoiceModel,
@ -113,12 +113,17 @@ export class SequelizeIssuedInvoiceV2SummaryMapper extends SequelizeQueryMapper<
"invoice_number",
errors
);
const invoiceDate = extractOrPushError(UtcDate.createFromISO(raw.invoice_date), "invoice_date", errors);
const invoiceDate = extractOrPushError(
UtcDate.createFromISO(raw.invoice_date),
"invoice_date",
errors
);
const operationDate = extractOrPushError(
maybeFromNullableResult(raw.operation_date, (value) => UtcDate.createFromISO(value)),
"operation_date",
errors
);
const reference = extractOrPushError(
maybeFromNullableResult(raw.reference, (value) => Result.ok(String(value))),
"reference",

View File

@ -5,10 +5,10 @@ import {
requireCompanyContextGuard,
} from "@erp/core/api";
import type { GetProformaByIdUseCase } from "../../../../application";
import type { GetProformaByIdUseCase } from "../../../../application/index.ts";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class GetProformaController extends ExpressController {
export class GetProformaByIdController extends ExpressController {
public constructor(private readonly useCase: GetProformaByIdUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;

View File

@ -1,7 +1,7 @@
//export * from "./change-status-proforma.controller";
//export * from "./create-proforma.controller";
//export * from "./delete-proforma.controller";
export * from "./get-proforma.controller";
export * from "./get-proforma-by-id.controller";
export * from "./issue-proforma.controller";
export * from "./list-proformas.controller";
export * from "./preview-proforma-report.controller";

View File

@ -3,7 +3,7 @@ import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import {
GetProformaController,
GetProformaByIdController,
IssueProformaController,
ListProformasController,
PreviewProformaReportController,
@ -15,9 +15,9 @@ import {
ChangeStatusProformaByIdParamsRequestSchema,
ChangeStatusProformaByIdRequestSchema,
CreateProformaRequestSchema,
LegacyIssueProformaByIdParamsRequestSchema,
GetProformaByIdRequestSchema,
IssueProformaByIdParamsRequestSchema,
LegacyIssueProformaByIdParamsRequestSchema,
ListProformasRequestSchema,
PreviewProformaReportByIdParamsRequestSchema,
ReportProformaPdfByIdParamsRequestSchema,
@ -28,8 +28,8 @@ import type { IIssuedInvoicePublicServices } from "../../../application";
import { ChangeStatusProformaController } from "./controllers/change-status-proforma.controller";
import { CreateProformaController } from "./controllers/create-proforma.controller";
import { IssueProformaByIdRequestMapper } from "./mappers";
import { UpdateProformaController } from "./controllers/update-proforma.controller";
import { IssueProformaByIdRequestMapper } from "./mappers";
export const proformasRouter = (params: StartParams) => {
const { app, config, getService, getInternal } = params;
@ -67,7 +67,7 @@ export const proformasRouter = (params: StartParams) => {
validateRequest(GetProformaByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.getProformaById();
const controller = new GetProformaController(useCase);
const controller = new GetProformaByIdController(useCase);
return controller.execute(req, res, next);
}
);

View File

@ -218,11 +218,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
errors.push({ path: "taxes", message: taxesResult.error.message });
}
const recipient = this._recipientMapper.mapToPersistence(source.recipient, {
errors,
parent: source,
...params,
});
const recipient = this._recipientMapper.mapToPersistence(source.recipient);
if (errors.length > 0) {
return Result.fail(
@ -233,8 +229,11 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
const allAmounts = source.totals();
return Result.ok({
// Identificación
id: source.id.toPrimitive(),
company_id: source.companyId.toPrimitive(),
// Flags / estado / serie / número
status: source.status.toPrimitive(),
proforma_reference: source.proformaReference.toPrimitive(),
proforma_date: source.invoiceDate.toPrimitive(),
@ -242,36 +241,49 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
target_invoice_series_code: maybeToNullable(source.series, (v) => v.toPrimitive()),
language_code: source.languageCode.toPrimitive(),
currency_code: source.currencyCode.toPrimitive(),
reference: maybeToNullable(source.reference, (reference) => reference),
description: maybeToNullable(source.description, (description) => description),
notes: maybeToNullable(source.notes, (v) => v.toPrimitive()),
payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()),
payment_term_id: null,
tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value),
subtotal_amount_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale,
items_discount_amount_value: allAmounts.itemsDiscountAmount.value,
items_discount_amount_scale: allAmounts.itemsDiscountAmount.scale,
global_discount_percentage_value: source.globalDiscountPercentage.toPrimitive().value,
global_discount_percentage_scale: source.globalDiscountPercentage.toPrimitive().scale,
global_discount_amount_value: allAmounts.globalDiscountAmount.value,
global_discount_amount_scale: allAmounts.globalDiscountAmount.scale,
total_discount_amount_value: allAmounts.totalDiscountAmount.value,
total_discount_amount_scale: allAmounts.totalDiscountAmount.scale,
taxable_amount_value: allAmounts.taxableAmount.value,
taxable_amount_scale: allAmounts.taxableAmount.scale,
iva_amount_value: allAmounts.ivaAmount.value,
iva_amount_scale: allAmounts.ivaAmount.scale,
rec_amount_value: allAmounts.recAmount.value,
rec_amount_scale: allAmounts.recAmount.scale,
retention_amount_value: allAmounts.retentionAmount.value,
retention_amount_scale: allAmounts.retentionAmount.scale,
taxes_amount_value: allAmounts.taxesAmount.value,
taxes_amount_scale: allAmounts.taxesAmount.scale,
total_amount_value: allAmounts.totalAmount.value,
total_amount_scale: allAmounts.totalAmount.scale,
customer_id: source.customerId.toPrimitive(),
...recipient,
taxes: taxesResult.data,
items: itemsResult.data,
} satisfies ProformaCreationAttributes);

View File

@ -11,7 +11,6 @@ import {
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableResult,
maybeToNullable,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
@ -28,44 +27,49 @@ export class SequelizeProformaV2RecipientDomainMapper {
parent: Partial<IProformaCreateProps>;
};
if (!source.customer_name) {
return Result.ok(Maybe.none());
}
const _name = source.current_customer.name;
const _tin = source.current_customer.tin;
const _street = source.current_customer.street;
const _street2 = source.current_customer.street2;
const _city = source.current_customer.city;
const _postal_code = source.current_customer.postal_code;
const _province = source.current_customer.province;
const _country = source.current_customer.country;
const customerName = extractOrPushError(Name.create(source.customer_name), "customer_name", errors);
const customerName = extractOrPushError(Name.create(_name), "current_customer.name", errors);
const customerTin = extractOrPushError(
maybeFromNullableResult(source.customer_tin, (value) => TINNumber.create(value)),
"customer_tin",
maybeFromNullableResult(_tin, (value) => TINNumber.create(value)),
"current_customer.tin",
errors
);
const customerStreet = extractOrPushError(
maybeFromNullableResult(source.customer_street, (value) => Street.create(value)),
"customer_street",
maybeFromNullableResult(_street, (value) => Street.create(value)),
"current_customer.street",
errors
);
const customerStreet2 = extractOrPushError(
maybeFromNullableResult(source.customer_street2, (value) => Street.create(value)),
"customer_street2",
maybeFromNullableResult(_street2, (value) => Street.create(value)),
"current_customer.street2",
errors
);
const customerCity = extractOrPushError(
maybeFromNullableResult(source.customer_city, (value) => City.create(value)),
"customer_city",
maybeFromNullableResult(_city, (value) => City.create(value)),
"current_customer.city",
errors
);
const customerProvince = extractOrPushError(
maybeFromNullableResult(source.customer_province, (value) => Province.create(value)),
"customer_province",
maybeFromNullableResult(_province, (value) => Province.create(value)),
"current_customer.province",
errors
);
const customerPostalCode = extractOrPushError(
maybeFromNullableResult(source.customer_postal_code, (value) => PostalCode.create(value)),
"customer_postal_code",
maybeFromNullableResult(_postal_code, (value) => PostalCode.create(value)),
"current_customer.postal_code",
errors
);
const customerCountry = extractOrPushError(
maybeFromNullableResult(source.customer_country, (value) => Country.create(value)),
"customer_country",
maybeFromNullableResult(_country, (value) => Country.create(value)),
"current_customer.country",
errors
);
@ -92,7 +96,8 @@ export class SequelizeProformaV2RecipientDomainMapper {
}
mapToPersistence(source: Maybe<ProformaRecipient>) {
if (source.isNone()) {
return Result.ok();
/*if (source.isNone()) {
return {
customer_tin: null,
customer_name: null,
@ -117,5 +122,6 @@ export class SequelizeProformaV2RecipientDomainMapper {
customer_postal_code: maybeToNullable(recipient.postalCode, (value) => value.toPrimitive()),
customer_country: maybeToNullable(recipient.country, (value) => value.toPrimitive()),
};
*/
}
}

View File

@ -203,6 +203,8 @@ export class SequelizeProformaRepositoryV2
transaction: Transaction,
options: FindOptions<InferAttributes<ProformaModel>> = {}
): Promise<Result<Proforma, Error>> {
const { CustomerModel } = this.database.models;
try {
const normalizedOrder = Array.isArray(options.order)
? options.order
@ -225,6 +227,7 @@ export class SequelizeProformaRepositoryV2
order: [...normalizedOrder, [{ model: ProformaItemModel, as: "items" }, "position", "ASC"]],
include: [
...normalizedInclude,
{ model: CustomerModel, as: "current_customer", required: false },
{ model: ProformaItemModel, as: "items", required: false },
{ model: ProformaTaxModel, as: "taxes", required: false },
{ model: IssuedInvoiceModel, as: "linked_invoice", required: false, attributes: ["id"] },

View File

@ -21,7 +21,6 @@ import {
export const GetIssuedInvoiceByIdResponseSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
is_proforma: z.boolean(),
invoice_number: z.string(),
status: IssuedInvoiceStatusSchema,

View File

@ -1,50 +1,9 @@
import {
CurrencyCodeSchema,
IsoDateSchema,
LanguageCodeSchema,
MoneySchema,
createPaginatedListSchema,
} from "@erp/core";
import { z } from "zod/v4";
import { createPaginatedListSchema } from "@erp/core";
import type { z } from "zod/v4";
import {
IssuedInvoiceRecipientSummarySchema,
IssuedInvoiceStatusSchema,
VerifactuRecordSchema,
} from "../../shared";
import { IssuedInvoiceSummarySchema } from "../../shared";
export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema(
z.object({
id: z.uuid(),
company_id: z.uuid(),
is_proforma: z.boolean(),
invoice_number: z.string(),
status: IssuedInvoiceStatusSchema,
series: z.string(),
invoice_date: IsoDateSchema,
operation_date: IsoDateSchema.nullable(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
reference: z.string().nullable(),
description: z.string(),
customer_id: z.uuid(),
recipient: IssuedInvoiceRecipientSummarySchema,
subtotal_amount: MoneySchema,
total_discount_amount: MoneySchema,
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
verifactu: VerifactuRecordSchema,
linked_proforma_id: z.uuid().nullable(),
})
IssuedInvoiceSummarySchema
);
export type ListIssuedInvoicesResponseDTO = z.infer<typeof ListIssuedInvoicesResponseSchema>;

View File

@ -1,4 +1,5 @@
export * from "./issued-invoice-item-detail.dto";
export * from "./issued-invoice-recipient-summary.dto";
export * from "./issued-invoice-status.dto";
export * from "./issued-invoice-summary.dto";
export * from "./verifactu-record.dto";

View File

@ -0,0 +1,39 @@
import { CurrencyCodeSchema, IsoDateSchema, LanguageCodeSchema, MoneySchema } from "@erp/core";
import { z } from "zod/v4";
import { IssuedInvoiceRecipientSummarySchema } from "./issued-invoice-recipient-summary.dto";
import { IssuedInvoiceStatusSchema } from "./issued-invoice-status.dto";
import { VerifactuRecordSchema } from "./verifactu-record.dto";
export const IssuedInvoiceSummarySchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
invoice_number: z.string(),
status: IssuedInvoiceStatusSchema,
series: z.string(),
invoice_date: IsoDateSchema,
operation_date: IsoDateSchema.nullable(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
reference: z.string().nullable(),
description: z.string().nullable(),
customer_id: z.uuid(),
recipient: IssuedInvoiceRecipientSummarySchema,
subtotal_amount: MoneySchema,
total_discount_amount: MoneySchema,
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
verifactu: VerifactuRecordSchema,
linked_proforma_id: z.uuid().nullable(),
});
export type IssuedInvoiceSummaryDTO = z.infer<typeof IssuedInvoiceSummarySchema>;

View File

@ -1,3 +1,4 @@
import { getUserErrorMessage } from "@erp/core/client";
import { useDebounce } from "@erp/core/hooks";
import { useMemo, useState } from "react";
@ -37,13 +38,15 @@ export const useIssuedInvoiceListController = () => {
filters:
verifactuStatusFilter === "all"
? []
: [{ field: "verifactu.status", operator: "eq", value: verifactuStatusFilter }],
: [{ field: "verifactu.status", operator: "EQUALS", value: verifactuStatusFilter }],
}),
[debouncedSearch, pageIndex, pageSize, verifactuStatusFilter]
);
const query = useIssuedInvoiceListQuery({ criteria });
const errorMessage = query.error ? getUserErrorMessage(query.error) : null;
const setStatusFilterValue = (value: string) => {
const nextValue = (value || "all") as VerifactuRecordListStatusFilter;
@ -88,8 +91,8 @@ export const useIssuedInvoiceListController = () => {
isError: query.isError,
error: query.error,
refetch: query.refetch,
errorMessage,
retry: query.refetch,
pageIndex,
pageSize,

View File

@ -1,5 +1,5 @@
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { AppContent, BackHistoryButton, LogoVerifactu } from "@repo/rdx-ui/components";
import { ErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { LogoVerifactu } from "@repo/rdx-ui/components";
import {
Alert,
AlertDescription,
@ -44,8 +44,16 @@ export const ListIssuedInvoicesPage = () => {
widthClass: "w-[500px]",
});*/
if (listCtrl.isError) {
if (listCtrl.isError && listCtrl.errorMessage) {
return (
<ErrorState
description={listCtrl.errorMessage.description}
onRetry={listCtrl.retry}
reference={listCtrl.errorMessage.reference}
title={listCtrl.errorMessage.title}
/>
);
/*return (
<AppContent>
<ErrorAlert
message={(listCtrl.error as Error)?.message || "Error al cargar el listado"}
@ -53,7 +61,7 @@ export const ListIssuedInvoicesPage = () => {
/>
<BackHistoryButton />
</AppContent>
);
);*/
}
return (

View File

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

View File

@ -1,3 +0,0 @@
export * from "../../../list/ui/blocks/issued-invoices-grid/use-issued-invoices-grid-columns";
export * from "./use-issued-invoices-list";

View File

@ -1,52 +0,0 @@
// src/modules/issued-invoices/hooks/use-proformas-list.ts
import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@erp/core/hooks";
import { useMemo, useState } from "react";
import { useIssuedInvoicesQuery } from "../../../hooks";
export const useIssuedInvoicesList = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("all");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo<CriteriaDTO>(() => {
const baseFilters =
status === "all" ? [] : [{ field: "status", operator: "CONTAINS", value: status }];
return {
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
order: "desc",
orderBy: "invoice_date",
filters: baseFilters,
};
}, [pageSize, pageIndex, debouncedQ, status]);
const query = useIssuedInvoicesQuery({ criteria });
const data = useMemo(
() => (query.data ? IssuedInvoiceSummaryDtoAdapter.fromDto(query.data) : undefined),
[query.data]
);
const setSearchValue = (value: string) => setSearch(value.trim().replace(/\s+/g, " "));
const setStatusFilter = (newStatus: string) => setStatus(newStatus);
return {
...query,
data,
pageIndex,
pageSize,
search,
setPageIndex,
setPageSize,
setSearchValue,
setStatusFilter,
};
};

View File

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

View File

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

View File

@ -1,108 +0,0 @@
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceSummaryPageData } from "../../../schema/issued-invoice-summary.web.schema";
import { useIssuedInvoicesGridColumns } from "../hooks";
interface IssuedInvoicesGridProps {
data: IssuedInvoiceSummaryPageData;
loading?: boolean;
pageIndex: number;
pageSize: number;
searchValue: string;
onSearchChange: (v: string) => void;
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
onRowClick?: (id: string) => void;
onExportClick?: () => void;
}
export const IssuedInvoicesGrid = ({
data,
loading,
pageIndex,
pageSize,
searchValue,
onSearchChange,
onPageChange,
onPageSizeChange,
onRowClick,
onExportClick,
}: IssuedInvoicesGridProps) => {
const { t } = useTranslation();
const { items, total_items } = data;
const columns = useIssuedInvoicesGridColumns({
onDownloadPdf: (invoice) => null, //downloadInvoicePdf(inv.id),
onSendEmail: (invoice) => null, //sendInvoiceEmail(inv.id),
});
if (loading)
return (
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4">
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
<Select defaultValue="all">
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.issued_invoices.status.all")}</SelectItem>
<SelectItem value="pendiente">
{t("catalog.issued_invoices.status.pendiente")}
</SelectItem>
<SelectItem value="aceptado_con_error">
{t("catalog.issued_invoices.status.aceptado_con_error")}
</SelectItem>
<SelectItem value="incorrecto">
{t("catalog.issued_invoices.status.incorrecto")}
</SelectItem>
<SelectItem value="duplicado">
{t("catalog.issued_invoices.status.duplicado")}
</SelectItem>
<SelectItem value="anulado">{t("catalog.issued_invoices.status.anulado")}</SelectItem>
<SelectItem value="factura_inexistente">
{t("catalog.issued_invoices.status.factura_inexistente")}
</SelectItem>
<SelectItem value="rechazado">
{t("catalog.issued_invoices.status.rechazado")}
</SelectItem>
<SelectItem value="error">{t("catalog.issued_invoices.status.error")}</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
data={items}
enablePagination
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={(row, _index) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
/>
</div>
);
};

View File

@ -1,4 +1,5 @@
import type { CriteriaDTO } from "@erp/core";
import { isApiClientError } from "@erp/core/client";
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, useQuery } from "@tanstack/react-query";
@ -27,5 +28,21 @@ export const useIssuedInvoiceListQuery = (options?: IssuedInvoicesListQueryOptio
},
enabled,
placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria
retry: (failureCount, error) => {
if (!isApiClientError(error)) {
return failureCount < 1;
}
if (
error.kind === "validation" ||
error.kind === "forbidden" ||
error.kind === "not-found" ||
error.kind === "conflict"
) {
return false;
}
return failureCount < 2;
},
});
};

View File

@ -38,8 +38,6 @@ export const buildUpdateProformaByIdParams = (
throw new Error("proformaId is required");
}
console.log(patch);
const data: UpdateProformaByIdParams["data"] = {};
if (ObjectHelper.hasOwn(patch, "series")) {

View File

@ -132,7 +132,6 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
});
toPatchField(dto.tax_regime_code).ifSetOrNull((taxRegimeCode) => {
console.log("Hay taxRegimeCode", taxRegimeCode);
customerPatchProps.taxRegimeCode = extractOrPushError(
maybeFromNullableResult(taxRegimeCode, (value) => Result.ok(String(value))),
"tax_regime_code",
@ -162,9 +161,6 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
this.throwIfValidationErrors(errors);
console.log("dto => ", dto);
console.log("customerPatchProps => ", customerPatchProps);
return Result.ok(customerPatchProps);
} catch (err: unknown) {
console.error(err);

View File

@ -68,8 +68,6 @@ export class CustomerRepository
const { id, ...updatePayload } = dtoResult.data;
console.log(dtoResult.data);
const [affected] = await CustomerModel.update(updatePayload, {
where: { id /*, version */ },
transaction,

View File

@ -99,9 +99,7 @@ export const useCreateCustomerController = (options?: UseCustomerCreateControlle
}
options?.onCreated?.(created);
} catch (error: unknown) {
console.log(error);
const normalizedError = normalizeSubmitError(error);
console.debug(normalizedError);
// No revierto el form para que no se pierdan los cambios que
// ha hecho el usuario y no han sido guardados.

View File

@ -617,6 +617,9 @@ importers:
'@erp/catalogs':
specifier: workspace:*
version: link:../catalogs
'@erp/companies':
specifier: workspace:*
version: link:../companies
'@erp/core':
specifier: workspace:*
version: link:../core