Facturas de cliente

This commit is contained in:
David Arranz 2025-09-04 19:57:04 +02:00
parent 206d5bf9d3
commit b7a58ebad5
39 changed files with 1056 additions and 445 deletions

View File

@ -0,0 +1,45 @@
import { toEmptyString } from "@repo/rdx-ddd";
import { CreateCustomerInvoiceResponseDTO } from "../../../../common/dto";
import { CustomerInvoice } from "../../../domain";
type CreateCustomerInvoiceItemsByInvoiceIdResponseDTO = CreateCustomerInvoiceResponseDTO["items"];
export class CreateCustomerInvoiceItemsAssembler {
toDTO(invoice: CustomerInvoice): CreateCustomerInvoiceItemsByInvoiceIdResponseDTO {
const { items } = invoice;
return items.map((item, index) => ({
id: item.id.toString(),
position: String(index),
description: toEmptyString(item.description, (value) => value.toPrimitive()),
quantity: item.quantity.match(
(quantity) => {
const { value, scale } = quantity.toPrimitive();
return { value: value.toString(), scale: scale.toString() };
},
() => ({ value: "", scale: "" })
),
unit_amount: item.unitAmount.match(
(unitAmount) => {
const { value, scale } = unitAmount.toPrimitive();
return { value: value.toString(), scale: scale.toString() };
},
() => ({ value: "", scale: "" })
),
discount_percentage: item.discountPercentage.match(
(discountPercentage) => {
const { value, scale } = discountPercentage.toPrimitive();
return { value: value.toString(), scale: scale.toString() };
},
() => ({ value: "", scale: "" })
),
total_amount: {
value: item.totalAmount.toPrimitive().value.toString(),
scale: item.totalAmount.toPrimitive().scale.toString(),
},
}));
}
}

View File

