Compare commits

...

5 Commits

Author SHA1 Message Date
c8eff4e9fc Proceso de paso de proforma a factura 2025-11-06 17:17:28 +01:00
2c84dc26bd Gestión de errores en módulos de servidor 2025-11-06 17:17:00 +01:00
55983f0295 Arreglado cálculo de totales de factura 2025-11-06 17:16:18 +01:00
5c51093b1d Formateo 2025-11-06 17:15:17 +01:00
d60b8276f6 Gestión global de errores 2025-11-06 17:14:49 +01:00
32 changed files with 427 additions and 252 deletions

View File

@ -1,3 +1,4 @@
import { globalErrorHandler } from "@erp/core/api";
import cors, { CorsOptions } from "cors"; import cors, { CorsOptions } from "cors";
import express, { Application } from "express"; import express, { Application } from "express";
import helmet from "helmet"; import helmet from "helmet";
@ -86,5 +87,10 @@ export function createApp(): Application {
next(); next();
}); });
// Gestión global de errores.
// Siempre al final de la cadena de middlewares
// y después de las rutas.
app.use(globalErrorHandler);
return app; return app;
} }

View File

@ -18,8 +18,3 @@ export const v1Routes = (): Router => {
return routes; return routes;
}; };
// Gestión global de errores.
// Siempre al final de la cadena de middlewares
// y después de las rutas.
//app.use(globalErrorHandler);

View File

@ -1,9 +1,7 @@
import { loggerSingleton } from "@repo/rdx-logger";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { logger } from "../../helpers";
import { ITransactionManager } from "./transaction-manager.interface"; import { ITransactionManager } from "./transaction-manager.interface";
const logger = loggerSingleton();
export abstract class TransactionManager implements ITransactionManager { export abstract class TransactionManager implements ITransactionManager {
protected _transaction: unknown | null = null; protected _transaction: unknown | null = null;
protected _isCompleted = false; protected _isCompleted = false;

View File

@ -13,9 +13,9 @@
import { import {
DomainValidationError, DomainValidationError,
ValidationErrorCollection,
isDomainValidationError, isDomainValidationError,
isValidationErrorCollection, isValidationErrorCollection,
ValidationErrorCollection,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { import {

View File

@ -1,4 +1,5 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { logger } from "../../../helpers";
import { ApiErrorContext, ApiErrorMapper, toProblemJson } from "../api-error-mapper"; import { ApiErrorContext, ApiErrorMapper, toProblemJson } from "../api-error-mapper";
// ✅ Construye tu mapper una vez (composition root del adaptador HTTP) // ✅ Construye tu mapper una vez (composition root del adaptador HTTP)
@ -10,7 +11,7 @@ export const globalErrorHandler = async (
res: Response, res: Response,
next: NextFunction next: NextFunction
) => { ) => {
console.error(`❌ Global unhandled error: ${error.message}`); //console.error(`❌ Global unhandled error: ${error.message}`);
// Si ya se envió una respuesta, delegamos al siguiente error handler // Si ya se envió una respuesta, delegamos al siguiente error handler
if (res.headersSent) { if (res.headersSent) {
@ -26,7 +27,9 @@ export const globalErrorHandler = async (
const body = toProblemJson(apiError, ctx); const body = toProblemJson(apiError, ctx);
// 👇 Log interno con cause/traza (no lo exponemos al cliente) // 👇 Log interno con cause/traza (no lo exponemos al cliente)
// logger.error({ err, cause: (err as any)?.cause, ...ctx }, `❌ Unhandled API error: ${error.message}`); logger.error(`❌ Global unhandled API error: ${error.message}`, {
label: "globalErrorHandler",
});
res.status(apiError.status).json(body); res.status(apiError.status).json(body);
}; };

View File

@ -69,10 +69,11 @@ export class CustomerInvoiceFullPresenter extends Presenter<
payment_method: payment, payment_method: payment,
subtotal_amount: allAmounts.subtotalAmount.toObjectString(), subtotal_amount: allAmounts.subtotalAmount.toObjectString(),
items_discount_amount: allAmounts.itemDiscountAmount.toObjectString(),
discount_percentage: invoice.discountPercentage.toObjectString(), discount_percentage: invoice.discountPercentage.toObjectString(),
discount_amount: allAmounts.headerDiscountAmount.toObjectString(),
discount_amount: allAmounts.discountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(), taxable_amount: allAmounts.taxableAmount.toObjectString(),
taxes_amount: allAmounts.taxesAmount.toObjectString(), taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(), total_amount: allAmounts.totalAmount.toObjectString(),

View File

@ -1,8 +1,8 @@
import { ITransactionManager } from "@erp/core/api"; import { ITransactionManager } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd"; import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { InvalidProformaStatusError } from "../../domain"; import { ProformaCannotBeConvertedToInvoiceError } from "../../domain";
import { StatusInvoiceIsApprovedSpecification } from "../../domain/specs"; import { ProformaCanTranstionToIssuedSpecification } from "../../domain/specs";
import { CustomerInvoiceApplicationService } from "../customer-invoice-application.service"; import { CustomerInvoiceApplicationService } from "../customer-invoice-application.service";
type IssueCustomerInvoiceUseCaseInput = { type IssueCustomerInvoiceUseCaseInput = {
@ -52,9 +52,9 @@ export class IssueCustomerInvoiceUseCase {
const proforma = proformaResult.data; const proforma = proformaResult.data;
/** 2. Comprobamos que la proforma origen está aprovada para generar la factura */ /** 2. Comprobamos que la proforma origen está aprovada para generar la factura */
const isApprovedSpec = new StatusInvoiceIsApprovedSpecification(); const isOk = new ProformaCanTranstionToIssuedSpecification();
if (!(await isApprovedSpec.isSatisfiedBy(proforma))) { if (!(await isOk.isSatisfiedBy(proforma))) {
return Result.fail(new InvalidProformaStatusError(proformaId.toString())); return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proformaId.toString()));
} }
/** 3. Generar nueva factura */ /** 3. Generar nueva factura */
@ -71,7 +71,7 @@ export class IssueCustomerInvoiceUseCase {
// props base obtenidas del agregado proforma // props base obtenidas del agregado proforma
const issuedInvoiceOrError = this.service.buildIssueInvoiceInCompany(companyId, proforma, { const issuedInvoiceOrError = this.service.buildIssueInvoiceInCompany(companyId, proforma, {
invoiceNumber: Maybe.some(newIssueNumber), invoiceNumber: newIssueNumber,
invoiceDate: UtcDate.today(), invoiceDate: UtcDate.today(),
}); });

View File

@ -60,11 +60,13 @@ export type CustomerInvoicePatchProps = Partial<
}; };
export interface ICustomerInvoice { export interface ICustomerInvoice {
canTransitionTo(nextStatus: string): boolean;
hasRecipient: boolean; hasRecipient: boolean;
hasPaymentMethod: boolean; hasPaymentMethod: boolean;
getSubtotalAmount(): InvoiceAmount; _getSubtotalAmount(): InvoiceAmount;
getDiscountAmount(): InvoiceAmount; getHeaderDiscountAmount(): InvoiceAmount;
getTaxableAmount(): InvoiceAmount; getTaxableAmount(): InvoiceAmount;
getTaxesAmount(): InvoiceAmount; getTaxesAmount(): InvoiceAmount;
@ -92,6 +94,18 @@ export class CustomerInvoice
currencyCode: props.currencyCode, currencyCode: props.currencyCode,
}); });
} }
getHeaderDiscountAmount(): InvoiceAmount {
throw new Error("Method not implemented.");
}
getTaxableAmount(): InvoiceAmount {
throw new Error("Method not implemented.");
}
getTaxesAmount(): InvoiceAmount {
throw new Error("Method not implemented.");
}
getTotalAmount(): InvoiceAmount {
throw new Error("Method not implemented.");
}
static create(props: CustomerInvoiceProps, id?: UniqueID): Result<CustomerInvoice, Error> { static create(props: CustomerInvoiceProps, id?: UniqueID): Result<CustomerInvoice, Error> {
const customerInvoice = new CustomerInvoice(props, id); const customerInvoice = new CustomerInvoice(props, id);
@ -155,6 +169,10 @@ export class CustomerInvoice
return this.props.status; return this.props.status;
} }
canTransitionTo(nextStatus: string): boolean {
return this.props.status.canTransitionTo(nextStatus);
}
public get series(): Maybe<CustomerInvoiceSerie> { public get series(): Maybe<CustomerInvoiceSerie> {
return this.props.series; return this.props.series;
} }
@ -216,15 +234,19 @@ export class CustomerInvoice
return this.paymentMethod.isSome(); return this.paymentMethod.isSome();
} }
private _getDiscountAmount(subtotalAmount: InvoiceAmount): InvoiceAmount { private _getHeaderDiscountAmount(
return subtotalAmount.percentage(this.discountPercentage); subtotalAmount: InvoiceAmount,
itemsDiscountAmount: InvoiceAmount
): InvoiceAmount {
return subtotalAmount.subtract(itemsDiscountAmount).percentage(this.discountPercentage);
} }
private _getTaxableAmount( private _getTaxableAmount(
subtotalAmount: InvoiceAmount, subtotalAmount: InvoiceAmount,
discountAmount: InvoiceAmount itemsDiscountAmount: InvoiceAmount,
headerDiscountAmount: InvoiceAmount
): InvoiceAmount { ): InvoiceAmount {
return subtotalAmount.subtract(discountAmount); return subtotalAmount.subtract(itemsDiscountAmount).subtract(headerDiscountAmount);
} }
private _getTaxesAmount(taxableAmount: InvoiceAmount): InvoiceAmount { private _getTaxesAmount(taxableAmount: InvoiceAmount): InvoiceAmount {
@ -247,7 +269,7 @@ export class CustomerInvoice
return taxableAmount.add(taxesAmount); return taxableAmount.add(taxesAmount);
} }
public getSubtotalAmount(): InvoiceAmount { public _getSubtotalAmount(): InvoiceAmount {
const itemsSubtotal = this.items.getSubtotalAmount().convertScale(2); const itemsSubtotal = this.items.getSubtotalAmount().convertScale(2);
return InvoiceAmount.create({ return InvoiceAmount.create({
@ -256,12 +278,21 @@ export class CustomerInvoice
}).data as InvoiceAmount; }).data as InvoiceAmount;
} }
public getDiscountAmount(): InvoiceAmount { public _getItemsDiscountAmount(): InvoiceAmount {
return this._getDiscountAmount(this.getSubtotalAmount()); const itemsDiscountAmount = this.items.getDiscountAmount().convertScale(2);
return InvoiceAmount.create({
value: itemsDiscountAmount.value,
currency_code: this.currencyCode.code,
}).data as InvoiceAmount;
}
/*public getHeaderDiscountAmount(): InvoiceAmount {
return this._getHeaderDiscountAmount(this.getSubtotalAmount());
} }
public getTaxableAmount(): InvoiceAmount { public getTaxableAmount(): InvoiceAmount {
return this._getTaxableAmount(this.getSubtotalAmount(), this.getDiscountAmount()); return this._getTaxableAmount(this.getSubtotalAmount(), this.getHeaderDiscountAmount());
} }
public getTaxesAmount(): InvoiceAmount { public getTaxesAmount(): InvoiceAmount {
@ -273,7 +304,7 @@ export class CustomerInvoice
const taxesAmount = this._getTaxesAmount(taxableAmount); const taxesAmount = this._getTaxesAmount(taxableAmount);
return this._getTotalAmount(taxableAmount, taxesAmount); return this._getTotalAmount(taxableAmount, taxesAmount);
} }*/
public getTaxes(): InvoiceTaxTotal[] { public getTaxes(): InvoiceTaxTotal[] {
const itemTaxes = this.items.getTaxesAmountByTaxes(); const itemTaxes = this.items.getTaxesAmountByTaxes();
@ -294,15 +325,23 @@ export class CustomerInvoice
} }
public getAllAmounts() { public getAllAmounts() {
const subtotalAmount = this.getSubtotalAmount(); const subtotalAmount = this._getSubtotalAmount(); // Sin IVA ni dtos de línea
const discountAmount = this._getDiscountAmount(subtotalAmount); const itemDiscountAmount = this._getItemsDiscountAmount(); // Suma de los Importes de descuentos de linea
const taxableAmount = this._getTaxableAmount(subtotalAmount, discountAmount); const headerDiscountAmount = this._getHeaderDiscountAmount(subtotalAmount, itemDiscountAmount); // Importe de descuento de cabecera
const taxableAmount = this._getTaxableAmount(
subtotalAmount,
itemDiscountAmount,
headerDiscountAmount
); //
const taxesAmount = this._getTaxesAmount(taxableAmount); const taxesAmount = this._getTaxesAmount(taxableAmount);
const totalAmount = this._getTotalAmount(taxableAmount, taxesAmount); const totalAmount = this._getTotalAmount(taxableAmount, taxesAmount);
return { return {
subtotalAmount, subtotalAmount,
discountAmount, itemDiscountAmount,
headerDiscountAmount,
taxableAmount, taxableAmount,
taxesAmount, taxesAmount,
totalAmount, totalAmount,

View File

@ -53,7 +53,7 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
*/ */
public getSubtotalAmount(): ItemAmount { public getSubtotalAmount(): ItemAmount {
return this.getAll().reduce( return this.getAll().reduce(
(total, tax) => total.add(tax.getSubtotalAmount()), (total, item) => total.add(item.getSubtotalAmount()),
ItemAmount.zero(this._currencyCode.code) ItemAmount.zero(this._currencyCode.code)
); );
} }

View File

@ -1,2 +1,2 @@
export * from "./customer-invoice-id-already-exits-error"; export * from "./customer-invoice-id-already-exits-error";
export * from "./invalid-proforma-status-error"; export * from "./proforma-cannot-be-converted-to-invoice-error";

View File

@ -1,11 +0,0 @@
import { DomainError } from "@repo/rdx-ddd";
export class InvalidProformaStatusError extends DomainError {
constructor(id: string, options?: ErrorOptions) {
super(`Error. Proforma with id '${id}' has invalid status.`, options);
this.name = "InvalidProformaStatusError";
}
}
export const isInvalidProformaStatusError = (e: unknown): e is InvalidProformaStatusError =>
e instanceof InvalidProformaStatusError;

View File

@ -0,0 +1,33 @@
import { DomainError } from "@repo/rdx-ddd";
/**
* Error de dominio que indica que una Proforma no puede convertirse a Factura (issue).
*
* @remarks
* - Se lanza cuando el flujo de emisión (issue) desde una Proforma no es válido
*
* @public
*/
export class ProformaCannotBeConvertedToInvoiceError extends DomainError {
/**
* Crea una instancia del error con el identificador de la Proforma.
*
* @param id - Identificador de la Proforma.
* @param options - Opciones nativas de Error (puedes pasar `cause`).
*/
constructor(id: string, options?: ErrorOptions) {
super(`Error. Proforma with id '${id}' cannot be converted to an Invoice.`, options);
this.name = "ProformaCannotBeConvertedToInvoiceError";
}
}
/**
* *Type guard* para `ProformaCannotBeConvertedToInvoiceError`.
*
* @param e - Error desconocido
* @returns `true` si `e` es `ProformaCannotBeConvertedToInvoiceError`
*/
export const isProformaCannotBeConvertedToInvoiceError = (
e: unknown
): e is ProformaCannotBeConvertedToInvoiceError =>
e instanceof ProformaCannotBeConvertedToInvoiceError;

View File

@ -1 +1 @@
export * from "./status-invoice-is-approved.specification"; export * from "./proforma-can-transtion-to-issued.specification";

View File

@ -0,0 +1,9 @@
import { CompositeSpecification } from "@repo/rdx-ddd";
import { CustomerInvoice } from "../aggregates";
import { INVOICE_STATUS } from "../value-objects";
export class ProformaCanTranstionToIssuedSpecification extends CompositeSpecification<CustomerInvoice> {
public async isSatisfiedBy(proforma: CustomerInvoice): Promise<boolean> {
return proforma.isProforma && proforma.canTransitionTo(INVOICE_STATUS.ISSUED);
}
}

View File

@ -1,8 +0,0 @@
import { CompositeSpecification } from "@repo/rdx-ddd";
import { CustomerInvoice } from "../aggregates";
export class StatusInvoiceIsApprovedSpecification extends CompositeSpecification<CustomerInvoice> {
public async isSatisfiedBy(invoice: CustomerInvoice): Promise<boolean> {
return invoice.status.isApproved();
}
}

View File

@ -11,9 +11,8 @@ export enum INVOICE_STATUS {
APPROVED = "approved", // <- Proforma APPROVED = "approved", // <- Proforma
REJECTED = "rejected", // <- Proforma REJECTED = "rejected", // <- Proforma
// status === issued <- (si is_proforma === true) => Es una proforma (histórica) // status === "issued" <- (si is_proforma === true) => Es una proforma (histórica)
// status === issued <- (si is_proforma === false) => Factura y enviará/enviada a Veri*Factu // status === "issued" <- (si is_proforma === false) => Factura y enviará/enviada a Veri*Factu
ISSUED = "issued", ISSUED = "issued",
} }
export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusProps> { export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusProps> {
@ -93,15 +92,6 @@ export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusPro
return CustomerInvoiceStatus.TRANSITIONS[this.props.value].includes(nextStatus); return CustomerInvoiceStatus.TRANSITIONS[this.props.value].includes(nextStatus);
} }
transitionTo(nextStatus: string): Result<CustomerInvoiceStatus, Error> {
if (!this.canTransitionTo(nextStatus)) {
return Result.fail(
new Error(`Transición no permitida de ${this.props.value} a ${nextStatus}`)
);
}
return CustomerInvoiceStatus.create(nextStatus);
}
toString() { toString() {
return String(this.props.value); return String(this.props.value);
} }

View File

@ -1,11 +1,14 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto"; import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto";
import { CreateCustomerInvoiceUseCase } from "../../../application"; import { CreateCustomerInvoiceUseCase } from "../../../application";
import { customerInvoicesApiErrorMapper } from "../customer-invoices-api-error-mapper";
export class CreateCustomerInvoiceController extends ExpressController { export class CreateCustomerInvoiceController extends ExpressController {
public constructor(private readonly useCase: CreateCustomerInvoiceUseCase) { public constructor(private readonly useCase: CreateCustomerInvoiceUseCase) {
super(); super();
this.errorMapper = customerInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,5 +1,6 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { DeleteCustomerInvoiceUseCase } from "../../../application"; import { DeleteCustomerInvoiceUseCase } from "../../../application";
import { customerInvoicesApiErrorMapper } from "../customer-invoices-api-error-mapper";
export class DeleteCustomerInvoiceController extends ExpressController { export class DeleteCustomerInvoiceController extends ExpressController {
public constructor( public constructor(
@ -7,6 +8,8 @@ export class DeleteCustomerInvoiceController extends ExpressController {
/* private readonly presenter: any */ /* private readonly presenter: any */
) { ) {
super(); super();
this.errorMapper = customerInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,9 +1,12 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { GetCustomerInvoiceUseCase } from "../../../application"; import { GetCustomerInvoiceUseCase } from "../../../application";
import { customerInvoicesApiErrorMapper } from "../customer-invoices-api-error-mapper";
export class GetCustomerInvoiceController extends ExpressController { export class GetCustomerInvoiceController extends ExpressController {
public constructor(private readonly useCase: GetCustomerInvoiceUseCase) { public constructor(private readonly useCase: GetCustomerInvoiceUseCase) {
super(); super();
this.errorMapper = customerInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,9 +1,12 @@
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { IssueCustomerInvoiceUseCase } from "../../../application"; import { IssueCustomerInvoiceUseCase } from "../../../application";
import { customerInvoicesApiErrorMapper } from "../customer-invoices-api-error-mapper";
export class IssueCustomerInvoiceController extends ExpressController { export class IssueCustomerInvoiceController extends ExpressController {
public constructor(private readonly useCase: IssueCustomerInvoiceUseCase) { public constructor(private readonly useCase: IssueCustomerInvoiceUseCase) {
super(); super();
this.errorMapper = customerInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,10 +1,13 @@
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { Criteria } from "@repo/rdx-criteria/server"; import { Criteria } from "@repo/rdx-criteria/server";
import { ListCustomerInvoicesUseCase } from "../../../application"; import { ListCustomerInvoicesUseCase } from "../../../application";
import { customerInvoicesApiErrorMapper } from "../customer-invoices-api-error-mapper";
export class ListCustomerInvoicesController extends ExpressController { export class ListCustomerInvoicesController extends ExpressController {
public constructor(private readonly useCase: ListCustomerInvoicesUseCase) { public constructor(private readonly useCase: ListCustomerInvoicesUseCase) {
super(); super();
this.errorMapper = customerInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,9 +1,12 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { ReportCustomerInvoiceUseCase } from "../../../application"; import { ReportCustomerInvoiceUseCase } from "../../../application";
import { customerInvoicesApiErrorMapper } from "../customer-invoices-api-error-mapper";
export class ReportCustomerInvoiceController extends ExpressController { export class ReportCustomerInvoiceController extends ExpressController {
public constructor(private readonly useCase: ReportCustomerInvoiceUseCase) { public constructor(private readonly useCase: ReportCustomerInvoiceUseCase) {
super(); super();
this.errorMapper = customerInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,10 +1,13 @@
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { UpdateCustomerInvoiceByIdRequestDTO } from "../../../../common/dto"; import { UpdateCustomerInvoiceByIdRequestDTO } from "../../../../common/dto";
import { UpdateCustomerInvoiceUseCase } from "../../../application"; import { UpdateCustomerInvoiceUseCase } from "../../../application";
import { customerInvoicesApiErrorMapper } from "../customer-invoices-api-error-mapper";
export class UpdateCustomerInvoiceController extends ExpressController { export class UpdateCustomerInvoiceController extends ExpressController {
public constructor(private readonly useCase: UpdateCustomerInvoiceUseCase) { public constructor(private readonly useCase: UpdateCustomerInvoiceUseCase) {
super(); super();
this.errorMapper = customerInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,10 +1,17 @@
// Ejemplo: regla específica para Billing → InvoiceIdAlreadyExistsError // Ejemplo: regla específica para Billing → InvoiceIdAlreadyExistsError
// (si defines un error más ubicuo dentro del BC con su propia clase) // (si defines un error más ubicuo dentro del BC con su propia clase)
import { ApiErrorMapper, ConflictApiError, ErrorToApiRule } from "@erp/core/api"; import {
ApiErrorMapper,
ConflictApiError,
ErrorToApiRule,
ValidationApiError,
} from "@erp/core/api";
import { import {
CustomerInvoiceIdAlreadyExistsError, CustomerInvoiceIdAlreadyExistsError,
isCustomerInvoiceIdAlreadyExistsError, isCustomerInvoiceIdAlreadyExistsError,
isProformaCannotBeConvertedToInvoiceError,
ProformaCannotBeConvertedToInvoiceError,
} from "../../domain"; } from "../../domain";
// Crea una regla específica (prioridad alta para sobreescribir mensajes) // Crea una regla específica (prioridad alta para sobreescribir mensajes)
@ -18,6 +25,17 @@ const invoiceDuplicateRule: ErrorToApiRule = {
), ),
}; };
const proformaConversionRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isProformaCannotBeConvertedToInvoiceError(e),
build: (e) =>
new ValidationApiError(
(e as ProformaCannotBeConvertedToInvoiceError).message ||
"Proforma cannot be converted to an Invoice."
),
};
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra // Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const customerInvoicesApiErrorMapper: ApiErrorMapper = export const customerInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
ApiErrorMapper.default().register(invoiceDuplicateRule); .register(invoiceDuplicateRule)
.register(proformaConversionRule);

View File

@ -48,6 +48,7 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
.optional(), .optional(),
subtotal_amount: MoneySchema, subtotal_amount: MoneySchema,
items_discount_amount: MoneySchema,
discount_percentage: PercentageSchema, discount_percentage: PercentageSchema,
discount_amount: MoneySchema, discount_amount: MoneySchema,
taxable_amount: MoneySchema, taxable_amount: MoneySchema,

View File

@ -1,20 +1,19 @@
import { DataTableColumnHeader } from '@repo/rdx-ui/components'; import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import { InputGroup, InputGroupTextarea } from "@repo/shadcn-ui/components"; import { InputGroup, InputGroupTextarea } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import * as React from "react"; import * as React from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { useInvoiceContext } from '../../../context'; import { useInvoiceContext } from "../../../context";
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select'; import { CustomerInvoiceTaxesMultiSelect } from "../../customer-invoice-taxes-multi-select";
import { AmountInputField } from './amount-input-field'; import { AmountInputField } from "./amount-input-field";
import { HoverCardTotalsSummary } from './hover-card-total-summary'; import { HoverCardTotalsSummary } from "./hover-card-total-summary";
import { ItemDataTableRowActions } from './items-data-table-row-actions'; import { ItemDataTableRowActions } from "./items-data-table-row-actions";
import { PercentageInputField } from './percentage-input-field'; import { PercentageInputField } from "./percentage-input-field";
import { QuantityInputField } from './quantity-input-field'; import { QuantityInputField } from "./quantity-input-field";
export interface InvoiceItemFormData { export interface InvoiceItemFormData {
id: string; // ← mapea RHF field.id aquí id: string; // ← mapea RHF field.id aquí
description: string; description: string;
quantity: number | ""; quantity: number | "";
unit_amount: number | ""; unit_amount: number | "";
@ -22,53 +21,62 @@ export interface InvoiceItemFormData {
tax_codes: string[]; tax_codes: string[];
total_amount: number | ""; // readonly calculado total_amount: number | ""; // readonly calculado
} }
export interface InvoiceFormData { items: InvoiceItemFormData[] } export interface InvoiceFormData {
items: InvoiceItemFormData[];
}
export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] { export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
const { t, readOnly, currency_code, language_code } = useInvoiceContext(); const { t, readOnly, currency_code, language_code } = useInvoiceContext();
const { control } = useFormContext<InvoiceFormData>(); const { control } = useFormContext<InvoiceFormData>();
// Atención: Memoizar siempre para evitar reconstrucciones y resets de estado de tabla // Atención: Memoizar siempre para evitar reconstrucciones y resets de estado de tabla
return React.useMemo<ColumnDef<InvoiceItemFormData>[]>(() => [ return React.useMemo<ColumnDef<InvoiceItemFormData>[]>(
{ () => [
id: 'position', {
header: ({ column }) => ( id: "position",
<DataTableColumnHeader column={column} title={"#"} className='text-center' /> header: ({ column }) => (
), <DataTableColumnHeader column={column} title={"#"} className='text-center' />
cell: ({ row }) => row.index + 1, ),
enableSorting: false, cell: ({ row }) => row.index + 1,
size: 32, enableSorting: false,
}, size: 32,
{ },
accessorKey: "description", {
header: ({ column }) => ( accessorKey: "description",
<DataTableColumnHeader column={column} title={t("form_fields.item.description.label")} className='text-left' /> header: ({ column }) => (
), <DataTableColumnHeader
cell: ({ row }) => ( column={column}
<Controller title={t("form_fields.item.description.label")}
control={control} className='text-left'
name={`items.${row.index}.description`} />
render={({ field }) => ( ),
<InputGroup> cell: ({ row }) => (
<InputGroupTextarea {...field} <Controller
id={`desc-${row.original.id}`} // ← estable control={control}
rows={1} name={`items.${row.index}.description`}
aria-label={t("form_fields.item.description.label")} render={({ field }) => (
spellCheck <InputGroup>
readOnly={readOnly} <InputGroupTextarea
// auto-grow simple {...field}
onInput={(e) => { id={`desc-${row.original.id}`} // ← estable
const el = e.currentTarget; rows={1}
el.style.height = "auto"; aria-label={t("form_fields.item.description.label")}
el.style.height = `${el.scrollHeight}px`; spellCheck
}} readOnly={readOnly}
className={cn( // auto-grow simple
"min-w-[12rem] max-w-[46rem] w-full resize-none bg-transparent border-dashed transition", onInput={(e) => {
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid", const el = e.currentTarget;
"focus:resize-y" el.style.height = "auto";
)} el.style.height = `${el.scrollHeight}px`;
data-cell-focus /> }}
{/*<InputGroupAddon align="block-end"> className={cn(
"min-w-[12rem] max-w-[46rem] w-full resize-none bg-transparent border-dashed transition",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid",
"focus:resize-y"
)}
data-cell-focus
/>
{/*<InputGroupAddon align="block-end">
<InputGroupText>Line 1, Column 1</InputGroupText> <InputGroupText>Line 1, Column 1</InputGroupText>
<InputGroupButton <InputGroupButton
variant="default" variant="default"
@ -80,129 +88,186 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
<span className="sr-only">Send</span> <span className="sr-only">Send</span>
</InputGroupButton> </InputGroupButton>
</InputGroupAddon>*/} </InputGroupAddon>*/}
</InputGroup> </InputGroup>
)}
)} />
/> ),
), enableSorting: false,
enableSorting: false, size: 480,
size: 480, minSize: 240, maxSize: 768, minSize: 240,
}, maxSize: 768,
{ },
accessorKey: "quantity", {
header: ({ column }) => ( accessorKey: "quantity",
<DataTableColumnHeader column={column} title={t("form_fields.item.quantity.label")} className='text-right' /> header: ({ column }) => (
), <DataTableColumnHeader
cell: ({ row }) => ( column={column}
<QuantityInputField title={t("form_fields.item.quantity.label")}
control={control} className='text-right'
name={`items.${row.index}.quantity`} />
readOnly={readOnly} ),
inputId={`qty-${row.original.id}`} cell: ({ row }) => (
emptyMode="blank" <QuantityInputField
data-row-index={row.index} control={control}
data-col-index={4} name={`items.${row.index}.quantity`}
data-cell-focus readOnly={readOnly}
className="font-base" inputId={`qty-${row.original.id}`}
/> emptyMode='blank'
), data-row-index={row.index}
enableSorting: false, data-col-index={4}
size: 52, minSize: 48, maxSize: 64, data-cell-focus
}, className='font-base'
{ />
accessorKey: "unit_amount", ),
header: ({ column }) => ( enableSorting: false,
<DataTableColumnHeader column={column} title={t("form_fields.item.unit_amount.label")} className='text-right' /> size: 52,
), minSize: 48,
cell: ({ row }) => ( maxSize: 64,
<AmountInputField },
control={control} {
name={`items.${row.index}.unit_amount`} accessorKey: "unit_amount",
readOnly={readOnly} header: ({ column }) => (
inputId={`unit-${row.original.id}`} <DataTableColumnHeader
scale={4} column={column}
currencyCode={currency_code} title={t("form_fields.item.unit_amount.label")}
languageCode={language_code} className='text-right'
data-row-index={row.index} />
data-col-index={5} ),
data-cell-focus cell: ({ row }) => (
className="font-base"
/>
),
enableSorting: false,
size: 120, minSize: 100, maxSize: 160,
},
{
accessorKey: "discount_percentage",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.discount_percentage.label")} className='text-right' />
),
cell: ({ row }) => (
<PercentageInputField
control={control}
name={`items.${row.index}.discount_percentage`}
readOnly={readOnly}
inputId={`disc-${row.original.id}`}
scale={4}
data-row-index={row.index}
data-col-index={6}
data-cell-focus
className="font-base"
/>
),
enableSorting: false,
size: 40, minSize: 40
},
{
accessorKey: "tax_codes",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.tax_codes.label")} />
),
cell: ({ row }) => (
<Controller
control={control}
name={`items.${row.index}.tax_codes`}
render={({ field }) => (
<CustomerInvoiceTaxesMultiSelect
{...field}
inputId={`tax-${row.original.id}`}
data-row-index={row.index}
data-col-index={7}
data-cell-focus
/>
)}
/>
),
enableSorting: false,
size: 240, minSize: 232, maxSize: 320,
},
{
accessorKey: "total_amount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.total_amount.label")} className='text-right' />
),
cell: ({ row }) => (
<HoverCardTotalsSummary rowIndex={row.index}>
<AmountInputField <AmountInputField
control={control} control={control}
name={`items.${row.index}.total_amount`} name={`items.${row.index}.unit_amount`}
readOnly readOnly={readOnly}
inputId={`total-${row.original.id}`} inputId={`unit-${row.original.id}`}
scale={4}
currencyCode={currency_code} currencyCode={currency_code}
languageCode={language_code} languageCode={language_code}
className="font-semibold" data-row-index={row.index}
data-col-index={5}
data-cell-focus
className='font-base'
/> />
</HoverCardTotalsSummary> ),
), enableSorting: false,
enableSorting: false, size: 120,
size: 120, minSize: 100, maxSize: 160, minSize: 100,
}, maxSize: 160,
{ },
id: "actions", {
header: ({ column }) => ( accessorKey: "discount_percentage",
<DataTableColumnHeader column={column} title={t("components.datatable.actions")} /> header: ({ column }) => (
), <DataTableColumnHeader
cell: ({ row, table }) => <ItemDataTableRowActions row={row} table={table} />, column={column}
}, title={t("form_fields.item.discount_percentage.label")}
], [t, readOnly, control, currency_code, language_code,]); className='text-right'
/>
),
cell: ({ row }) => (
<PercentageInputField
control={control}
name={`items.${row.index}.discount_percentage`}
readOnly={readOnly}
inputId={`disc-${row.original.id}`}
scale={4}
data-row-index={row.index}
data-col-index={6}
data-cell-focus
className='font-base'
/>
),
enableSorting: false,
size: 40,
minSize: 40,
},
{
accessorKey: "taxable_amount",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("form_fields.item.taxable_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<AmountInputField
control={control}
name={`items.${row.index}.taxable_amount`}
readOnly={readOnly}
inputId={`unit-${row.original.id}`}
scale={4}
currencyCode={currency_code}
languageCode={language_code}
data-row-index={row.index}
data-col-index={5}
data-cell-focus
className='font-base'
/>
),
enableSorting: false,
size: 120,
minSize: 100,
maxSize: 160,
},
{
accessorKey: "tax_codes",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("form_fields.item.tax_codes.label")} />
),
cell: ({ row }) => (
<Controller
control={control}
name={`items.${row.index}.tax_codes`}
render={({ field }) => (
<CustomerInvoiceTaxesMultiSelect
{...field}
inputId={`tax-${row.original.id}`}
data-row-index={row.index}
data-col-index={7}
data-cell-focus
/>
)}
/>
),
enableSorting: false,
size: 120,
minSize: 130,
maxSize: 180,
},
{
accessorKey: "total_amount",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("form_fields.item.total_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<HoverCardTotalsSummary rowIndex={row.index}>
<AmountInputField
control={control}
name={`items.${row.index}.total_amount`}
readOnly
inputId={`total-${row.original.id}`}
currencyCode={currency_code}
languageCode={language_code}
className='font-semibold'
/>
</HoverCardTotalsSummary>
),
enableSorting: false,
size: 120,
minSize: 100,
maxSize: 160,
},
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("components.datatable.actions")} />
),
cell: ({ row, table }) => <ItemDataTableRowActions row={row} table={table} />,
},
],
[t, readOnly, control, currency_code, language_code]
);
} }

View File

@ -1,10 +1,13 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { CreateCustomerRequestDTO } from "../../../../common/dto"; import { CreateCustomerRequestDTO } from "../../../../common/dto";
import { CreateCustomerUseCase } from "../../../application"; import { CreateCustomerUseCase } from "../../../application";
import { customersApiErrorMapper } from "../customer-api-error-mapper";
export class CreateCustomerController extends ExpressController { export class CreateCustomerController extends ExpressController {
public constructor(private readonly useCase: CreateCustomerUseCase) { public constructor(private readonly useCase: CreateCustomerUseCase) {
super(); super();
this.errorMapper = customersApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,9 +1,12 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { DeleteCustomerUseCase } from "../../../application"; import { DeleteCustomerUseCase } from "../../../application";
import { customersApiErrorMapper } from "../customer-api-error-mapper";
export class DeleteCustomerController extends ExpressController { export class DeleteCustomerController extends ExpressController {
public constructor(private readonly useCase: DeleteCustomerUseCase) { public constructor(private readonly useCase: DeleteCustomerUseCase) {
super(); super();
this.errorMapper = customersApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,9 +1,12 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { GetCustomerUseCase } from "../../../application"; import { GetCustomerUseCase } from "../../../application";
import { customersApiErrorMapper } from "../customer-api-error-mapper";
export class GetCustomerController extends ExpressController { export class GetCustomerController extends ExpressController {
public constructor(private readonly useCase: GetCustomerUseCase) { public constructor(private readonly useCase: GetCustomerUseCase) {
super(); super();
this.errorMapper = customersApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,10 +1,13 @@
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { Criteria } from "@repo/rdx-criteria/server"; import { Criteria } from "@repo/rdx-criteria/server";
import { ListCustomersUseCase } from "../../../application"; import { ListCustomersUseCase } from "../../../application";
import { customersApiErrorMapper } from "../customer-api-error-mapper";
export class ListCustomersController extends ExpressController { export class ListCustomersController extends ExpressController {
public constructor(private readonly listCustomers: ListCustomersUseCase) { public constructor(private readonly listCustomers: ListCustomersUseCase) {
super(); super();
this.errorMapper = customersApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,10 +1,13 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { UpdateCustomerByIdRequestDTO } from "../../../../common/dto"; import { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
import { UpdateCustomerUseCase } from "../../../application"; import { UpdateCustomerUseCase } from "../../../application";
import { customersApiErrorMapper } from "../customer-api-error-mapper";
export class UpdateCustomerController extends ExpressController { export class UpdateCustomerController extends ExpressController {
public constructor(private readonly useCase: UpdateCustomerUseCase) { public constructor(private readonly useCase: UpdateCustomerUseCase) {
super(); super();
this.errorMapper = customersApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
} }

View File

@ -1,4 +1,4 @@
import { ApiErrorMapper, ConflictApiError, ErrorToApiRule } from "@erp/core/api"; import { ApiErrorMapper, ErrorToApiRule, NotFoundApiError } from "@erp/core/api";
import { CustomerNotFoundError, isCustomerNotFoundError } from "../../domain"; import { CustomerNotFoundError, isCustomerNotFoundError } from "../../domain";
// Crea una regla específica (prioridad alta para sobreescribir mensajes) // Crea una regla específica (prioridad alta para sobreescribir mensajes)
@ -6,7 +6,7 @@ const customerNotFoundRule: ErrorToApiRule = {
priority: 120, priority: 120,
matches: (e) => isCustomerNotFoundError(e), matches: (e) => isCustomerNotFoundError(e),
build: (e) => build: (e) =>
new ConflictApiError( new NotFoundApiError(
(e as CustomerNotFoundError).message || "Customer with the provided id not exists." (e as CustomerNotFoundError).message || "Customer with the provided id not exists."
), ),
}; };