Facturas de cliente

This commit is contained in:
David Arranz 2025-09-03 12:41:12 +02:00
parent b302870647
commit db4a79422e
25 changed files with 472 additions and 506 deletions

View File

@ -1,6 +1,13 @@
import { AggregateRoot, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { CustomerInvoiceCustomer, CustomerInvoiceItem, CustomerInvoiceItems } from "../entities";
import {
AggregateRoot,
CurrencyCode,
LanguageCode,
TextValue,
UniqueID,
UtcDate,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CustomerInvoiceItems } from "../entities";
import {
CustomerInvoiceNumber,
CustomerInvoiceSerie,
@ -8,20 +15,19 @@ import {
} from "../value-objects";
export interface CustomerInvoiceProps {
invoiceNumber: CustomerInvoiceNumber;
invoiceSeries: CustomerInvoiceSerie;
companyId: UniqueID;
status: CustomerInvoiceStatus;
series: Maybe<CustomerInvoiceSerie>;
invoiceNumber: CustomerInvoiceNumber;
issueDate: UtcDate;
operationDate: UtcDate;
operationDate: Maybe<UtcDate>;
notes: Maybe<TextValue>;
//dueDate: UtcDate; // ? --> depende de la forma de pago
//tax: Tax; // ? --> detalles?
currency: string;
//language: Language;
//purchareOrderNumber: string;
//notes: Note;
@ -31,53 +37,27 @@ export interface CustomerInvoiceProps {
//paymentInstructions: Note;
//paymentTerms: string;
customer?: CustomerInvoiceCustomer;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
//customer?: CustomerInvoiceCustomer;
items?: CustomerInvoiceItems;
}
export interface ICustomerInvoice {
id: UniqueID;
invoiceNumber: CustomerInvoiceNumber;
invoiceSeries: CustomerInvoiceSerie;
export type CustomerInvoicePatchProps = Partial<Omit<CustomerInvoiceProps, "companyId">>;
status: CustomerInvoiceStatus;
issueDate: UtcDate;
operationDate: UtcDate;
//senderId: UniqueID;
customer?: CustomerInvoiceCustomer;
//dueDate
//tax: Tax;
//language: Language;
currency: string;
//purchareOrderNumber: string;
//notes: Note;
//paymentInstructions: Note;
//paymentTerms: string;
items: CustomerInvoiceItems;
calculateSubtotal: () => MoneyValue;
calculateTaxTotal: () => MoneyValue;
calculateTotal: () => MoneyValue;
}
export class CustomerInvoice
extends AggregateRoot<CustomerInvoiceProps>
implements ICustomerInvoice
{
private _items!: Collection<CustomerInvoiceItem>;
export class CustomerInvoice extends AggregateRoot<CustomerInvoiceProps> {
private _items!: CustomerInvoiceItems;
//protected _status: CustomerInvoiceStatus;
protected constructor(props: CustomerInvoiceProps, id?: UniqueID) {
super(props, id);
this._items = props.items || CustomerInvoiceItems.create();
this._items =
props.items ||
CustomerInvoiceItems.create({
languageCode: props.languageCode,
currencyCode: props.currencyCode,
});
}
static create(props: CustomerInvoiceProps, id?: UniqueID): Result<CustomerInvoice, Error> {
@ -94,50 +74,55 @@ export class CustomerInvoice
return Result.ok(customerInvoice);
}
get invoiceNumber() {
public update(partialInvoice: CustomerInvoicePatchProps): Result<CustomerInvoice, Error> {
throw new Error("Not implemented");
}
public get companyId(): UniqueID {
return this.props.companyId;
}
public get series(): Maybe<CustomerInvoiceSerie> {
return this.props.series;
}
public get invoiceNumber() {
return this.props.invoiceNumber;
}
get invoiceSeries() {
return this.props.invoiceSeries;
public get issueDate(): UtcDate {
return this.props.issueDate;
}
get issueDate() {
return this.props.issueDate;
public get operationDate(): Maybe<UtcDate> {
return this.props.operationDate;
}
public get notes(): Maybe<TextValue> {
return this.props.notes;
}
public get languageCode(): LanguageCode {
return this.props.languageCode;
}
public get currencyCode(): CurrencyCode {
return this.props.currencyCode;
}
// Method to get the complete list of line items
get lineItems(): CustomerInvoiceItems {
return this._items;
}
/*get senderId(): UniqueID {
return this.props.senderId;
}*/
get customer(): CustomerInvoiceCustomer | undefined {
/* get customer(): CustomerInvoiceCustomer | undefined {
return this.props.customer;
}
get operationDate() {
return this.props.operationDate;
}
/*get language() {
return this.props.language;
}*/
get dueDate() {
return undefined;
}
get tax() {
return undefined;
}
get status() {
return this.props.status;
}
get items() {
return this._items;
}
/*get purchareOrderNumber() {
return this.props.purchareOrderNumber;
}
@ -158,19 +143,7 @@ export class CustomerInvoice
return this.props.shipTo;
}*/
get currency() {
return this.props.currency;
}
/*get notes() {
return this.props.notes;
}*/
// Method to get the complete list of line items
/*get lineItems(): CustomerInvoiceLineItem[] {
return this._lineItems;
}
/*
addLineItem(lineItem: CustomerInvoiceLineItem, position?: number): void {
if (position === undefined) {
this._lineItems.push(lineItem);
@ -179,7 +152,7 @@ export class CustomerInvoice
}
}*/
calculateSubtotal(): MoneyValue {
/*calculateSubtotal(): MoneyValue {
const customerInvoiceSubtotal = MoneyValue.create({
amount: 0,
currency_code: this.props.currency,
@ -189,10 +162,10 @@ export class CustomerInvoice
return this._items.getAll().reduce((subtotal, item) => {
return subtotal.add(item.calculateTotal());
}, customerInvoiceSubtotal);
}
}*/
// Method to calculate the total tax in the customerInvoice
calculateTaxTotal(): MoneyValue {
/*calculateTaxTotal(): MoneyValue {
const taxTotal = MoneyValue.create({
amount: 0,
currency_code: this.props.currency,
@ -200,10 +173,10 @@ export class CustomerInvoice
}).data;
return taxTotal;
}
}*/
// Method to calculate the total customerInvoice amount, including taxes
calculateTotal(): MoneyValue {
/*calculateTotal(): MoneyValue {
return this.calculateSubtotal().add(this.calculateTaxTotal());
}
}*/
}

View File

@ -1,4 +1,4 @@
import { MoneyValue, Percentage, Quantity } from "@/core/common/domain";
import { CurrencyCode, LanguageCode, MoneyValue, Percentage, Quantity } from "@repo/rdx-ddd";
import { CustomerInvoiceItemDescription } from "../../value-objects";
import { CustomerInvoiceItem } from "./customer-invoice-item";
@ -9,6 +9,8 @@ describe("CustomerInvoiceItem", () => {
quantity: Quantity.create({ amount: 200, scale: 2 }),
unitPrice: MoneyValue.create(50),
discount: Percentage.create(0),
languageCode: LanguageCode.create("es"),
currencyCode: CurrencyCode.create("EUR"),
};
const result = CustomerInvoiceItem.create(props);

View File

@ -0,0 +1,102 @@
import { CurrencyCode, DomainEntity, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
CustomerInvoiceItemDescription,
CustomerInvoiceItemDiscount,
CustomerInvoiceItemQuantity,
CustomerInvoiceItemSubtotalPrice,
CustomerInvoiceItemTotalPrice,
CustomerInvoiceItemUnitPrice,
} from "../../value-objects";
export interface CustomerInvoiceItemProps {
description: Maybe<CustomerInvoiceItemDescription>;
quantity: Maybe<CustomerInvoiceItemQuantity>; // Cantidad de unidades
unitPrice: Maybe<CustomerInvoiceItemUnitPrice>; // Precio unitario en la moneda de la factura
discount: Maybe<CustomerInvoiceItemDiscount>; // % descuento
languageCode: LanguageCode;
currencyCode: CurrencyCode;
}
export class CustomerInvoiceItem extends DomainEntity<CustomerInvoiceItemProps> {
private _subtotalPrice!: CustomerInvoiceItemSubtotalPrice;
private _totalPrice!: CustomerInvoiceItemTotalPrice;
public static create(
props: CustomerInvoiceItemProps,
id?: UniqueID
): Result<CustomerInvoiceItem, Error> {
const item = new CustomerInvoiceItem(props, id);
// Reglas de negocio / validaciones
// ...
// ...
// 🔹 Disparar evento de dominio "CustomerInvoiceItemCreatedEvent"
//const { customerInvoice } = props;
//user.addDomainEvent(new CustomerInvoiceAuthenticatedEvent(id, customerInvoice.toString()));
return Result.ok(item);
}
get description(): Maybe<CustomerInvoiceItemDescription> {
return this.props.description;
}
get quantity(): Maybe<CustomerInvoiceItemQuantity> {
return this.props.quantity;
}
get unitPrice(): Maybe<CustomerInvoiceItemUnitPrice> {
return this.props.unitPrice;
}
get subtotalPrice(): CustomerInvoiceItemSubtotalPrice {
if (!this._subtotalPrice) {
this._subtotalPrice = this.calculateSubtotal();
}
return this._subtotalPrice;
}
get discount(): Maybe<CustomerInvoiceItemDiscount> {
return this.props.discount;
}
get totalPrice(): CustomerInvoiceItemTotalPrice {
if (!this._totalPrice) {
this._totalPrice = this.calculateTotal();
}
return this._totalPrice;
}
public get languageCode(): LanguageCode {
return this.props.languageCode;
}
public get currencyCode(): CurrencyCode {
return this.props.currencyCode;
}
getValue(): CustomerInvoiceItemProps {
return this.props;
}
toPrimitive() {
return this.getValue();
}
calculateSubtotal(): CustomerInvoiceItemSubtotalPrice {
throw new Error("Not implemented");
/*const unitPrice = this.unitPrice.isSome()
? this.unitPrice.unwrap()
: CustomerInvoiceItemUnitPrice.zero();
return this.unitPrice.multiply(this.quantity.toNumber()); // Precio unitario * Cantidad*/
}
calculateTotal(): CustomerInvoiceItemTotalPrice {
throw new Error("Not implemented");
//return this.subtotalPrice.subtract(this.subtotalPrice.percentage(this.discount.toNumber()));
}
}

View File

@ -0,0 +1,25 @@
import { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import { Collection } from "@repo/rdx-utils";
import { CustomerInvoiceItem } from "./customer-invoice-item";
export interface CustomerInvoiceItemsProps {
items?: CustomerInvoiceItem[];
languageCode: LanguageCode;
currencyCode: CurrencyCode;
}
export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
private _languageCode!: LanguageCode;
private _currencyCode!: CurrencyCode;
constructor(props: CustomerInvoiceItemsProps) {
const { items, languageCode, currencyCode } = props;
super(items);
this._languageCode = languageCode;
this._currencyCode = currencyCode;
}
public static create(props: CustomerInvoiceItemsProps): CustomerInvoiceItems {
return new CustomerInvoiceItems(props);
}
}

View File

@ -1,2 +1,2 @@
export * from "./invoice-item";
export * from "./invoice-items";
export * from "./customer-invoice-item";
export * from "./customer-invoice-items";

View File

@ -1,107 +0,0 @@
import { DomainEntity, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
CustomerInvoiceItemDescription,
CustomerInvoiceItemDiscount,
CustomerInvoiceItemQuantity,
CustomerInvoiceItemSubtotalPrice,
CustomerInvoiceItemTotalPrice,
CustomerInvoiceItemUnitPrice,
} from "../../value-objects";
export interface ICustomerInvoiceItemProps {
description: CustomerInvoiceItemDescription;
quantity: CustomerInvoiceItemQuantity; // Cantidad de unidades
unitPrice: CustomerInvoiceItemUnitPrice; // Precio unitario en la moneda de la factura
//subtotalPrice?: MoneyValue; // Precio unitario * Cantidad
discount: CustomerInvoiceItemDiscount; // % descuento
//totalPrice?: MoneyValue;
}
export interface ICustomerInvoiceItem {
id: UniqueID;
description: CustomerInvoiceItemDescription;
quantity: CustomerInvoiceItemQuantity;
unitPrice: CustomerInvoiceItemUnitPrice;
subtotalPrice: CustomerInvoiceItemSubtotalPrice;
discount: CustomerInvoiceItemDiscount;
totalPrice: CustomerInvoiceItemTotalPrice;
}
export class CustomerInvoiceItem
extends DomainEntity<ICustomerInvoiceItemProps>
implements ICustomerInvoiceItem
{
private _subtotalPrice!: CustomerInvoiceItemSubtotalPrice;
private _totalPrice!: CustomerInvoiceItemTotalPrice;
public static create(
props: ICustomerInvoiceItemProps,
id?: UniqueID
): Result<CustomerInvoiceItem, Error> {
const item = new CustomerInvoiceItem(props, id);
// Reglas de negocio / validaciones
// ...
// ...
// 🔹 Disparar evento de dominio "CustomerInvoiceItemCreatedEvent"
//const { customerInvoice } = props;
//user.addDomainEvent(new CustomerInvoiceAuthenticatedEvent(id, customerInvoice.toString()));
return Result.ok(item);
}
get description(): CustomerInvoiceItemDescription {
return this.props.description;
}
get quantity(): CustomerInvoiceItemQuantity {
return this.props.quantity;
}
get unitPrice(): CustomerInvoiceItemUnitPrice {
return this.props.unitPrice;
}
get subtotalPrice(): CustomerInvoiceItemSubtotalPrice {
if (!this._subtotalPrice) {
this._subtotalPrice = this.calculateSubtotal();
}
return this._subtotalPrice;
}
get discount(): CustomerInvoiceItemDiscount {
return this.props.discount;
}
get totalPrice(): CustomerInvoiceItemTotalPrice {
if (!this._totalPrice) {
this._totalPrice = this.calculateTotal();
}
return this._totalPrice;
}
getValue() {
return this.props;
}
toPrimitive() {
return {
description: this.description.toPrimitive(),
quantity: this.quantity.toPrimitive(),
unit_price: this.unitPrice.toPrimitive(),
subtotal_price: this.subtotalPrice.toPrimitive(),
discount: this.discount.toPrimitive(),
total_price: this.totalPrice.toPrimitive(),
};
}
calculateSubtotal(): CustomerInvoiceItemSubtotalPrice {
return this.unitPrice.multiply(this.quantity.toNumber()); // Precio unitario * Cantidad
}
calculateTotal(): CustomerInvoiceItemTotalPrice {
return this.subtotalPrice.subtract(this.subtotalPrice.percentage(this.discount.toNumber()));
}
}

View File

@ -1,8 +0,0 @@
import { Collection } from "@repo/rdx-utils";
import { CustomerInvoiceItem } from "./customer-invoice-item";
export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
public static create(items?: CustomerInvoiceItem[]): CustomerInvoiceItems {
return new CustomerInvoiceItems(items);
}
}

View File

@ -3,12 +3,15 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { CustomerInvoice } from "../aggregates";
/**
* Interfaz del repositorio para el agregado `CustomerInvoice`.
* El escopado multitenant está representado por `companyId`.
*/
export interface ICustomerInvoiceRepository {
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
/**
*
* Persiste una nueva factura o actualiza una existente.
* Retorna el objeto actualizado tras la operación.
*
* @param invoice - El agregado a guardar.
* @param transaction - Transacción activa para la operación.
@ -17,34 +20,55 @@ export interface ICustomerInvoiceRepository {
save(invoice: CustomerInvoice, transaction: any): Promise<Result<CustomerInvoice, Error>>;
/**
*
* Busca una factura por su identificador único.
* @param id - UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice, Error>
* Comprueba si existe una factura con un `id` dentro de una `company`.
*/
findById(id: UniqueID, transaction: any): Promise<Result<CustomerInvoice, Error>>;
existsByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: any
): Promise<Result<boolean, Error>>;
/**
* Recupera una factura por su ID y companyId.
* Devuelve un `NotFoundError` si no se encuentra.
*/
getByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: any
): Promise<Result<CustomerInvoice, Error>>;
/**
*
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
* Consulta facturas dentro de una empresa usando un
* objeto Criteria (filtros, orden, paginación).
* El resultado está encapsulado en un objeto `Collection<T>`.
*
* @param companyId - ID de la empresa.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice[], Error>
*
* @see Criteria
*/
findByCriteria(
findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: any
): Promise<Result<Collection<CustomerInvoice>, Error>>;
/**
*
* Elimina o marca como eliminada una factura.
* Elimina o marca como eliminada una factura dentro de una empresa.
*
* @param companyId - ID de la empresa.
* @param id - UUID de la factura a eliminar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>>;
deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: any
): Promise<Result<void, Error>>;
}

View File

@ -14,10 +14,10 @@ export class CustomerInvoiceAddressType extends ValueObject<ICustomerInvoiceAddr
private static readonly ALLOWED_TYPES = ["shipping", "billing"];
static create(value: string): Result<CustomerInvoiceAddressType, Error> {
if (!this.ALLOWED_TYPES.includes(value)) {
if (!CustomerInvoiceAddressType.ALLOWED_TYPES.includes(value)) {
return Result.fail(
new Error(
`Invalid address type: ${value}. Allowed types are: ${this.ALLOWED_TYPES.join(", ")}`
`Invalid address type: ${value}. Allowed types are: ${CustomerInvoiceAddressType.ALLOWED_TYPES.join(", ")}`
)
);
}

View File

@ -3,11 +3,11 @@ import { ValueObject } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
interface ICustomerInvoiceItemDescriptionProps {
interface CustomerInvoiceItemDescriptionProps {
value: string;
}
export class CustomerInvoiceItemDescription extends ValueObject<ICustomerInvoiceItemDescriptionProps> {
export class CustomerInvoiceItemDescription extends ValueObject<CustomerInvoiceItemDescriptionProps> {
private static readonly MAX_LENGTH = 255;
private static readonly FIELD = "invoiceItemDescription";
private static readonly ERROR_CODE = "INVALID_INVOICE_ITEM_DESCRIPTION";
@ -23,10 +23,10 @@ export class CustomerInvoiceItemDescription extends ValueObject<ICustomerInvoice
}
static create(value: string) {
const result = CustomerInvoiceItemDescription.validate(value);
const valueIsValid = CustomerInvoiceItemDescription.validate(value);
if (!result.success) {
const detail = result.error.message;
if (!valueIsValid.success) {
const detail = valueIsValid.error.message;
return Result.fail(
new DomainValidationError(
CustomerInvoiceItemDescription.ERROR_CODE,

View File

@ -11,4 +11,13 @@ export class CustomerInvoiceItemUnitPrice extends MoneyValue {
};
return MoneyValue.create(props);
}
static zero(currency_code: string, scale: number = CustomerInvoiceItemUnitPrice.DEFAULT_SCALE) {
const props: MoneyValueProps = {
amount: 0,
scale,
currency_code,
};
return MoneyValue.create(props);
}
}

View File

@ -23,10 +23,10 @@ export class CustomerInvoiceNumber extends ValueObject<ICustomerInvoiceNumberPro
}
static create(value: string) {
const result = CustomerInvoiceNumber.validate(value);
const valueIsValid = CustomerInvoiceNumber.validate(value);
if (!result.success) {
const detail = result.error.message;
if (!valueIsValid.success) {
const detail = valueIsValid.error.message;
return Result.fail(
new DomainValidationError(
CustomerInvoiceNumber.ERROR_CODE,

View File

@ -23,10 +23,10 @@ export class CustomerInvoiceSerie extends ValueObject<ICustomerInvoiceSerieProps
}
static create(value: string) {
const result = CustomerInvoiceSerie.validate(value);
const valueIsValid = CustomerInvoiceSerie.validate(value);
if (!result.success) {
const detail = result.error.message;
if (!valueIsValid.success) {
const detail = valueIsValid.error.message;
return Result.fail(
new DomainValidationError(
CustomerInvoiceSerie.ERROR_CODE,

View File

@ -4,18 +4,18 @@ import { customerInvoicesRouter, models } from "./infrastructure";
export const customerInvoicesAPIModule: IModuleServer = {
name: "customer-invoices",
version: "1.0.0",
dependencies: [],
dependencies: ["customers"],
async init(params: ModuleParams) {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
customerInvoicesRouter(params);
logger.info("🚀 CustomerInvoices module initialized", { label: "customer-invoices" });
logger.info("🚀 CustomerInvoices module initialized", { label: this.name });
},
async registerDependencies(params) {
const { database, logger } = params;
logger.info("🚀 CustomerInvoices module dependencies registered", {
label: "customer-invoices",
label: this.name,
});
return {
models,

View File

@ -8,6 +8,8 @@ import {
GetCustomerInvoiceUseCase,
ListCustomerInvoicesAssembler,
ListCustomerInvoicesUseCase,
UpdateCustomerInvoiceAssembler,
UpdateCustomerInvoiceUseCase,
} from "../application";
import { CustomerInvoiceService, ICustomerInvoiceService } from "../domain";
import { CustomerInvoiceMapper } from "./mappers";
@ -22,13 +24,13 @@ type InvoiceDeps = {
list: ListCustomerInvoicesAssembler;
get: GetCustomerInvoiceAssembler;
create: CreateCustomerInvoicesAssembler;
//update: UpdateCustomerInvoiceAssembler;
update: UpdateCustomerInvoiceAssembler;
};
build: {
list: () => ListCustomerInvoicesUseCase;
get: () => GetCustomerInvoiceUseCase;
create: () => CreateCustomerInvoiceUseCase;
//update: () => UpdateCustomerInvoiceUseCase;
update: () => UpdateCustomerInvoiceUseCase;
delete: () => DeleteCustomerInvoiceUseCase;
};
presenters: {
@ -54,7 +56,7 @@ export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps {
list: new ListCustomerInvoicesAssembler(), // transforma domain → ListDTO
get: new GetCustomerInvoiceAssembler(), // transforma domain → DetailDTO
create: new CreateCustomerInvoicesAssembler(), // transforma domain → CreatedDTO
//update: new UpdateCustomerInvoiceAssembler(), // transforma domain -> UpdateDTO
update: new UpdateCustomerInvoiceAssembler(), // transforma domain -> UpdateDTO
};
}
@ -70,8 +72,8 @@ export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps {
get: () => new GetCustomerInvoiceUseCase(_service!, transactionManager!, _assemblers!.get),
create: () =>
new CreateCustomerInvoiceUseCase(_service!, transactionManager!, _assemblers!.create),
/*update: () =>
new UpdateCustomerInvoiceUseCase(_service!, transactionManager!, _assemblers!.update),*/
update: () =>
new UpdateCustomerInvoiceUseCase(_service!, transactionManager!, _assemblers!.update),
delete: () => new DeleteCustomerInvoiceUseCase(_service!, transactionManager!),
},
presenters: {

View File

@ -1,9 +1,17 @@
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import {
ISequelizeMapper,
MapperParamsType,
SequelizeMapper,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { CurrencyCode, LanguageCode, UniqueID, UtcDate, maybeFromNullableVO } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
CustomerInvoice,
CustomerInvoiceNumber,
CustomerInvoiceProps,
CustomerInvoiceSerie,
CustomerInvoiceStatus,
} from "../../domain";
@ -32,50 +40,75 @@ export class CustomerInvoiceMapper
source: CustomerInvoiceModel,
params?: MapperParamsType
): Result<CustomerInvoice, Error> {
const idOrError = UniqueID.create(source.id);
const statusOrError = CustomerInvoiceStatus.create(source.invoice_status);
const customerInvoiceSeriesOrError = CustomerInvoiceSerie.create(source.invoice_series);
const customerInvoiceNumberOrError = CustomerInvoiceNumber.create(source.invoice_number);
const issueDateOrError = UtcDate.createFromISO(source.issue_date);
const operationDateOrError = UtcDate.createFromISO(source.operation_date);
const errors: ValidationErrorDetail[] = [];
const result = Result.combine([
idOrError,
statusOrError,
customerInvoiceSeriesOrError,
customerInvoiceNumberOrError,
issueDateOrError,
operationDateOrError,
]);
const invoiceId = extractOrPushError(UniqueID.create(source.id), "id", errors);
const companyId = extractOrPushError(UniqueID.create(source.company_id), "company_id", errors);
if (result.isFailure) {
return Result.fail(result.error);
const status = extractOrPushError(
CustomerInvoiceStatus.create(source.status),
"status",
errors
);
const series = extractOrPushError(CustomerInvoiceSerie.create(source.series), "series", errors);
const invoiceNumber = extractOrPushError(
CustomerInvoiceNumber.create(source.invoice_number),
"invoice_number",
errors
);
const issueDate = extractOrPushError(
UtcDate.createFromISO(source.issue_date),
"issue_date",
errors
);
const operationDate = extractOrPushError(
maybeFromNullableVO(source.operation_date, (value) => UtcDate.createFromISO(value)),
"operation_date",
errors
);
const languageCode = extractOrPushError(
LanguageCode.create(source.language_code),
"language_code",
errors
);
const currencyCode = extractOrPushError(
CurrencyCode.create(source.currency_code),
"currency_code",
errors
);
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer invoice props mapping failed", errors)
);
}
// Mapear los items de la factura
const itemsOrErrors = this.customerInvoiceItemMapper.mapArrayToDomain(source.items, {
/*const itemsOrErrors = this.customerInvoiceItemMapper.mapArrayToDomain(source.items, {
sourceParent: source,
...params,
});
if (itemsOrErrors.isFailure) {
return Result.fail(itemsOrErrors.error);
}
}*/
const customerInvoiceCurrency = source.invoice_currency || "EUR";
const invoiceProps: CustomerInvoiceProps = {
status: status!,
series: series!,
invoiceNumber: invoiceNumber!,
issueDate: issueDate!,
operationDate: operationDate!,
return CustomerInvoice.create(
{
status: statusOrError.data,
invoiceSeries: customerInvoiceSeriesOrError.data,
invoiceNumber: customerInvoiceNumberOrError.data,
issueDate: issueDateOrError.data,
operationDate: operationDateOrError.data,
currency: customerInvoiceCurrency,
items: itemsOrErrors.data,
},
idOrError.data
);
languageCode: languageCode!,
currencyCode: currencyCode!,
//items: itemsOrErrors.data,
};
return CustomerInvoice.create(invoiceProps, invoiceId);
}
public mapToPersistence(

View File

@ -1,5 +1,4 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
@ -21,26 +20,26 @@ export class CustomerInvoiceItemModel extends Model<
declare item_id: string;
declare invoice_id: string;
declare parent_id: CreationOptional<string>;
declare parent_id: string;
declare position: number;
declare item_type: string;
declare description: CreationOptional<string>;
declare description: string;
declare quantity_amount: CreationOptional<number>;
declare quantity_scale: CreationOptional<number>;
declare quantity_amount: number;
declare quantity_scale: number;
declare unit_price_amount: CreationOptional<number>;
declare unit_price_scale: CreationOptional<number>;
declare unit_price_amount: number;
declare unit_price_scale: number;
declare subtotal_amount: CreationOptional<number>;
declare subtotal_scale: CreationOptional<number>;
declare subtotal_amount: number;
declare subtotal_scale: number;
declare discount_amount: CreationOptional<number>;
declare discount_scale: CreationOptional<number>;
declare discount_amount: number;
declare discount_scale: number;
declare total_amount: CreationOptional<number>;
declare total_scale: CreationOptional<number>;
declare total_amount: number;
declare total_scale: number;
declare invoice: NonAttribute<CustomerInvoiceModel>;
@ -65,14 +64,14 @@ export default (database: Sequelize) => {
},
invoice_id: {
type: new DataTypes.UUID(),
primaryKey: true,
allowNull: false,
},
parent_id: {
type: new DataTypes.UUID(),
allowNull: true, // Puede ser nulo para elementos de nivel superior
},
position: {
type: new DataTypes.MEDIUMINT(),
type: new DataTypes.MEDIUMINT().UNSIGNED,
autoIncrement: false,
allowNull: false,
},
@ -84,6 +83,7 @@ export default (database: Sequelize) => {
description: {
type: new DataTypes.TEXT(),
allowNull: true,
defaultValue: null,
},
quantity_amount: {
@ -160,9 +160,17 @@ export default (database: Sequelize) => {
},
{
sequelize: database,
underscored: true,
tableName: "customer_invoice_items",
underscored: true,
indexes: [
{ name: "invoice_idx", fields: ["invoice_id"], unique: false },
{ name: "parent_idx", fields: ["parent_id"], unique: false },
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},

View File

@ -1,5 +1,4 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
@ -24,22 +23,23 @@ export class CustomerInvoiceModel extends Model<
InferCreationAttributes<CustomerInvoiceModel, { omit: "items" }>
> {
declare id: string;
declare company_id: string;
declare invoice_status: string;
declare invoice_series: CreationOptional<string>;
declare invoice_number: CreationOptional<string>;
declare issue_date: CreationOptional<string>;
declare operation_date: CreationOptional<string>;
declare invoice_language: string;
declare invoice_currency: string;
declare status: string;
declare series: string;
declare invoice_number: string;
declare issue_date: string;
declare operation_date: string;
declare language_code: string;
declare currency_code: string;
// Subtotal
declare subtotal_amount: CreationOptional<number>;
declare subtotal_scale: CreationOptional<number>;
declare subtotal_amount: number;
declare subtotal_scale: number;
// Total
declare total_amount: CreationOptional<number>;
declare total_scale: CreationOptional<number>;
declare total_amount: number;
declare total_scale: number;
// Relaciones
declare items: NonAttribute<CustomerInvoiceItemModel[]>;
@ -59,14 +59,14 @@ export class CustomerInvoiceModel extends Model<
static hooks(database: Sequelize) {
// Soft-cascade manual: al borrar una factura, marcamos items como borrados (paranoid).
CustomerInvoiceModel.addHook("afterDestroy", async (invoice, options) => {
/*CustomerInvoiceModel.addHook("afterDestroy", async (invoice, options) => {
if (!invoice?.id) return;
await CustomerInvoiceItemModel.destroy({
where: { invoiceId: invoice.id },
where: { invoice_id: invoice.id },
individualHooks: true,
transaction: options.transaction,
});
});
});*/
}
}
@ -78,12 +78,18 @@ export default (database: Sequelize) => {
primaryKey: true,
},
invoice_status: {
type: new DataTypes.STRING(),
company_id: {
type: DataTypes.UUID,
allowNull: false,
},
invoice_series: {
status: {
type: new DataTypes.STRING(),
allowNull: false,
defaultValue: "draft",
},
series: {
type: new DataTypes.STRING(),
allowNull: true,
defaultValue: null,
@ -107,14 +113,16 @@ export default (database: Sequelize) => {
defaultValue: null,
},
invoice_language: {
type: new DataTypes.STRING(),
language_code: {
type: DataTypes.STRING(2),
allowNull: false,
defaultValue: "es",
},
invoice_currency: {
type: new DataTypes.STRING(3), // ISO 4217
currency_code: {
type: new DataTypes.STRING(3),
allowNull: false,
defaultValue: "EUR",
},
subtotal_amount: {
@ -151,7 +159,11 @@ export default (database: Sequelize) => {
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [{ unique: true, fields: ["invoice_number"] }],
indexes: [
{ name: "company_idx", fields: ["company_id"], unique: false },
{ name: "idx_company_idx", fields: ["id", "company_id"], unique: true },
{ unique: true, fields: ["invoice_number"] },
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope

View File

@ -1,4 +1,4 @@
import { SequelizeRepository } from "@erp/core/api";
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
@ -11,7 +11,6 @@ export class CustomerInvoiceRepository
extends SequelizeRepository<CustomerInvoice>
implements ICustomerInvoiceRepository
{
//private readonly model: typeof CustomerInvoiceModel;
private readonly mapper!: ICustomerInvoiceMapper;
constructor(mapper: ICustomerInvoiceMapper) {
@ -52,16 +51,6 @@ export class CustomerInvoiceRepository
};
} */
async existsById(id: UniqueID, transaction?: Transaction): Promise<Result<boolean, Error>> {
try {
const result = await this._exists(CustomerInvoiceModel, "id", id.toString(), transaction);
return Result.ok(Boolean(result));
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
*
* Persiste una nueva factura o actualiza una existente.
@ -76,29 +65,64 @@ export class CustomerInvoiceRepository
): Promise<Result<CustomerInvoice, Error>> {
try {
const data = this.mapper.mapToPersistence(invoice);
await CustomerInvoiceModel.upsert(data, { transaction });
return Result.ok(invoice);
const [instance] = await CustomerInvoiceModel.upsert(data, { transaction, returning: true });
const savedInvoice = this.mapper.mapToDomain(instance);
return savedInvoice;
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
* Comprueba si existe una factura con un `id` dentro de una `company`.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
* @param id - Identificador UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error>
*/
async existsByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
try {
const count = await CustomerInvoiceModel.count({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
return Result.ok(Boolean(count > 0));
} catch (error: any) {
return Result.fail(translateSequelizeError(error));
}
}
/**
*
* Busca una factura por su identificador único.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
* @param id - UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice, Error>
*/
async findById(id: UniqueID, transaction: Transaction): Promise<Result<CustomerInvoice, Error>> {
async getByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<CustomerInvoice, Error>> {
try {
const rawData = await this._findById(CustomerInvoiceModel, id.toString(), { transaction });
const row = await CustomerInvoiceModel.findOne({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
if (!rawData) {
return Result.fail(new Error(`Invoice with id ${id} not found.`));
if (!row) {
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
}
return this.mapper.mapToDomain(rawData);
const customer = this.mapper.mapToDomain(row);
return customer;
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
@ -107,13 +131,16 @@ export class CustomerInvoiceRepository
/**
*
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice[], Error>
*
* @see Criteria
*/
public async findByCriteria(
public async findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: Transaction
): Promise<Result<Collection<CustomerInvoice>, Error>> {
@ -121,6 +148,11 @@ export class CustomerInvoiceRepository
const converter = new CriteriaToSequelizeConverter();
const query = converter.convert(criteria);
query.where = {
...query.where,
company_id: companyId.toString(),
};
const instances = await CustomerInvoiceModel.findAll({
...query,
transaction,
@ -135,13 +167,23 @@ export class CustomerInvoiceRepository
/**
*
* Elimina o marca como eliminada una factura.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param id - UUID de la factura a eliminar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>> {
async deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: any
): Promise<Result<void, Error>> {
try {
await this._deleteById(CustomerInvoiceModel, id, false, transaction);
const deleted = await CustomerInvoiceModel.destroy({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
return Result.ok<void>();
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));

View File

@ -7,7 +7,6 @@ import {
PhoneNumber,
PostalAddress,
PostalAddressPatchProps,
PostalAddressSnapshot,
TINNumber,
TaxCode,
TextValue,
@ -41,32 +40,6 @@ export interface CustomerProps {
currencyCode: CurrencyCode;
}
export interface CustomerSnapshot {
id: string;
companyId: string;
status: string;
reference: string | null;
isCompany: boolean;
name: string;
tradeName: string | null;
tin: string | null;
address: PostalAddressSnapshot; // snapshot serializable del VO PostalAddress
email: string | null;
phone: string | null;
fax: string | null;
website: string | null;
legalRecord: string | null;
defaultTaxes: string[];
languageCode: string;
currencyCode: string;
}
export type CustomerPatchProps = Partial<Omit<CustomerProps, "companyId" | "address">> & {
address?: PostalAddressPatchProps;
};

View File

@ -4,8 +4,7 @@ import { Collection, Result } from "@repo/rdx-utils";
import { Customer } from "../aggregates";
/**
* Contrato del repositorio de Customers.
* Define la interfaz de persistencia para el agregado `Customer`.
* Interfaz del repositorio para el agregado `Customer`.
* El escopado multitenant está representado por `companyId`.
*/
export interface ICustomerRepository {
@ -35,7 +34,8 @@ export interface ICustomerRepository {
): Promise<Result<Customer, Error>>;
/**
* Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
* Recupera múltiples customers dentro de una empresa
* según un criterio dinámico (búsqueda, paginación, etc.).
* El resultado está encapsulado en un objeto `Collection<T>`.
*/
findByCriteriaInCompany(
@ -47,6 +47,11 @@ export interface ICustomerRepository {
/**
* Elimina un Customer por su ID, dentro de una empresa.
* Retorna `void` si se elimina correctamente, o `NotFoundError` si no existía.
*
*/
deleteByIdInCompany(companyId: UniqueID, id: UniqueID, transaction: any): Promise<Result<void>>;
deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: any
): Promise<Result<void, Error>>;
}

View File

@ -10,12 +10,12 @@ export const customersAPIModule: IModuleServer = {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
customersRouter(params);
logger.info("🚀 Customers module initialized", { label: "customers" });
logger.info("🚀 Customers module initialized", { label: this.name });
},
async registerDependencies(params) {
const { database, logger } = params;
logger.info("🚀 Customers module dependencies registered", {
label: "customers",
label: this.name,
});
return {
models,

View File

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

View File

@ -171,6 +171,7 @@ export default (database: Sequelize) => {
sequelize: database,
tableName: "customers",
underscored: true,
paranoid: true, // softs deletes
timestamps: true,

View File

@ -11,7 +11,6 @@ export class CustomerRepository
extends SequelizeRepository<Customer>
implements ICustomerRepository
{
//private readonly model: typeof CustomerModel;
private readonly mapper!: ICustomerMapper;
constructor(mapper: ICustomerMapper) {
@ -149,7 +148,6 @@ export class CustomerRepository
return Result.ok<void>();
} catch (err: unknown) {
// , `Error deleting customer ${id} in company ${companyId}`
return Result.fail(translateSequelizeError(err));
}
}