@ -1,26 +1,68 @@
import { CustomerInvoice } from "@erp/customer-invoices/api/domain";
import { CustomerInvoicesCreationResponseDTO } from "@erp/customer-invoices/common/dto";
import { UpdateCustomerInvoiceByIdResponseDTO } from "@erp/customer-invoices/common/dto";
import { toEmptyString } from "@repo/rdx-ddd";
import { CustomerInvoice } from "../../../domain";
import { CreateCustomerInvoiceItemsAssembler } from "./create-customer-invoice-items.assembler";
export class CreateCustomerInvoiceAssembler {
private _itemsAssembler!: CreateCustomerInvoiceItemsAssembler;
constructor() {
this._itemsAssembler = new CreateCustomerInvoiceItemsAssembler();
}
public toDTO(invoice: CustomerInvoice): UpdateCustomerInvoiceByIdResponseDTO {
const items = this._itemsAssembler.toDTO(invoice);
export class CreateCustomerInvoicesAssembler {
public toDTO(invoice: CustomerInvoice): CustomerInvoicesCreationResponseDTO {
return {
id: invoice.id.toPrimitive(),
company_id: invoice.companyId.toPrimitive(),
invoice_status: invoice.status.toString(),
invoice_number: invoice.invoiceNumber.toString(),
invoice_series: invoice.invoiceSeries.toString(),
issue_date: invoice.issueDate.toISOString(),
operation_date: invoice.operationDate.toISOString(),
language_code: "ES",
currency: "EUR",
status: invoice.status.toPrimitive(),
series: invoice.series.toString(),
//subtotal_price: invoice.calculateSubtotal().toPrimitive(),
//total_price: invoice.calculateTotal().toPrimitive(),
invoice_date: invoice.invoiceDate.toDateString(),
operation_date: toEmptyString(invoice.operationDate, (value) => value.toDateString()),
//recipient: CustomerInvoiceParticipantAssembler(customerInvoice.recipient),
notes: toEmptyString(invoice.notes, (value) => value.toPrimitive()),
language_code: invoice.languageCode.toPrimitive(),
currency_code: invoice.currencyCode.toPrimitive(),
subtotal_amount: {
value: invoice.subtotalAmount.value.toString(),
scale: invoice.subtotalAmount.scale.toString(),
},
discount_percentage: {
value: invoice.discountPercentage.value.toString(),
scale: invoice.discountPercentage.scale.toString(),
},
discount_amount: {
value: invoice.discountAmount.value.toString(),
scale: invoice.discountAmount.scale.toString(),
},
taxable_amount: {
value: invoice.taxableAmount.value.toString(),
scale: invoice.taxableAmount.scale.toString(),
},
tax_amount: {
value: invoice.taxAmount.value.toString(),
scale: invoice.taxAmount.scale.toString(),
},
total_amount: {
value: invoice.totalAmount.value.toString(),
scale: invoice.totalAmount.scale.toString(),
},
items,
metadata: {
entity: "customer-invoice",
entity: "customer-invoices",
},
};
}

View File

@ -1,62 +1,78 @@
import { DuplicateEntityError, ITransactionManager } from "@erp/core/api";
import { CreateCustomerInvoiceRequestDTO } from "@erp/customer-invoices/common/dto";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { ICustomerInvoiceService } from "../../domain";
import { mapDTOToCustomerInvoiceProps } from "../helpers";
import { CreateCustomerInvoicesAssembler } from "./assembler";
import { CreateCustomerInvoiceRequestDTO } from "../../../common/dto";
import { CustomerInvoiceService } from "../../domain";
import { CreateCustomerInvoiceAssembler } from "./assembler";
import { mapDTOToCreateCustomerInvoiceProps } from "./map-dto-to-create-customer-invoice-props";
type CreateCustomerInvoiceUseCaseInput = {
tenantId: string;
companyId: UniqueID;
dto: CreateCustomerInvoiceRequestDTO;
};
export class CreateCustomerInvoiceUseCase {
constructor(
private readonly service: ICustomerInvoiceService,
private readonly service: CustomerInvoiceService,
private readonly transactionManager: ITransactionManager,
private readonly assembler: CreateCustomerInvoicesAssembler
private readonly assembler: CreateCustomerInvoiceAssembler
) {}
public execute(params: CreateCustomerInvoiceUseCaseInput) {
const { dto, tenantId } = params;
const invoicePropsOrError = mapDTOToCustomerInvoiceProps(dto);
const { dto, companyId } = params;
if (invoicePropsOrError.isFailure) {
return Result.fail(invoicePropsOrError.error);
// 1) Mapear DTO → props de dominio
const dtoResult = mapDTOToCreateCustomerInvoiceProps(dto);
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
const { props, id } = invoicePropsOrError.data;
const invoiceOrError = this.service.build(props, id);
const { props, id } = dtoResult.data;
if (invoiceOrError.isFailure) {
return Result.fail(invoiceOrError.error);
// 2) Construir entidad de dominio
const buildResult = this.service.buildInvoiceInCompany(companyId, props, id);
if (buildResult.isFailure) {
return Result.fail(buildResult.error);
}
const newInvoice = invoiceOrError.data;
const newInvoice = buildResult.data;
// 3) Ejecutar bajo transacción: verificar duplicado → persistir → ensamblar vista
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const duplicateCheck = await this.service.existsById(id, transaction);
if (duplicateCheck.isFailure) {
return Result.fail(duplicateCheck.error);
}
if (duplicateCheck.data) {
return Result.fail(new DuplicateEntityError("CustomerInvoice", id.toString()));
}
const result = await this.service.save(newInvoice, transaction);
if (result.isFailure) {
return Result.fail(result.error);
}
const viewDTO = this.assembler.toDTO(newInvoice);
return Result.ok(viewDTO);
} catch (error: unknown) {
return Result.fail(error as Error);
const existsGuard = await this.ensureNotExists(companyId, id, transaction);
if (existsGuard.isFailure) {
return Result.fail(existsGuard.error);
}
const saveResult = await this.service.saveInvoice(newInvoice, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}
const viewDTO = this.assembler.toDTO(saveResult.data);
return Result.ok(viewDTO);
});
}
/**
Verifica que no exista uana factura con el mismo id en la companyId.
*/
private async ensureNotExists(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<void, Error>> {
const existsResult = await this.service.existsByIdInCompany(companyId, id, transaction);
if (existsResult.isFailure) {
return Result.fail(existsResult.error);
}
if (existsResult.data) {
return Result.fail(new DuplicateEntityError("Customer invoice", "id", String(id)));
}
return Result.ok<void>(undefined);
}
}

View File

@ -0,0 +1,190 @@
import {
DomainError,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import {
CurrencyCode,
LanguageCode,
Percentage,
TextValue,
UniqueID,
UtcDate,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CreateCustomerInvoiceRequestDTO } from "../../../common/dto";
import {
CustomerInvoiceItemDescription,
CustomerInvoiceItemDiscount,
CustomerInvoiceItemProps,
CustomerInvoiceItemQuantity,
CustomerInvoiceItemUnitAmount,
CustomerInvoiceNumber,
CustomerInvoiceProps,
CustomerInvoiceSerie,
CustomerInvoiceStatus,
} from "../../domain";
/**
* Convierte el DTO a las props validadas (CustomerProps).
* No construye directamente el agregado.
*
* @param dto - DTO con los datos de la factura de cliente
* @returns
*
*/
export function mapDTOToCreateCustomerInvoiceProps(dto: CreateCustomerInvoiceRequestDTO) {
try {
const errors: ValidationErrorDetail[] = [];
const customerId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
const companyId = extractOrPushError(UniqueID.create(dto.company_id), "company_id", errors);
const invoiceNumber = extractOrPushError(
CustomerInvoiceNumber.create(dto.invoice_number),
"invoice_number",
errors
);
const status = extractOrPushError(CustomerInvoiceStatus.create(dto.status), "status", errors);
const series = extractOrPushError(
maybeFromNullableVO(dto.series, (value) => CustomerInvoiceSerie.create(value)),
"series",
errors
);
const invoiceDate = extractOrPushError(
UtcDate.createFromISO(dto.invoice_date),
"invoice_date",
errors
);
const operationDate = extractOrPushError(
maybeFromNullableVO(dto.operation_date, (value) => UtcDate.createFromISO(value)),
"operation_date",
errors
);
const notes = extractOrPushError(
maybeFromNullableVO(dto.notes, (value) => TextValue.create(value)),
"notes",
errors
);
const languageCode = extractOrPushError(
LanguageCode.create(dto.language_code),
"language_code",
errors
);
const currencyCode = extractOrPushError(
CurrencyCode.create(dto.currency_code),
"currency_code",
errors
);
const discountPercentage = extractOrPushError(
Percentage.create({
value: Number(dto.discount_percentage.value),
scale: Number(dto.discount_percentage.scale),
}),
"discount_percentage",
errors
);
const items = mapDTOToCreateCustomerInvoiceItemsProps(dto, errors);
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer invoice props mapping failed", errors)
);
}
const invoiceProps: CustomerInvoiceProps = {
companyId: companyId!,
status: status!,
invoiceNumber: invoiceNumber!,
invoiceDate: invoiceDate!,
operationDate: operationDate!,
series: series!,
notes: notes!,
languageCode: languageCode!,
currencyCode: currencyCode!,
discountPercentage: discountPercentage!,
};
return Result.ok({ id: customerId!, props: invoiceProps });
} catch (err: unknown) {
return Result.fail(new DomainError("Customer invoice props mapping failed", { cause: err }));
}
}
function mapDTOToCreateCustomerInvoiceItemsProps(
dto: CreateCustomerInvoiceRequestDTO,
errors: ValidationErrorDetail[]
): CustomerInvoiceItemProps[] | undefined {
const items: CustomerInvoiceItemProps[] = [];
const languageCode = extractOrPushError(
LanguageCode.create(dto.language_code),
"language_code",
errors
);
const currencyCode = extractOrPushError(
CurrencyCode.create(dto.currency_code),
"currency_code",
errors
);
dto.items.forEach((item, index) => {
const description = extractOrPushError(
maybeFromNullableVO(item.description, (value) =>
CustomerInvoiceItemDescription.create(value)
),
"description",
errors
);
const quantity = extractOrPushError(
maybeFromNullableVO(item.quantity, (value) => CustomerInvoiceItemQuantity.create(value)),
"quantity",
errors
);
const unitAmount = extractOrPushError(
maybeFromNullableVO(item.unit_amount, (value) => CustomerInvoiceItemUnitAmount.create(value)),
"unit_amount",
errors
);
const discountPercentage = extractOrPushError(
maybeFromNullableVO(item.discount_percentage, (value) =>
CustomerInvoiceItemDiscount.create(value)
),
"discount_percentage",
errors
);
items.push({
currencyCode: currencyCode!,
languageCode: languageCode!,
description: description!,
quantity: quantity!,
unitAmount: unitAmount!,
discountPercentage: discountPercentage!,
});
});
return items;
}

View File

@ -1,8 +1,8 @@
import { UpdateCustomerInvoiceByIdResponseDTO } from "@erp/customer-invoices/common/dto";
import { toEmptyString } from "@repo/rdx-ddd";
import { GetCustomerInvoiceByIdResponseDTO } from "../../../../common/dto";
import { CustomerInvoice } from "../../../domain";
type GetCustomerInvoiceItemsByInvoiceIdResponseDTO = UpdateCustomerInvoiceByIdResponseDTO["items"];
type GetCustomerInvoiceItemsByInvoiceIdResponseDTO = GetCustomerInvoiceByIdResponseDTO["items"];
export class GetCustomerInvoiceItemsAssembler {
toDTO(invoice: CustomerInvoice): GetCustomerInvoiceItemsByInvoiceIdResponseDTO {

View File

@ -21,7 +21,7 @@ export class GetCustomerInvoiceAssembler {
status: invoice.status.toPrimitive(),
series: invoice.series.toString(),
issue_date: invoice.issueDate.toDateString(),
invoice_date: invoice.invoiceDate.toDateString(),
operation_date: toEmptyString(invoice.operationDate, (value) => value.toDateString()),
notes: toEmptyString(invoice.notes, (value) => value.toPrimitive()),
@ -65,46 +65,6 @@ export class GetCustomerInvoiceAssembler {
entity: "customer-invoices",
},
//subtotal: customerInvoice.calculateSubtotal().toPrimitive(),
//total: customerInvoice.calculateTotal().toPrimitive(),
/*items:
customerInvoice.items.size() > 0
? customerInvoice.items.map((item: CustomerInvoiceItem) => ({
description: item.description.toString(),
quantity: item.quantity.toPrimitive(),
unit_measure: "",
unit_price: item.unitPrice.toPrimitive(),
subtotal: item.calculateSubtotal().toPrimitive(),
//tax_amount: item.calculateTaxAmount().toPrimitive(),
total: item.calculateTotal().toPrimitive(),
}))
: [],*/
//sender: {}, //await CustomerInvoiceParticipantAssembler(customerInvoice.senderId, context),
/*recipient: await CustomerInvoiceParticipantAssembler(customerInvoice.recipient, context),
items: customerInvoiceItemAssembler(customerInvoice.items, context),
payment_term: {
payment_type: "",
due_date: "",
},
due_amount: {
currency: customerInvoice.currency.toString(),
precision: 2,
amount: 0,
},
custom_fields: [],
metadata: {
create_time: "",
last_updated_time: "",
delete_time: "",
},*/
};
}
}

View File

@ -36,7 +36,11 @@ export function mapDTOToCustomerInvoiceProps(dto: CreateCustomerInvoiceCommandDT
"invoice_series",
errors
);
const issueDate = extractOrPushError(UtcDate.createFromISO(dto.issue_date), "issue_date", errors);
const invoiceDate = extractOrPushError(
UtcDate.createFromISO(dto.invoice_date),
"invoice_date",
errors
);
const operationDate = extractOrPushError(
UtcDate.createFromISO(dto.operation_date),
"operation_date",
@ -59,7 +63,7 @@ export function mapDTOToCustomerInvoiceProps(dto: CreateCustomerInvoiceCommandDT
const invoiceProps: CustomerInvoiceProps = {
invoiceNumber: invoiceNumber!,
invoiceSeries: invoiceSeries!,
issueDate: issueDate!,
invoiceDate: invoiceDate!,
operationDate: operationDate!,
status: CustomerInvoiceStatus.createDraft(),
currency,

View File

@ -15,7 +15,7 @@ export class ListCustomerInvoicesAssembler {
invoice_status: invoice.status.toString(),
invoice_number: invoice.invoiceNumber.toString(),
invoice_series: invoice.invoiceSeries.toString(),
issue_date: invoice.issueDate.toISOString(),
invoice_date: invoice.invoiceDate.toISOString(),
operation_date: invoice.operationDate.toISOString(),
language_code: "ES",
currency: "EUR",

View File

@ -21,7 +21,7 @@ export class UpdateCustomerInvoiceAssembler {
status: invoice.status.toPrimitive(),
series: invoice.series.toString(),
issue_date: invoice.issueDate.toDateString(),
invoice_date: invoice.invoiceDate.toDateString(),
operation_date: toEmptyString(invoice.operationDate, (value) => value.toDateString()),
notes: toEmptyString(invoice.notes, (value) => value.toPrimitive()),

View File

@ -52,9 +52,9 @@ export class CreateCustomerInvoiceUseCase {
customerInvoice_series = CustomerInvoiceSeries.create(customerInvoiceDTO.customerInvoice_series).object;
}
let issue_date = CustomerInvoiceDate.create(customerInvoiceDTO.issue_date).object;
if (issue_date.isEmpty()) {
issue_date = CustomerInvoiceDate.createCurrentDate().object;
let invoice_date = CustomerInvoiceDate.create(customerInvoiceDTO.invoice_date).object;
if (invoice_date.isEmpty()) {
invoice_date = CustomerInvoiceDate.createCurrentDate().object;
}
let operation_date = CustomerInvoiceDate.create(customerInvoiceDTO.operation_date).object;
@ -96,7 +96,7 @@ export class CreateCustomerInvoiceUseCase {
return DraftCustomerInvoice.create(
{
customerInvoiceSeries: customerInvoice_series,
issueDate: issue_date,
invoiceDate: invoice_date,
operationDate: operation_date,
customerInvoiceCurrency,
language: customerInvoiceLanguage,
@ -285,9 +285,9 @@ export class UpdateCustomerInvoiceUseCase2
customerInvoice_series = CustomerInvoiceSeries.create(customerInvoiceDTO.customerInvoice_series).object;
}
let issue_date = CustomerInvoiceDate.create(customerInvoiceDTO.issue_date).object;
if (issue_date.isEmpty()) {
issue_date = CustomerInvoiceDate.createCurrentDate().object;
let invoice_date = CustomerInvoiceDate.create(customerInvoiceDTO.invoice_date).object;
if (invoice_date.isEmpty()) {
invoice_date = CustomerInvoiceDate.createCurrentDate().object;
}
let operation_date = CustomerInvoiceDate.create(customerInvoiceDTO.operation_date).object;
@ -329,7 +329,7 @@ export class UpdateCustomerInvoiceUseCase2
return DraftCustomerInvoice.create(
{
customerInvoiceSeries: customerInvoice_series,
issueDate: issue_date,
invoiceDate: invoice_date,
operationDate: operation_date,
customerInvoiceCurrency,
language: customerInvoiceLanguage,

View File

@ -23,7 +23,7 @@ export interface CustomerInvoiceProps {
status: CustomerInvoiceStatus;
series: Maybe<CustomerInvoiceSerie>;
issueDate: UtcDate;
invoiceDate: UtcDate;
operationDate: Maybe<UtcDate>;
notes: Maybe<TextValue>;
@ -33,7 +33,6 @@ export interface CustomerInvoiceProps {
//tax: Tax; // ? --> detalles?
//purchareOrderNumber: string;
//notes: Note;
//senderId: UniqueID;
@ -43,18 +42,18 @@ export interface CustomerInvoiceProps {
languageCode: LanguageCode;
currencyCode: CurrencyCode;
subtotalAmount: MoneyValue;
//subtotalAmount: MoneyValue;
discountPercentage: Percentage;
//discountAmount: MoneyValue;
//taxableAmount: MoneyValue;
taxAmount: MoneyValue;
//taxAmount: MoneyValue;
totalAmount: MoneyValue;
//totalAmount: MoneyValue;
//customer?: CustomerInvoiceCustomer;
items?: CustomerInvoiceItems;
items: CustomerInvoiceItems;
}
export type CustomerInvoicePatchProps = Partial<Omit<CustomerInvoiceProps, "companyId">>;
@ -107,8 +106,8 @@ export class CustomerInvoice extends AggregateRoot<CustomerInvoiceProps> {
return this.props.invoiceNumber;
}
public get issueDate(): UtcDate {
return this.props.issueDate;
public get invoiceDate(): UtcDate {
return this.props.invoiceDate;
}
public get operationDate(): Maybe<UtcDate> {
@ -128,7 +127,7 @@ export class CustomerInvoice extends AggregateRoot<CustomerInvoiceProps> {
}
public get subtotalAmount(): MoneyValue {
return this.props.subtotalAmount;
throw new Error("discountAmount not implemented");
}
public get discountPercentage(): Percentage {
@ -144,7 +143,7 @@ export class CustomerInvoice extends AggregateRoot<CustomerInvoiceProps> {
}
public get taxAmount(): MoneyValue {
return this.props.taxAmount;
throw new Error("discountAmount not implemented");
}
public get totalAmount(): MoneyValue {

View File

@ -1,4 +1,11 @@
import { CurrencyCode, DomainEntity, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import {
CurrencyCode,
DomainEntity,
LanguageCode,
MoneyValue,
Percentage,
UniqueID,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
CustomerInvoiceItemDescription,
@ -12,8 +19,8 @@ import {
export interface CustomerInvoiceItemProps {
description: Maybe<CustomerInvoiceItemDescription>;
quantity: Maybe<CustomerInvoiceItemQuantity>; // Cantidad de unidades
unitPrice: Maybe<CustomerInvoiceItemUnitAmount>; // Precio unitario en la moneda de la factura
discount: Maybe<CustomerInvoiceItemDiscount>; // % descuento
unitAmount: Maybe<CustomerInvoiceItemUnitAmount>; // Precio unitario en la moneda de la factura
discountPercentage: Maybe<CustomerInvoiceItemDiscount>; // % descuento
languageCode: LanguageCode;
currencyCode: CurrencyCode;
@ -49,7 +56,7 @@ export class CustomerInvoiceItem extends DomainEntity<CustomerInvoiceItemProps>
}
get unitAmount(): Maybe<CustomerInvoiceItemUnitAmount> {
return this.props.unitPrice;
return this.props.unitAmount;
}
get subtotalAmount(): CustomerInvoiceItemSubtotalAmount {
@ -59,8 +66,12 @@ export class CustomerInvoiceItem extends DomainEntity<CustomerInvoiceItemProps>
return this._subtotalAmount;
}
get discountPercentage(): Maybe<CustomerInvoiceItemDiscount> {
return this.props.discount;
get discountPercentage(): Maybe<Percentage> {
return this.props.discountPercentage;
}
get discountAmount(): Maybe<MoneyValue> {
throw new Error("Not implemented");
}
get totalAmount(): CustomerInvoiceItemTotalAmount {

View File

@ -1,3 +0,0 @@
import { Quantity } from "@repo/rdx-ddd";
export class CustomerInvoiceItemQuantity extends Quantity {}

View File

@ -3,10 +3,10 @@ import { MoneyValue, MoneyValueProps } from "@repo/rdx-ddd";
export class CustomerInvoiceItemSubtotalAmount extends MoneyValue {
public static DEFAULT_SCALE = 4;
static create({ value: amount, currency_code, scale }: MoneyValueProps) {
static create({ value, currency_code }: MoneyValueProps) {
const props = {
amount: Number(amount),
scale: scale ?? MoneyValue.DEFAULT_SCALE,
value: Number(value),
scale: CustomerInvoiceItemSubtotalAmount.DEFAULT_SCALE,
currency_code,
};
return MoneyValue.create(props);

View File

@ -1,6 +1,6 @@
import { MoneyValue, MoneyValueProps } from "@repo/rdx-ddd";
export class CustomerInvoiceItemTotalAmount extends MoneyValue {
export class ItemTotalAmount extends MoneyValue {
public static DEFAULT_SCALE = 4;
static create({ value, currency_code, scale }: MoneyValueProps) {

View File

@ -1,10 +1,10 @@
export * from "./customer-invoice-address-type";
export * from "./customer-invoice-item-description";
export * from "./customer-invoice-item-discount";
export * from "./customer-invoice-item-quantity";
export * from "./customer-invoice-item-subtotal-amount";
export * from "./customer-invoice-item-total-amount";
export * from "./customer-invoice-item-unit-amount";
export * from "./customer-invoice-number";
export * from "./customer-invoice-serie";
export * from "./customer-invoice-status";
export * from "./item-quantity";

View File

@ -0,0 +1,13 @@
import { Quantity, QuantityProps } from "@repo/rdx-ddd";
export class ItemQuantity extends Quantity {
public static DEFAULT_SCALE = 2;
static create({ value }: QuantityProps) {
const props = {
value: Number(value),
scale: ItemQuantity.DEFAULT_SCALE,
};
return Quantity.create(props);
}
}

View File

@ -1,18 +1,16 @@
import type { ModuleParams } from "@erp/core/api";
import { SequelizeTransactionManager } from "@erp/core/api";
import {
CreateCustomerInvoiceAssembler,
CreateCustomerInvoiceUseCase,
CreateCustomerInvoicesAssembler,
DeleteCustomerInvoiceUseCase,
GetCustomerInvoiceAssembler,
GetCustomerInvoiceItemsAssembler,
GetCustomerInvoiceUseCase,
ListCustomerInvoicesAssembler,
ListCustomerInvoicesUseCase,
UpdateCustomerInvoiceAssembler,
UpdateCustomerInvoiceUseCase,
} from "../application";
import { CustomerInvoiceService, ICustomerInvoiceService } from "../domain";
import { CustomerInvoiceService } from "../domain";
import { CustomerInvoiceMapper } from "./mappers";
import { CustomerInvoiceRepository } from "./sequelize";
@ -20,11 +18,11 @@ type InvoiceDeps = {
transactionManager: SequelizeTransactionManager;
repo: CustomerInvoiceRepository;
mapper: CustomerInvoiceMapper;
service: ICustomerInvoiceService;
service: CustomerInvoiceService;
assemblers: {
list: ListCustomerInvoicesAssembler;
get: GetCustomerInvoiceItemsAssembler;
create: CreateCustomerInvoicesAssembler;
get: GetCustomerInvoiceAssembler;
create: CreateCustomerInvoiceAssembler;
update: UpdateCustomerInvoiceAssembler;
};
build: {
@ -41,7 +39,7 @@ type InvoiceDeps = {
let _repo: CustomerInvoiceRepository | null = null;
let _mapper: CustomerInvoiceMapper | null = null;
let _service: ICustomerInvoiceService | null = null;
let _service: CustomerInvoiceService | null = null;
let _assemblers: InvoiceDeps["assemblers"] | null = null;
export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps {
@ -56,7 +54,7 @@ export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps {
_assemblers = {
list: new ListCustomerInvoicesAssembler(), // transforma domain → ListDTO
get: new GetCustomerInvoiceAssembler(), // transforma domain → DetailDTO
create: new CreateCustomerInvoicesAssembler(), // transforma domain → CreatedDTO
create: new CreateCustomerInvoiceAssembler(), // transforma domain → CreatedDTO
update: new UpdateCustomerInvoiceAssembler(), // transforma domain -> UpdateDTO
};
}

View File

@ -4,25 +4,17 @@ import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto";
import { CreateCustomerInvoiceUseCase } from "../../../application";
export class CreateCustomerInvoiceController extends ExpressController {
public constructor(
private readonly useCase: CreateCustomerInvoiceUseCase
/* private readonly presenter: any */
) {
public constructor(private readonly useCase: CreateCustomerInvoiceUseCase) {
super();
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.useGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
}
protected async executeImpl() {
const tenantId = this.getTenantId()!; // garantizado por tenantGuard
const companyId = this.getTenantId()!; // garantizado por tenantGuard
const dto = this.req.body as CreateCustomerInvoiceRequestDTO;
/*
// Inyectar empresa del usuario autenticado (ownership)
dto.customerCompanyId = user.companyId;
*/
const result = await this.useCase.execute({ tenantId, dto });
const result = await this.useCase.execute({ dto, companyId });
return result.match(
(data) => this.created(data),

View File

@ -1,5 +1,11 @@
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import {
ISequelizeMapper,
MapperParamsType,
SequelizeMapper,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { CurrencyCode, LanguageCode, Quantity, UniqueID, maybeFromNullableVO, toNullable } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { InferCreationAttributes } from "sequelize";
import {
@ -35,70 +41,75 @@ export class CustomerInvoiceItemMapper
source: CustomerInvoiceItemModel,
params?: MapperParamsType
): Result<CustomerInvoiceItem, Error> {
const { sourceParent } = params as { sourceParent: CustomerInvoiceModel };
const { sourceParent, errors } = params as {
sourceParent: CustomerInvoiceModel;
errors: ValidationErrorDetail[];
};
// Validación y creación de ID único
const idOrError = UniqueID.create(source.item_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const itemId = extractOrPushError(UniqueID.create(source.item_id), "item_id", errors);
// Validación y creación de descripción
const descriptionOrError = CustomerInvoiceItemDescription.create(source.description || "");
if (descriptionOrError.isFailure) {
return Result.fail(descriptionOrError.error);
}
const languageCode = extractOrPushError(
LanguageCode.create(sourceParent.language_code),
"language_code",
errors
);
// Validación y creación de cantidad
const quantityOrError = CustomerInvoiceItemQuantity.create({
amount: source.quantity_amount,
scale: source.quantity_scale,
});
if (quantityOrError.isFailure) {
return Result.fail(quantityOrError.error);
}
const currencyCode = extractOrPushError(
CurrencyCode.create(sourceParent.currency_code),
"currency_code",
errors
);
// Validación y creación de precio unitario
const unitPriceOrError = CustomerInvoiceItemUnitAmount.create({
value: source.unit_price_amount,
scale: source.unit_price_scale,
currency_code: sourceParent.invoice_currency,
});
if (unitPriceOrError.isFailure) {
return Result.fail(unitPriceOrError.error);
}
const description = extractOrPushError(
maybeFromNullableVO(source.description, (value) =>
CustomerInvoiceItemDescription.create(value)
),
"description",
errors
);
// Validación y creación de descuento
const discountOrError = CustomerInvoiceItemDiscount.create({
value: source.discount_amount || 0,
scale: source.discount_scale || 0,
});
if (discountOrError.isFailure) {
return Result.fail(discountOrError.error);
}
const quantity = extractOrPushError(
maybeFromNullableVO(source.quantity_value, (value) =>
Quantity.create({ value, })
// Combinación de resultados
const result = Result.combine([
idOrError,
descriptionOrError,
quantityOrError,
unitPriceOrError,
discountOrError,
]);
CustomerInvoiceItemQuantity.create({
value: source.quantity_amouwnt,
scale: source.quantity_scale,
}),
"discount_percentage",
errors
);
const unitAmount = extractOrPushError(
CustomerInvoiceItemUnitAmount.create({
value: source.unit_price_amount,
scale: source.unit_price_scale,
}),
"discount_percentage",
errors
);
const discountPercentage = extractOrPushError(
CustomerInvoiceItemDiscount.create({
value: source.discount_amount,
scale: source.discount_scale,
}),
"discount_percentage",
errors
);
if (result.isFailure) {
return Result.fail(result.error);
}
// Creación del objeto de dominio
return CustomerInvoiceItem.create(
{
description: descriptionOrError.data,
quantity: quantityOrError.data,
unitPrice: unitPriceOrError.data,
discount: discountOrError.data,
},
idOrError.data
languageCode: languageCode!,
currencyCode: currencyCode!,
description: description!,
quantity: quantity!,
unitAmount: unitAmount!,
discountPercentage: discountPercentage!,
},º
id
);
}
@ -106,35 +117,44 @@ export class CustomerInvoiceItemMapper
source: CustomerInvoiceItem,
params?: MapperParamsType
): InferCreationAttributes<CustomerInvoiceItemModel, {}> {
1
const { index, sourceParent } = params as {
index: number;
sourceParent: CustomerInvoice;
};
const lineData = {
parent_id: undefined,
return {
item_id: source.id.toPrimitive(),
invoice_id: sourceParent.id.toPrimitive(),
item_type: "simple",
position: index,
item_id: source.id.toPrimitive(),
description: source.description.toPrimitive(),
description: toNullable(source.description, (description) => description.toPrimitive()),
quantity_amount: source.quantity.toPrimitive().amount,
quantity_scale: source.quantity.toPrimitive().scale,
quantity_value: toNullable(source.quantity, (value) => value.toPrimitive().value),
quantity_scale: source.quantity.match(
(value) => value.toPrimitive().scale,
() => CustomerInvoiceItemQuantity.DEFAULT_SCALE),
unit_price_amount: source.unitAmount.toPrimitive().amount,
unit_price_scale: source.unitAmount.toPrimitive().scale,
unit_amount_value: toNullable(source.unitAmount, (value) => value.toPrimitive().value),
unit_amount_scale: source.unitAmount.match(
(value) => value.toPrimitive().scale,
() => CustomerInvoiceItemUnitAmount.DEFAULT_SCALE),
subtotal_amount: source.subtotalAmount.toPrimitive().amount,
subtotal_scale: source.subtotalAmount.toPrimitive().scale,
subtotal_amount_value: source.subtotalAmount.toPrimitive().value,
subtotal_amount_scale: source.subtotalAmount.toPrimitive().scale,
discount_amount: source.discount.toPrimitive().amount,
discount_scale: source.discount.toPrimitive().scale,
discount_percentage_value: toNullable(source.discountPercentage, (value) => value.toPrimitive().value),
discount_percentage_scale: source.discountPercentage.match(
(value) => value.toPrimitive().scale,
() => CustomerInvoiceItemUnitAmount.DEFAULT_SCALE),
total_amount: source.totalAmount.toPrimitive().amount,
total_scale: source.totalAmount.toPrimitive().scale,
discount_amount_value: source.subtotalAmount.toPrimitive().value,
discount_amount_scale: source.subtotalAmount.toPrimitive().scale,
total_amount_value: source.totalAmount.toPrimitive().value,
total_amount_scale: source.totalAmount.toPrimitive().scale,
};
return lineData;
}
}

View File

@ -6,7 +6,15 @@ import {
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { CurrencyCode, LanguageCode, UniqueID, UtcDate, maybeFromNullableVO } from "@repo/rdx-ddd";
import {
CurrencyCode,
LanguageCode,
Percentage,
TextValue,
UniqueID,
UtcDate,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
CustomerInvoice,
@ -29,86 +37,125 @@ export class CustomerInvoiceMapper
extends SequelizeMapper<CustomerInvoiceModel, CustomerInvoiceCreationAttributes, CustomerInvoice>
implements ICustomerInvoiceMapper
{
private customerInvoiceItemMapper: CustomerInvoiceItemMapper;
private _itemsMapper: CustomerInvoiceItemMapper;
constructor() {
super();
this.customerInvoiceItemMapper = new CustomerInvoiceItemMapper(); // Instanciar el mapper de items
this._itemsMapper = new CustomerInvoiceItemMapper(); // Instanciar el mapper de items
}
public mapToDomain(
source: CustomerInvoiceModel,
params?: MapperParamsType
): Result<CustomerInvoice, Error> {
const errors: ValidationErrorDetail[] = [];
try {
const errors: ValidationErrorDetail[] = [];
const invoiceId = extractOrPushError(UniqueID.create(source.id), "id", errors);
const companyId = extractOrPushError(UniqueID.create(source.company_id), "company_id", errors);
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)
const invoiceId = extractOrPushError(UniqueID.create(source.id), "id", errors);
const companyId = extractOrPushError(
UniqueID.create(source.company_id),
"company_id",
errors
);
const status = extractOrPushError(
CustomerInvoiceStatus.create(source.status),
"status",
errors
);
const series = extractOrPushError(
maybeFromNullableVO(source.series, (value) => CustomerInvoiceSerie.create(value)),
"serie",
errors
);
const invoiceNumber = extractOrPushError(
CustomerInvoiceNumber.create(source.invoice_number),
"invoice_number",
errors
);
const invoiceDate = extractOrPushError(
UtcDate.createFromISO(source.invoice_date),
"invoice_date",
errors
);
const operationDate = extractOrPushError(
maybeFromNullableVO(source.operation_date, (value) => UtcDate.createFromISO(value)),
"operation_date",
errors
);
const notes = extractOrPushError(
maybeFromNullableVO(source.notes, (value) => TextValue.create(value)),
"notes",
errors
);
const languageCode = extractOrPushError(
LanguageCode.create(source.language_code),
"language_code",
errors
);
const currencyCode = extractOrPushError(
CurrencyCode.create(source.currency_code),
"currency_code",
errors
);
const discountPercentage = extractOrPushError(
Percentage.create({
value: source.discount_percentage_value,
scale: source.discount_percentage_scale,
}),
"discount_percentage",
errors
);
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer invoice props mapping failed", errors)
);
}
// Mapear los items de la factura
const items = this._itemsMapper.mapArrayToDomain(source.items, {
sourceParent: source,
errors,
...params,
});
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer invoice item props mapping failed", errors)
);
}
const invoiceProps: CustomerInvoiceProps = {
companyId: companyId!,
status: status!,
series: series!,
invoiceNumber: invoiceNumber!,
invoiceDate: invoiceDate!,
operationDate: operationDate!,
notes: notes!,
languageCode: languageCode!,
currencyCode: currencyCode!,
discountPercentage: discountPercentage!,
items: items!,
};
return CustomerInvoice.create(invoiceProps, invoiceId);
} catch (err: unknown) {
return Result.fail(err as Error);
}
// Mapear los items de la factura
/*const itemsOrErrors = this.customerInvoiceItemMapper.mapArrayToDomain(source.items, {
sourceParent: source,
...params,
});
if (itemsOrErrors.isFailure) {
return Result.fail(itemsOrErrors.error);
}*/
const invoiceProps: CustomerInvoiceProps = {
status: status!,
series: series!,
invoiceNumber: invoiceNumber!,
issueDate: issueDate!,
operationDate: operationDate!,
languageCode: languageCode!,
currencyCode: currencyCode!,
//items: itemsOrErrors.data,
};
return CustomerInvoice.create(invoiceProps, invoiceId);
}
public mapToPersistence(
@ -118,14 +165,14 @@ export class CustomerInvoiceMapper
const subtotal = source.calculateSubtotal();
const total = source.calculateTotal();
const items = this.customerInvoiceItemMapper.mapCollectionToPersistence(source.items, params);
const items = this._itemsMapper.mapCollectionToPersistence(source.items, params);
return {
id: source.id.toString(),
invoice_status: source.status.toPrimitive(),
invoice_series: source.invoiceSeries.toPrimitive(),
invoice_number: source.invoiceNumber.toPrimitive(),
issue_date: source.issueDate.toPrimitive(),
invoice_date: source.invoiceDate.toPrimitive(),
operation_date: source.operationDate.toPrimitive(),
invoice_language: "es",
invoice_currency: source.currency || "EUR",

View File

@ -7,7 +7,7 @@ const ALLOWED_FILTERS = {
customerId: "customer_id",
invoiceSeries: "invoice_series",
invoiceNumber: "invoice_number",
issueDate: "issue_date",
invoiceDate: "invoice_date",
status: "status",
currencyCode: "currency_code",
// Rango por total (en unidades menores)
@ -15,8 +15,8 @@ const ALLOWED_FILTERS = {
} as const;
const ALLOWED_SORT: Record<string, string | string[]> = {
// Sort "issueDate" realmente ordena por (issue_date DESC, invoice_series ASC, invoice_number DESC, id DESC)
issueDate: ["issue_date"],
// Sort "invoiceDate" realmente ordena por (invoice_date DESC, invoice_series ASC, invoice_number DESC, id DESC)
invoiceDate: ["invoice_date"],
invoiceNumber: ["invoice_number"],
invoiceSeries: ["invoice_series"],
status: ["status"],
@ -31,7 +31,7 @@ export const DEFAULT_LIST_ATTRIBUTES = [
"customer_id",
"invoice_series",
"invoice_number",
"issue_date",
"invoice_date",
"status",
"total_amount_value",
"total_amount_scale",
@ -47,7 +47,7 @@ type Sanitized = {
attributes: (string | any)[];
// keyset opcional
keyset?: {
after?: { issueDate: string; invoiceSeries: string; invoiceNumber: number; id: string };
after?: { invoiceDate: string; invoiceSeries: string; invoiceNumber: number; id: string };
};
};
@ -114,9 +114,9 @@ export function sanitizeListCriteria(criteria: Criteria): Sanitized {
.split(",")
.filter(Boolean);
if (sortArray.length === 0) {
// orden por defecto: issue_date desc, invoice_series asc, invoice_number desc, id desc
// orden por defecto: invoice_date desc, invoice_series asc, invoice_number desc, id desc
order.push(
["issue_date", "DESC"],
["invoice_date", "DESC"],
["invoice_series", "ASC"],
["invoice_number", "DESC"],
["id", "DESC"]

View File

@ -0,0 +1,107 @@
import {
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { CustomerInvoiceItem } from "../../domain";
export type CustomerInvoiceItemTaxesCreationAttributes = InferCreationAttributes<
CustomerInvoiceItemTaxesModel,
{}
>;
export class CustomerInvoiceItemTaxesModel extends Model<
InferAttributes<CustomerInvoiceItemTaxesModel>,
InferCreationAttributes<CustomerInvoiceItemTaxesModel>
> {
declare id: string;
declare item_id: string;
declare tax_code: string; //"iva_21"
// Taxable amount (base imponible) // 100,00 €
declare taxable_amount_value: number;
declare taxable_amount_scale: number;
// Total tax amount / taxes total // 21,00 €
declare tax_amount_value: number;
declare tax_amount_scale: number;
// Relaciones
declare item: NonAttribute<CustomerInvoiceItem>;
static associate(database: Sequelize) {
const { CustomerInvoiceItemModel } = database.models;
CustomerInvoiceItemTaxesModel.belongsTo(CustomerInvoiceItemModel, {
as: "item",
targetKey: "id",
foreignKey: "item_id",
onDelete: "CASCADE",
});
}
static hooks(database: Sequelize) {}
}
export default (database: Sequelize) => {
CustomerInvoiceItemTaxesModel.init(
{
id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
item_id: {
type: DataTypes.UUID,
allowNull: false,
},
tax_code: {
type: new DataTypes.STRING(),
allowNull: false,
},
taxable_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
taxable_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
},
tax_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
tax_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
},
},
{
sequelize: database,
tableName: "customer_invoices_item_taxes",
underscored: true,
indexes: [{ name: "tax_code_idx", fields: ["tax_code"], unique: false }],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return CustomerInvoiceItemTaxesModel;
};

View File

@ -20,26 +20,39 @@ export class CustomerInvoiceItemModel extends Model<
declare item_id: string;
declare invoice_id: string;
declare parent_id: string;
declare position: number;
declare item_type: string;
declare description: string;
declare quantity_amount: number;
declare quantity_value: number;
declare quantity_scale: number;
declare unit_price_amount: number;
declare unit_price_scale: number;
declare unit_amount_value: number;
declare unit_amount_scale: number;
declare subtotal_amount: number;
declare subtotal_scale: number;
// Subtotal
declare subtotal_amount_value: number;
declare subtotal_amount_scale: number;
declare discount_amount: number;
declare discount_scale: number;
// Discount percentage
declare discount_percentage_value: number;
declare discount_percentage_scale: number;
declare total_amount: number;
declare total_scale: number;
// Discount amount
declare discount_amount_value: number;
declare discount_amount_scale: number;
// Taxable amount (base imponible)
declare taxable_amount_value: number;
declare taxable_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;
declare invoice: NonAttribute<CustomerInvoiceModel>;
@ -62,101 +75,119 @@ export default (database: Sequelize) => {
type: new DataTypes.UUID(),
primaryKey: true,
},
invoice_id: {
type: new DataTypes.UUID(),
allowNull: false,
},
parent_id: {
type: new DataTypes.UUID(),
allowNull: true, // Puede ser nulo para elementos de nivel superior
},
position: {
type: new DataTypes.MEDIUMINT().UNSIGNED,
autoIncrement: false,
allowNull: false,
},
item_type: {
type: new DataTypes.STRING(),
allowNull: false,
defaultValue: "simple",
},
description: {
type: new DataTypes.TEXT(),
allowNull: true,
defaultValue: null,
},
quantity_amount: {
quantity_value: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
quantity_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 2,
},
unit_price_amount: {
unit_amount_value: {
type: new DataTypes.BIGINT(),
allowNull: true,
defaultValue: null,
},
unit_price_scale: {
unit_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 4,
},
/*tax_slug: {
type: new DataTypes.DECIMAL(3, 2),
allowNull: true,
},
tax_rate: {
type: new DataTypes.DECIMAL(3, 2),
allowNull: true,
},
tax_equalization: {
type: new DataTypes.DECIMAL(3, 2),
allowNull: true,
},*/
subtotal_amount: {
subtotal_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
subtotal_scale: {
subtotal_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
discount_percentage_value: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
discount_amount: {
discount_percentage_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
},
discount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 2,
},
/*tax_amount: {
type: new DataTypes.BIGINT(),
allowNull: true,
},*/
total_amount: {
discount_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
total_scale: {
discount_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
taxable_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
taxable_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
taxes_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
taxes_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
total_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
total_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
},
{
sequelize: database,

View File

@ -0,0 +1,107 @@
import {
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { CustomerInvoice } from "../../domain";
export type CustomerInvoiceTaxesCreationAttributes = InferCreationAttributes<
CustomerInvoiceTaxesModel,
{}
>;
export class CustomerInvoiceTaxesModel extends Model<
InferAttributes<CustomerInvoiceTaxesModel>,
InferCreationAttributes<CustomerInvoiceTaxesModel>
> {
declare id: string;
declare invoice_id: string;
declare tax_code: string; //"iva_21"
// Taxable amount (base imponible) // 100,00 €
declare taxable_amount_value: number;
declare taxable_amount_scale: number;
// Total tax amount / taxes total // 21,00 €
declare tax_amount_value: number;
declare tax_amount_scale: number;
// Relaciones
declare invoice: NonAttribute<CustomerInvoice>;
static associate(database: Sequelize) {
const { CustomerInvoiceModel } = database.models;
CustomerInvoiceTaxesModel.belongsTo(CustomerInvoiceModel, {
as: "invoice",
targetKey: "id",
foreignKey: "invoice_id",
onDelete: "CASCADE",
});
}
static hooks(database: Sequelize) {}
}
export default (database: Sequelize) => {
CustomerInvoiceTaxesModel.init(
{
id: {
type: new DataTypes.UUID(),
primaryKey: true,
},
invoice_id: {
type: DataTypes.UUID,
allowNull: false,
},
tax_code: {
type: new DataTypes.STRING(),
allowNull: false,
},
taxable_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
taxable_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
},
tax_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
},
tax_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2,
},
},
{
sequelize: database,
tableName: "customer_invoices_taxes",
underscored: true,
indexes: [{ name: "tax_code_idx", fields: ["tax_code"], unique: false }],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return CustomerInvoiceTaxesModel;
};

View File

@ -28,11 +28,13 @@ export class CustomerInvoiceModel extends Model<
declare status: string;
declare series: string;
declare invoice_number: string;
declare issue_date: string;
declare invoice_date: string;
declare operation_date: string;
declare language_code: string;
declare currency_code: string;
declare notes: string;
// Subtotal
declare subtotal_amount_value: number;
declare subtotal_amount_scale: number;
@ -117,7 +119,7 @@ export default (database: Sequelize) => {
defaultValue: null,
},
issue_date: {
invoice_date: {
type: new DataTypes.DATEONLY(),
allowNull: true,
defaultValue: null,
@ -138,18 +140,23 @@ export default (database: Sequelize) => {
currency_code: {
type: new DataTypes.STRING(3),
allowNull: false,
defaultValue: "EUR",
},
notes: {
type: new DataTypes.TEXT(),
allowNull: true,
defaultValue: null,
},
subtotal_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 0,
},
subtotal_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 2,
},
discount_percentage_value: {
@ -160,8 +167,8 @@ export default (database: Sequelize) => {
discount_percentage_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 2,
},
discount_amount_value: {
@ -172,8 +179,8 @@ export default (database: Sequelize) => {
discount_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 2,
},
taxable_amount_value: {
@ -183,8 +190,8 @@ export default (database: Sequelize) => {
},
taxable_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 2,
},
tax_amount_value: {
@ -194,8 +201,8 @@ export default (database: Sequelize) => {
},
tax_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 2,
},
total_amount_value: {
@ -206,8 +213,8 @@ export default (database: Sequelize) => {
total_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: 2,
},
},
{

View File

@ -1,36 +1,39 @@
import { NumericStringSchema, PercentageSchema } from "@erp/core";
import * as z from "zod/v4";
export const CreateCustomerInvoiceRequestSchema = z.object({
id: z.uuid(),
invoice_status: z.string(),
invoice_number: z.string().min(1, "Customer invoice number is required"),
invoice_series: z.string().min(1, "Customer invoice series is required"),
issue_date: z.string().datetime({ offset: true, message: "Invalid issue date format" }),
operation_date: z.string().datetime({ offset: true, message: "Invalid operation date format" }),
description: z.string(),
language_code: z.string().min(2, "Language code must be at least 2 characters long"),
currency_code: z.string().min(3, "Currency code must be at least 3 characters long"),
notes: z.string().optional(),
items: z.array(
z.object({
description: z.string().min(1, "Item description is required"),
quantity: z.object({
value: z.number().positive("Quantity amount must be positive"),
scale: z.number().int().nonnegative("Quantity scale must be a non-negative integer"),
}),
unit_amount: z.object({
value: z.number().positive("Unit price amount must be positive"),
scale: z.number().int().nonnegative("Unit price scale must be a non-negative integer"),
currency_code: z
.string()
.min(3, "Unit price currency code must be at least 3 characters long"),
}),
discount_percentage: z.object({
value: z.number().nonnegative("Discount amount cannot be negative"),
scale: z.number().int().nonnegative("Discount scale must be a non-negative integer"),
}),
})
),
company_id: z.uuid(),
invoice_number: z.string(),
status: z.string().default("draft"),
series: z.string().default(""),
invoice_date: z.string(),
operation_date: z.string().default(""),
notes: z.string().default(""),
language_code: z.string().toLowerCase().default("es"),
currency_code: z.string().toUpperCase().default("EUR"),
discount_percentage: PercentageSchema.default({
value: "0",
scale: "2",
}),
items: z
.array(
z.object({
id: z.uuid(),
position: z.string(),
description: z.string().default(""),
quantity: NumericStringSchema.default(""),
unit_amount: NumericStringSchema.default(""),
discount_percentage: NumericStringSchema.default(""),
})
)
.default([]),
});
export type CreateCustomerInvoiceRequestDTO = z.infer<typeof CreateCustomerInvoiceRequestSchema>;

View File

@ -0,0 +1,42 @@
import { AmountSchema, MetadataSchema, PercentageSchema, QuantitySchema } from "@erp/core";
import * as z from "zod/v4";
export const CreateCustomerInvoiceResponseSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
invoice_number: z.string(),
status: z.string(),
series: z.string(),
invoice_date: z.string(),
operation_date: z.string(),
notes: z.string(),
language_code: z.string(),
currency_code: z.string(),
subtotal_amount: AmountSchema,
discount_percentage: PercentageSchema,
discount_amount: AmountSchema,
taxable_amount: AmountSchema,
tax_amount: AmountSchema,
total_amount: AmountSchema,
items: z.array(
z.object({
id: z.uuid(),
position: z.string(),
description: z.string(),
quantity: QuantitySchema,
unit_amount: AmountSchema,
discount_percentage: PercentageSchema,
total_amount: AmountSchema,
})
),
metadata: MetadataSchema.optional(),
});
export type CreateCustomerInvoiceResponseDTO = z.infer<typeof CreateCustomerInvoiceResponseSchema>;

View File

@ -1,19 +0,0 @@
import { MetadataSchema } from "@erp/core";
import * as z from "zod/v4";
export const CustomerInvoicesCreationResponseSchema = z.object({
id: z.uuid(),
invoice_status: z.string(),
invoice_number: z.string(),
invoice_series: z.string(),
issue_date: z.iso.datetime({ offset: true }),
operation_date: z.iso.datetime({ offset: true }),
language_code: z.string(),
currency: z.string(),
metadata: MetadataSchema.optional(),
});
export type CustomerInvoicesCreationResponseDTO = z.infer<
typeof CustomerInvoicesCreationResponseSchema
>;

View File

@ -7,7 +7,7 @@ export const CustomerInvoiceListResponseSchema = createListViewResponseSchema(
invoice_status: z.string(),
invoice_number: z.string(),
invoice_series: z.string(),
issue_date: z.iso.datetime({ offset: true }),
invoice_date: z.iso.datetime({ offset: true }),
operation_date: z.iso.datetime({ offset: true }),
language_code: z.string(),
currency: z.string(),

View File

@ -9,7 +9,7 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
status: z.string(),
series: z.string(),
issue_date: z.string(),
invoice_date: z.string(),
operation_date: z.string(),
notes: z.string(),

View File

@ -1,4 +1,4 @@
export * from "./customer-invoice-creation.response.dto";
export * from "./create-customer-invoice.response.dto";
export * from "./customer-invoices-list.response.dto";
export * from "./get-customer-invoice-by-id.response.dto";
export * from "./update-customer-invoice-by-id.response.dto";

View File

@ -9,7 +9,7 @@ export const UpdateCustomerInvoiceByIdResponseSchema = z.object({
status: z.string(),
series: z.string(),
issue_date: z.string(),
invoice_date: z.string(),
operation_date: z.string(),
notes: z.string(),

View File

@ -17,7 +17,7 @@
"invoice_number": "Inv. number",
"invoice_series": "Serie",
"invoice_status": "Status",
"issue_date": "Date",
"invoice_date": "Date",
"total_price": "Total price"
}
},
@ -52,7 +52,7 @@
"placeholder": "",
"description": ""
},
"issue_date": {
"invoice_date": {
"label": "Date",
"placeholder": "Select a date",
"description": "Invoice issue date"

View File

@ -35,8 +35,8 @@ export const CustomerInvoicesListGrid = () => {
{ field: "invoice_series", headerName: t("pages.list.grid_columns.invoice_series") },
{
field: "issue_date",
headerName: t("pages.list.grid_columns.issue_date"),
field: "invoice_date",
headerName: t("pages.list.grid_columns.invoice_date"),
valueFormatter: (params: ValueFormatterParams) => {
return formatDate(params.value);
},

View File

@ -49,7 +49,7 @@ const invoiceFormSchema = z.object({
invoice_status: z.string(),
invoice_number: z.string().min(1, "Número de factura requerido"),
invoice_series: z.string().min(1, "Serie requerida"),
issue_date: z.string(),
invoice_date: z.string(),
operation_date: z.string(),
language_code: z.string(),
currency: z.string(),
@ -144,7 +144,7 @@ const defaultInvoiceData = {
invoice_status: "draft",
invoice_number: "1",
invoice_series: "A",
issue_date: "2025-04-30T00:00:00.000Z",
invoice_date: "2025-04-30T00:00:00.000Z",
operation_date: "2025-04-30T00:00:00.000Z",
description: "",
language_code: "ES",
@ -332,11 +332,11 @@ export const CustomerInvoiceEditForm = ({
<DatePickerInputField
control={form.control}
name='issue_date'
name='invoice_date'
required
label={t("form_fields.issue_date.label")}
placeholder={t("form_fields.issue_date.placeholder")}
description={t("form_fields.issue_date.description")}
label={t("form_fields.invoice_date.label")}
placeholder={t("form_fields.invoice_date.placeholder")}
description={t("form_fields.invoice_date.description")}
/>
<TextField
@ -429,11 +429,11 @@ export const CustomerInvoiceEditForm = ({
<DatePickerInputField
control={form.control}
name='issue_date'
name='invoice_date'
required
label={t("form_fields.issue_date.label")}
placeholder={t("form_fields.issue_date.placeholder")}
description={t("form_fields.issue_date.description")}
label={t("form_fields.invoice_date.label")}
placeholder={t("form_fields.invoice_date.placeholder")}
description={t("form_fields.invoice_date.description")}
/>
<TextField
@ -815,14 +815,14 @@ export const CustomerInvoiceEditForm = ({
<PopoverTrigger asChild>
<Button variant='outline' className='w-full justify-start text-left font-normal'>
<CalendarIcon className='mr-2 h-4 w-4' />
{format(issueDate, "PPP", { locale: es })}
{format(invoiceDate, "PPP", { locale: es })}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='single'
selected={issueDate}
onSelect={(date) => handleDateChange("issue_date", date)}
selected={invoiceDate}
onSelect={(date) => handleDateChange("invoice_date", date)}
initialFocus
/>
</PopoverContent>

View File

@ -8,8 +8,8 @@ import { CreateCustomersAssembler } from "./assembler";
import { mapDTOToCreateCustomerProps } from "./map-dto-to-create-customer-props";
type CreateCustomerUseCaseInput = {
dto: CreateCustomerRequestDTO;
companyId: UniqueID;
dto: CreateCustomerRequestDTO;
};
export class CreateCustomerUseCase {
@ -30,7 +30,7 @@ export class CreateCustomerUseCase {
const { props, id } = dtoResult.data;
// 3) Construir entidad de dominio
// 2) Construir entidad de dominio
const buildResult = this.service.buildCustomerInCompany(companyId, props, id);
if (buildResult.isFailure) {
return Result.fail(buildResult.error);
@ -38,7 +38,7 @@ export class CreateCustomerUseCase {
const newCustomer = buildResult.data;
// 4) Ejecutar bajo transacción: verificar duplicado → persistir → ensamblar vista
// 3) Ejecutar bajo transacción: verificar duplicado → persistir → ensamblar vista
return this.transactionManager.complete(async (transaction: Transaction) => {
const existsGuard = await this.ensureNotExists(companyId, id, transaction);
if (existsGuard.isFailure) {

View File

@ -16,7 +16,7 @@ export const updateCustomerPresenter: IUpdateCustomerPresenter = {
customer_status: customer.status.toString(),
customer_number: customer.customerNumber.toString(),
customer_series: customer.customerSeries.toString(),
issue_date: customer.issueDate.toISO8601(),
invoice_date: customer.invoiceDate.toISO8601(),
operation_date: customer.operationDate.toISO8601(),
language_code: customer.language.toString(),
currency: customer.currency.toString(),

View File

@ -182,7 +182,6 @@ export default (database: Sequelize) => {
indexes: [
{ name: "company_idx", fields: ["company_id"], unique: false },
{ name: "idx_company_idx", fields: ["id", "company_id"], unique: true },
{ name: "email_idx", fields: ["email"], unique: true },
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope