This commit is contained in:
David Arranz 2025-09-26 17:00:11 +02:00
parent 3846199cbe
commit 26442edd60
38 changed files with 892 additions and 152 deletions

View File

@ -21,7 +21,7 @@ export class SequelizeTransactionManager extends TransactionManager {
} }
protected async _rollbackTransaction(): Promise<void> { protected async _rollbackTransaction(): Promise<void> {
throw new InfrastructureError("Database not available"); throw new InfrastructureError("[SequelizeTransactionManager] Database not available");
} }
constructor(database: Sequelize, options?: { isolationLevel?: string }) { constructor(database: Sequelize, options?: { isolationLevel?: string }) {
@ -32,7 +32,9 @@ export class SequelizeTransactionManager extends TransactionManager {
get database(): Sequelize { get database(): Sequelize {
if (!this._database) { if (!this._database) {
throw new InfrastructureUnavailableError("Database not available"); throw new InfrastructureUnavailableError(
"[SequelizeTransactionManager] Database not available"
);
} }
return this._database; return this._database;
} }
@ -43,7 +45,9 @@ export class SequelizeTransactionManager extends TransactionManager {
*/ */
async complete<T>(work: (transaction: Transaction) => Promise<T>): Promise<T> { async complete<T>(work: (transaction: Transaction) => Promise<T>): Promise<T> {
if (!this._database) { if (!this._database) {
throw new InfrastructureUnavailableError("Database not available"); throw new InfrastructureUnavailableError(
"[SequelizeTransactionManager] Database not available"
);
} }
// Evita transacciones anidadas según la política del TransactionManager base // Evita transacciones anidadas según la política del TransactionManager base
@ -52,7 +56,9 @@ export class SequelizeTransactionManager extends TransactionManager {
"❌ Cannot start a new transaction inside another. Nested transactions are not allowed.", "❌ Cannot start a new transaction inside another. Nested transactions are not allowed.",
{ label: "SequelizeTransactionManager.complete" } { label: "SequelizeTransactionManager.complete" }
); );
throw new Error("A transaction is already active. Nested transactions are not allowed."); throw new InfrastructureError(
"[SequelizeTransactionManager] A transaction is already active. Nested transactions are not allowed."
);
} }
try { try {

View File

@ -1,2 +1 @@
//export * from "./participantAddressFinder"; export * from "./status-invoice_is_approved.spec";
//export * from "./participantFinder";

View File

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

View File

@ -3,3 +3,4 @@ export * from "./get-customer-invoice.use-case";
export * from "./list-customer-invoices.use-case"; export * from "./list-customer-invoices.use-case";
export * from "./report"; export * from "./report";
//export * from "./update-customer-invoice.use-case"; //export * from "./update-customer-invoice.use-case";
export * from "./issue-customer-invoice.use-case";

View File

@ -0,0 +1,72 @@
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceNumber, CustomerInvoiceService } from "../../domain";
import { StatusInvoiceIsApprovedSpecification } from "../specs";
type IssueCustomerInvoiceUseCaseInput = {
companyId: UniqueID;
invoice_id: string;
};
export class IssueCustomerInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceService,
private readonly transactionManager: ITransactionManager
) {}
public execute(params: IssueCustomerInvoiceUseCaseInput) {
const { invoice_id, companyId } = params;
const idOrError = UniqueID.create(invoice_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const invoiceId = idOrError.data;
return this.transactionManager.complete(async (transaction) => {
try {
const invoiceResult = await this.service.getInvoiceByIdInCompany(
companyId,
invoiceId,
transaction
);
if (invoiceResult.isFailure) {
return Result.fail(invoiceResult.error);
}
const invoiceProforma = invoiceResult.data;
const isOk = new StatusInvoiceIsApprovedSpecification().isSatisfiedBy(invoiceProforma);
if (!isOk) {
return Result.fail(
new EntityNotFoundError("Customer invoice", "id", invoiceId.toString())
);
}
// La factura se puede emitir.
// Pedir el número de factura
const newInvoiceNumber = CustomerInvoiceNumber.create("xxx/001").data;
// Asignamos el número de la factura
const issuedInvoiceResult = invoiceProforma.issueInvoice(newInvoiceNumber);
if (issuedInvoiceResult.isFailure) {
return Result.fail(new EntityNotFoundError("Customer invoice", "id", error));
}
const issuedInvoice = issuedInvoiceResult.data;
this.service.saveInvoice(issuedInvoice, transaction);
//return await this.service.IssueInvoiceByIdInCompany(companyId, invoiceId, transaction);
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -38,12 +38,15 @@ export interface CustomerInvoiceProps {
languageCode: LanguageCode; languageCode: LanguageCode;
currencyCode: CurrencyCode; currencyCode: CurrencyCode;
taxes: InvoiceTaxes;
items: CustomerInvoiceItems; items: CustomerInvoiceItems;
paymentMethod: Maybe<InvoicePaymentMethod>; paymentMethod: Maybe<InvoicePaymentMethod>;
discountPercentage: Percentage; discountPercentage: Percentage;
verifactu_qr: string;
verifactu_url: string;
verifactu_status: string;
} }
export interface ICustomerInvoice { export interface ICustomerInvoice {
@ -56,6 +59,8 @@ export interface ICustomerInvoice {
getTaxableAmount(): InvoiceAmount; getTaxableAmount(): InvoiceAmount;
getTaxesAmount(): InvoiceAmount; getTaxesAmount(): InvoiceAmount;
getTotalAmount(): InvoiceAmount; getTotalAmount(): InvoiceAmount;
issueInvoice(newInvoiceNumber: CustomerInvoiceNumber): Result<CustomerInvoice, Error>;
} }
export type CustomerInvoicePatchProps = Partial<Omit<CustomerInvoiceProps, "companyId">>; export type CustomerInvoicePatchProps = Partial<Omit<CustomerInvoiceProps, "companyId">>;
@ -65,7 +70,6 @@ export class CustomerInvoice
implements ICustomerInvoice implements ICustomerInvoice
{ {
private _items!: CustomerInvoiceItems; private _items!: CustomerInvoiceItems;
private _taxes!: InvoiceTaxes;
protected constructor(props: CustomerInvoiceProps, id?: UniqueID) { protected constructor(props: CustomerInvoiceProps, id?: UniqueID) {
super(props, id); super(props, id);
@ -75,8 +79,6 @@ export class CustomerInvoice
languageCode: props.languageCode, languageCode: props.languageCode,
currencyCode: props.currencyCode, currencyCode: props.currencyCode,
}); });
this._taxes = props.taxes;
} }
static create(props: CustomerInvoiceProps, id?: UniqueID): Result<CustomerInvoice, Error> { static create(props: CustomerInvoiceProps, id?: UniqueID): Result<CustomerInvoice, Error> {
@ -167,7 +169,7 @@ export class CustomerInvoice
} }
public get taxes(): InvoiceTaxes { public get taxes(): InvoiceTaxes {
return this._taxes; return this.items.getTaxesAmountByTaxes();
} }
public get hasRecipient() { public get hasRecipient() {
@ -240,4 +242,16 @@ export class CustomerInvoice
totalAmount, totalAmount,
}; };
} }
public issueInvoice(newInvoiceNumber: CustomerInvoiceNumber) {
return CustomerInvoice.create(
{
...this.props,
status: CustomerInvoiceStatus.createEmitted(),
isProforma: false,
invoiceNumber: newInvoiceNumber,
},
this.id
);
}
} }

View File

@ -162,6 +162,10 @@ export class CustomerInvoiceItem
return this._getTotalAmount(taxableAmount, taxesAmount); return this._getTotalAmount(taxableAmount, taxesAmount);
} }
public getTaxesAmountByTaxes() {
return this.taxes.getTaxesAmountByTaxes(this.getTaxableAmount());
}
public getAllAmounts() { public getAllAmounts() {
const subtotalAmount = this.getSubtotalAmount(); const subtotalAmount = this.getSubtotalAmount();
const discountAmount = this._getDiscountAmount(subtotalAmount); const discountAmount = this._getDiscountAmount(subtotalAmount);

View File

@ -1,6 +1,8 @@
import { Tax } from "@erp/core/api";
import { CurrencyCode, LanguageCode } from "@repo/rdx-ddd"; import { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import { Collection } from "@repo/rdx-utils"; import { Collection } from "@repo/rdx-utils";
import { ItemAmount } from "../../value-objects"; import { ItemAmount } from "../../value-objects";
import { InvoiceTax, InvoiceTaxes } from "../invoice-taxes";
import { CustomerInvoiceItem } from "./customer-invoice-item"; import { CustomerInvoiceItem } from "./customer-invoice-item";
export interface CustomerInvoiceItemsProps { export interface CustomerInvoiceItemsProps {
@ -70,4 +72,32 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
ItemAmount.zero(this._currencyCode.code) ItemAmount.zero(this._currencyCode.code)
); );
} }
public getTaxesAmountByTaxes(): InvoiceTaxes {
InvoiceTaxes.create({});
const taxesMap = new Map<Tax, ItemAmount>();
const currencyCode = this._currencyCode.code;
for (const item of this.getAll()) {
for (const { tax, taxesAmount } of item.getTaxesAmountByTaxes()) {
const current = taxesMap.get(tax) ?? ItemAmount.zero(currencyCode);
taxesMap.set(tax, current.add(taxesAmount));
}
}
const items: InvoiceTax[] = [];
for (const [tax, taxesAmount] of taxesMap) {
items.push(
InvoiceTax.create({
tax,
taxesAmount,
}).data
);
}
return InvoiceTaxes.create({
items: items,
});
}
} }

View File

@ -1,10 +1,12 @@
import { Tax } from "@erp/core/api"; import { Tax } from "@erp/core/api";
import { DomainEntity, UniqueID } from "@repo/rdx-ddd"; import { DomainEntity, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { ItemAmount } from "../../value-objects";
import { InvoiceAmount } from "../../value-objects/invoice-amount"; import { InvoiceAmount } from "../../value-objects/invoice-amount";
export interface InvoiceTaxProps { export interface InvoiceTaxProps {
tax: Tax; tax: Tax;
taxesAmount: ItemAmount;
} }
export class InvoiceTax extends DomainEntity<InvoiceTaxProps> { export class InvoiceTax extends DomainEntity<InvoiceTaxProps> {

View File

@ -23,6 +23,21 @@ export class ItemTaxes extends Collection<ItemTax> {
); );
} }
public getTaxesAmountByTaxCode(taxCode: string, taxableAmount: ItemAmount): ItemAmount {
const currencyCode = taxableAmount.currencyCode;
return this.filter((itemTax) => itemTax.tax.code === taxCode).reduce((totalAmount, itemTax) => {
return itemTax.getTaxAmount(taxableAmount).add(totalAmount);
}, ItemAmount.zero(currencyCode));
}
public getTaxesAmountByTaxes(taxableAmount: ItemAmount): {
return this.getAll().map((taxItem) => ({
tax: taxItem.tax,
taxesAmount: this.getTaxesAmountByTaxCode(taxItem.tax.code, taxableAmount),
}));
}
public getCodesToString(): string { public getCodesToString(): string {
return this.getAll() return this.getAll()
.map((taxItem) => taxItem.tax.code) .map((taxItem) => taxItem.tax.code)

View File

@ -74,6 +74,10 @@ export class CustomerInvoiceStatus extends ValueObject<ICustomerInvoiceStatusPro
return this.props.value === INVOICE_STATUS.DRAFT; return this.props.value === INVOICE_STATUS.DRAFT;
} }
isApproved(): boolean {
return this.props.value === INVOICE_STATUS.APPROVED;
}
getProps(): string { getProps(): string {
return this.props.value; return this.props.value;
} }

View File

@ -3,4 +3,5 @@ export * from "./delete-customer-invoice.controller";
export * from "./get-customer-invoice.controller"; export * from "./get-customer-invoice.controller";
export * from "./list-customer-invoices.controller"; export * from "./list-customer-invoices.controller";
///export * from "./update-customer-invoice.controller"; ///export * from "./update-customer-invoice.controller";
export * from "./issue-customer-invoice.controller";
export * from "./report-customer-invoice.controller"; export * from "./report-customer-invoice.controller";

View File

@ -0,0 +1,25 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { IssueCustomerInvoiceUseCase } from "../../../application";
export class IssueCustomerInvoiceController extends ExpressController {
public constructor(
private readonly useCase: IssueCustomerInvoiceUseCase
/* private readonly presenter: any */
) {
super();
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.useGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
}
async executeImpl() {
const tenantId = this.getTenantId()!; // garantizado por tenantGuard
const { id } = this.req.params;
const result = await this.useCase.execute({ id, tenantId });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -116,5 +116,17 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
} }
); );
router.put(
"/:invoice_id/issue",
//checkTabContext,
validateRequest(XXX, "params"),
validateRequest(XXX, "body"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.issue();
const controller = new IssueCustomerInvoiceController(useCase);
return controller.execute(req, res, next);
}
app.use(`${baseRoutePath}/customer-invoices`, router); app.use(`${baseRoutePath}/customer-invoices`, router);
}; };

View File

@ -5,12 +5,15 @@ import {
ValidationErrorDetail, ValidationErrorDetail,
extractOrPushError, extractOrPushError,
maybeFromNullableVO, maybeFromNullableVO,
toNullable,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { InferCreationAttributes } from "sequelize"; import { InferCreationAttributes } from "sequelize";
import { import {
CustomerInvoice,
CustomerInvoiceItem, CustomerInvoiceItem,
CustomerInvoiceItemDescription, CustomerInvoiceItemDescription,
CustomerInvoiceItemProps,
CustomerInvoiceProps, CustomerInvoiceProps,
ItemAmount, ItemAmount,
ItemDiscount, ItemDiscount,
@ -42,7 +45,10 @@ export class CustomerInvoiceItemDomainMapper
this._taxesMapper = new ItemTaxesDomainMapper(params); this._taxesMapper = new ItemTaxesDomainMapper(params);
} }
private mapAttributesToDomain(source: CustomerInvoiceItemModel, params?: MapperParamsType) { private mapAttributesToDomain(
source: CustomerInvoiceItemModel,
params?: MapperParamsType
): Partial<CustomerInvoiceItemProps> & { itemId?: UniqueID } {
const { errors, index, attributes } = params as { const { errors, index, attributes } = params as {
index: number; index: number;
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
@ -65,7 +71,7 @@ export class CustomerInvoiceItemDomainMapper
const quantity = extractOrPushError( const quantity = extractOrPushError(
maybeFromNullableVO(source.quantity_value, (value) => ItemQuantity.create({ value })), maybeFromNullableVO(source.quantity_value, (value) => ItemQuantity.create({ value })),
`items[${index}].discount_percentage`, `items[${index}].quantity`,
errors errors
); );
@ -77,14 +83,23 @@ export class CustomerInvoiceItemDomainMapper
errors errors
); );
const discountPercentage = extractOrPushError(
maybeFromNullableVO(source.discount_percentage_value, (value) =>
ItemDiscount.create({ value })
),
`items[${index}].discount_percentage`,
errors
);
return { return {
itemId, itemId,
languageCode: attributes.languageCode, languageCode: attributes.languageCode,
currencyCode: attributes.currencyCode, currencyCode: attributes.currencyCode,
description, description,
quantity, quantity,
unitAmount, unitAmount,
discountPercentage,
}; };
} }
@ -112,16 +127,7 @@ export class CustomerInvoiceItemDomainMapper
} }
} }
// 3) Importes // 3) Taxes (colección a nivel de item/línea)
const discountPercentage = extractOrPushError(
maybeFromNullableVO(source.discount_percentage_value, (value) =>
ItemDiscount.create({ value })
),
`items[${index}].discount_percentage`,
errors
);
// 4) Taxes (colección a nivel de item/línea)
const taxesResults = this._taxesMapper.mapToDomainCollection( const taxesResults = this._taxesMapper.mapToDomainCollection(
source.taxes, source.taxes,
source.taxes.length, source.taxes.length,
@ -133,7 +139,7 @@ export class CustomerInvoiceItemDomainMapper
if (taxesResults.isFailure) { if (taxesResults.isFailure) {
errors.push({ errors.push({
path: `taxes[${index}].discount_percentage`, path: "taxes",
message: taxesResults.error.message, message: taxesResults.error.message,
}); });
} }
@ -151,7 +157,7 @@ export class CustomerInvoiceItemDomainMapper
description: attributes.description!, description: attributes.description!,
quantity: attributes.quantity!, quantity: attributes.quantity!,
unitAmount: attributes.unitAmount!, unitAmount: attributes.unitAmount!,
discountPercentage: discountPercentage!, discountPercentage: attributes.discountPercentage!,
taxes, taxes,
}, },
attributes.itemId attributes.itemId
@ -172,63 +178,59 @@ export class CustomerInvoiceItemDomainMapper
source: CustomerInvoiceItem, source: CustomerInvoiceItem,
params?: MapperParamsType params?: MapperParamsType
): Result<InferCreationAttributes<CustomerInvoiceItemModel, {}>, Error> { ): Result<InferCreationAttributes<CustomerInvoiceItemModel, {}>, Error> {
throw new Error("not implemented"); const { errors, index, parent } = params as {
/*
const { index, sourceParent } = params as {
index: number; index: number;
sourceParent: CustomerInvoice; parent: CustomerInvoice;
errors: ValidationErrorDetail[];
}; };
const taxesResults = this._taxesMapper.mapToPersistenceArray(source.taxes, params);
return {
if (taxesResults.isFailure) {
errors.push({
path: "taxes",
message: taxesResults.error.message,
});
}
const allAmounts = source.getAllAmounts();
return Result.ok({
item_id: source.id.toPrimitive(), item_id: source.id.toPrimitive(),
invoice_id: sourceParent.id.toPrimitive(), invoice_id: parent.id.toPrimitive(),
position: index, position: index,
description: toNullable(source.description, (description) => description.toPrimitive()), description: toNullable(source.description, (v) => v.toPrimitive()),
quantity_value: toNullable(source.quantity, (value) => value.toPrimitive().value), quantity_value: toNullable(source.quantity, (v) => v.toPrimitive().value),
quantity_scale: source.quantity.match( quantity_scale: toNullable(source.quantity, (v) => v.toPrimitive().scale),
(value) => value.toPrimitive().scale,
() => ItemQuantity.DEFAULT_SCALE
),
unit_amount_value: toNullable(source.unitAmount, (value) => value.toPrimitive().value), unit_amount_value: toNullable(source.unitAmount, (v) => v.toPrimitive().value),
unit_amount_scale: source.unitAmount.match( unit_amount_scale: toNullable(source.unitAmount, (v) => v.toPrimitive().scale),
(value) => value.toPrimitive().scale,
() => ItemAmount.DEFAULT_SCALE
),
subtotal_amount_value: source.subtotalAmount.toPrimitive().value, subtotal_amount_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: source.subtotalAmount.toPrimitive().scale, subtotal_amount_scale: allAmounts.subtotalAmount.scale,
discount_percentage_value: toNullable( discount_percentage_value: toNullable(
source.discountPercentage, source.discountPercentage,
(value) => value.toPrimitive().value (v) => v.toPrimitive().value
), ),
discount_percentage_scale: source.discountPercentage.match( discount_percentage_scale: toNullable(
(value) => value.toPrimitive().scale, source.discountPercentage,
() => ItemAmount.DEFAULT_SCALE (v) => v.toPrimitive().scale
), ),
discount_amount_value: toNullable( discount_amount_value: allAmounts.discountAmount.value,
source.discountAmount, discount_amount_scale: allAmounts.discountAmount.scale,
(value) => value.toPrimitive().value
),
discount_amount_scale: source.discountAmount.match(
(value) => value.toPrimitive().scale,
() => ItemDiscount.DEFAULT_SCALE
),
taxable_amount_value: source.taxableAmount.toPrimitive().value, taxable_amount_value: allAmounts.taxableAmount.value,
taxable_amount_scale: source.taxableAmount.toPrimitive().value, taxable_amount_scale: allAmounts.taxableAmount.value,
taxes_amount_value: source.taxesAmount.toPrimitive().value, taxes_amount_value: allAmounts.taxesAmount.value,
taxes_amount_scale: source.taxesAmount.toPrimitive().value, taxes_amount_scale: allAmounts.taxesAmount.scale,
total_amount_value: source.totalAmount.toPrimitive().value, total_amount_value: allAmounts.totalAmount.value,
total_amount_scale: source.totalAmount.toPrimitive().scale, total_amount_scale: allAmounts.totalAmount.scale,
};*/ });
} }
} }

View File

@ -1,4 +1,9 @@
import { ISequelizeDomainMapper, MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import {
ISequelizeDomainMapper,
InfrastructureError,
MapperParamsType,
SequelizeDomainMapper,
} from "@erp/core/api";
import { import {
CurrencyCode, CurrencyCode,
LanguageCode, LanguageCode,
@ -10,6 +15,7 @@ import {
ValidationErrorDetail, ValidationErrorDetail,
extractOrPushError, extractOrPushError,
maybeFromNullableVO, maybeFromNullableVO,
toNullable,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { import {
@ -25,7 +31,7 @@ import { InvoiceTaxes } from "../../../domain/entities/invoice-taxes";
import { CustomerInvoiceCreationAttributes, CustomerInvoiceModel } from "../../sequelize"; import { CustomerInvoiceCreationAttributes, CustomerInvoiceModel } from "../../sequelize";
import { CustomerInvoiceItemDomainMapper as CustomerInvoiceItemFullMapper } from "./customer-invoice-item.mapper"; import { CustomerInvoiceItemDomainMapper as CustomerInvoiceItemFullMapper } from "./customer-invoice-item.mapper";
import { InvoiceRecipientDomainMapper as InvoiceRecipientFullMapper } from "./invoice-recipient.mapper"; import { InvoiceRecipientDomainMapper as InvoiceRecipientFullMapper } from "./invoice-recipient.mapper";
import { TaxesDomainMapper as TaxesFullMapper } from "./taxes.mapper"; import { TaxesDomainMapper as TaxesFullMapper } from "./invoice-taxes.mapper";
export interface ICustomerInvoiceDomainMapper export interface ICustomerInvoiceDomainMapper
extends ISequelizeDomainMapper< extends ISequelizeDomainMapper<
@ -324,33 +330,86 @@ export class CustomerInvoiceDomainMapper
source: CustomerInvoice, source: CustomerInvoice,
params?: MapperParamsType params?: MapperParamsType
): Result<CustomerInvoiceCreationAttributes, Error> { ): Result<CustomerInvoiceCreationAttributes, Error> {
throw new Error("not implemented"); const errors: ValidationErrorDetail[] = [];
/*const items = this._itemsMapper.mapCollectionToPersistence(source.items, params); // 1) Items
const itemsResult = this._itemsMapper.mapToPersistenceArray(source.items, {
errors,
parent: source,
...params,
});
if (itemsResult.isFailure) {
errors.push({
path: "items",
message: itemsResult.error.message,
});
}
const customer = source.recipient.match( // 1) Taxes
(recipient) => const taxesResult = this._taxesMapper.mapToPersistenceArray(source.taxes, {
({ errors,
customer_id: recipient.id.toPrimitive(), parent: source,
customer_tin: recipient.tin.toPrimitive(), ...params,
customer_name: recipient.name.toPrimitive(), });
customer_street: toNullable(recipient.address.street, (street) => street.toPrimitive()), if (taxesResult.isFailure) {
customer_street2: toNullable(recipient.address.street2, (street2) => errors.push({
street2.toPrimitive() path: "taxes",
), message: taxesResult.error.message,
customer_city: toNullable(recipient.address.city, (city) => city.toPrimitive()), });
customer_province: toNullable(recipient.address.province, (province) => }
province.toPrimitive()
), // 3) Calcular totales
customer_postal_code: toNullable(recipient.address.postalCode, (postalCode) => const allAmounts = source.getAllAmounts();
postalCode.toPrimitive()
), // 4) Construir parte
customer_country: toNullable(recipient.address.country, (country) => const invoiceValues: Partial<CustomerInvoiceCreationAttributes> = {
country.toPrimitive() id: source.id.toPrimitive(),
), company_id: source.companyId.toPrimitive(),
}) as any,
() => ({ is_proforma: source.isProforma,
customer_id: source.customerId.toPrimitive(), status: source.status.toPrimitive(),
series: toNullable(source.series, (series) => series.toPrimitive()),
invoice_number: source.invoiceNumber.toPrimitive(),
invoice_date: source.invoiceDate.toPrimitive(),
operation_date: toNullable(source.operationDate, (operationDate) =>
operationDate.toPrimitive()
),
language_code: source.languageCode.toPrimitive(),
currency_code: source.currencyCode.toPrimitive(),
notes: toNullable(source.notes, (notes) => notes.toPrimitive()),
subtotal_amount_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale,
discount_percentage_value: source.discountPercentage.toPrimitive().value,
discount_percentage_scale: source.discountPercentage.toPrimitive().scale,
discount_amount_value: allAmounts.discountAmount.value,
discount_amount_scale: allAmounts.discountAmount.scale,
taxable_amount_value: allAmounts.taxableAmount.value,
taxable_amount_scale: allAmounts.taxableAmount.scale,
taxes_amount_value: allAmounts.taxesAmount.value,
taxes_amount_scale: allAmounts.taxesAmount.scale,
total_amount_value: allAmounts.totalAmount.value,
total_amount_scale: allAmounts.totalAmount.scale,
customer_id: source.customerId.toPrimitive(),
payment_method_id: toNullable(source.paymentMethod, (payment) => payment.toObjectString().id),
payment_method_description: toNullable(
source.paymentMethod,
(payment) => payment.toObjectString().payment_description
),
};
// 5) Cliente / Recipient ??
// Si es proforma no guardamos los campos como históricos (snapshots)
if (source.isProforma) {
Object.assign(invoiceValues, {
customer_tin: null, customer_tin: null,
customer_name: null, customer_name: null,
customer_street: null, customer_street: null,
@ -359,48 +418,40 @@ export class CustomerInvoiceDomainMapper
customer_province: null, customer_province: null,
customer_postal_code: null, customer_postal_code: null,
customer_country: null, customer_country: null,
}) });
) as any; } else {
const recipient = source.recipient.getOrUndefined();
if (!recipient) {
return Result.fail(
new InfrastructureError(
"[CustomerInvoiceDomainMapper] Issued customer invoice w/o recipient data"
)
);
}
return { Object.assign(invoiceValues, {
id: source.id.toPrimitive(), customer_tin: recipient.tin.toPrimitive(),
company_id: source.companyId.toPrimitive(), customer_name: recipient.name.toPrimitive(),
customer_street: toNullable(recipient.street, (v) => v.toPrimitive()),
customer_street2: toNullable(recipient.street2, (v) => v.toPrimitive()),
customer_city: toNullable(recipient.city, (v) => v.toPrimitive()),
customer_province: toNullable(recipient.province, (v) => v.toPrimitive()),
customer_postal_code: toNullable(recipient.postalCode, (v) => v.toPrimitive()),
customer_country: toNullable(recipient.country, (v) => v.toPrimitive()),
} as Partial<CustomerInvoiceCreationAttributes>);
}
status: source.status.toPrimitive(), // 7) Si hubo errores de mapeo, devolvemos colección de validación
series: toNullable(source.series, (series) => series.toPrimitive()), if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer invoice mapping to persistence failed", errors)
);
}
invoice_number: source.invoiceNumber.toPrimitive(), return Result.ok<CustomerInvoiceCreationAttributes>({
invoice_date: source.invoiceDate.toPrimitive(), ...invoiceValues,
items: itemsResult.data,
operation_date: toNullable(source.operationDate, (operationDate) => taxes: taxesResult.data,
operationDate.toPrimitive() });
),
notes: toNullable(source.notes, (notes) => notes.toPrimitive()),
language_code: source.languageCode.code,
currency_code: source.currencyCode.code,
subtotal_amount_value: 0, //subtotal.amount,
subtotal_amount_scale: 2, //subtotal.scale,
discount_percentage_value: source.discountPercentage.value,
discount_percentage_scale: source.discountPercentage.scale,
discount_amount_value: source.discountAmount.value,
discount_amount_scale: source.discountAmount.scale,
taxable_amount_value: source.taxableAmount.value,
taxable_amount_scale: source.taxableAmount.scale,
taxes_amount_value: source.taxAmount.value,
taxes_amount_scale: source.taxAmount.scale,
total_amount_value: 0, //total.amount,
total_amount_scale: 2, //total.scale,
items,
...customer,
};*/
} }
} }

View File

@ -7,7 +7,8 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceProps } from "../../../domain"; import { InferCreationAttributes } from "sequelize";
import { CustomerInvoice, CustomerInvoiceProps } from "../../../domain";
import { InvoiceTax } from "../../../domain/entities/invoice-taxes"; import { InvoiceTax } from "../../../domain/entities/invoice-taxes";
import { CustomerInvoiceTaxCreationAttributes, CustomerInvoiceTaxModel } from "../../sequelize"; import { CustomerInvoiceTaxCreationAttributes, CustomerInvoiceTaxModel } from "../../sequelize";
@ -63,10 +64,31 @@ export class TaxesDomainMapper extends SequelizeDomainMapper<
return createResult; return createResult;
} }
/*public mapToPersistence( public mapToPersistence(
source: InvoiceTax, source: InvoiceTax,
params?: MapperParamsType params?: MapperParamsType
): CustomerInvoiceTaxCreationAttributes { ): Result<InferCreationAttributes<CustomerInvoiceTaxModel, {}>, Error> {
throw new Error("not implemented"); const { errors, parent } = params as {
}*/ index: number;
parent: CustomerInvoice;
errors: ValidationErrorDetail[];
};
const taxableAmount = parent.getTaxableAmount()
const taxesAmount =
return Result.ok({
tax_id: source.id.toPrimitive(),
invoice_id: parent.id.toPrimitive(),
tax_code: source.tax.code,
taxable_amount_value: taxableAmount.value,
taxable_amount_scale: taxableAmount.scale,
taxes_amount_value: taxesAmount.value,
taxes_amount_scale: taxesAmount.scale,
});
}
} }

View File

@ -1,22 +1,37 @@
import { JsonTaxCatalogProvider } from "@erp/core"; import { JsonTaxCatalogProvider } from "@erp/core";
import { MapperParamsType, SequelizeDomainMapper, Tax } from "@erp/core/api"; import {
ISequelizeDomainMapper,
MapperParamsType,
SequelizeDomainMapper,
Tax,
} from "@erp/core/api";
import { CustomerInvoiceItem, ItemTax } from "@erp/customer-invoices/api/domain";
import { import {
ValidationErrorCollection, ValidationErrorCollection,
ValidationErrorDetail, ValidationErrorDetail,
extractOrPushError, extractOrPushError,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { ItemTax } from "../../../domain";
import { import {
CustomerInvoiceItemCreationAttributes, CustomerInvoiceItemTaxCreationAttributes,
CustomerInvoiceItemTaxModel, CustomerInvoiceItemTaxModel,
} from "../../sequelize"; } from "../../sequelize";
export class ItemTaxesDomainMapper extends SequelizeDomainMapper< export interface IItemTaxesDomainMapper
CustomerInvoiceItemTaxModel, extends ISequelizeDomainMapper<
CustomerInvoiceItemCreationAttributes, CustomerInvoiceItemTaxModel,
ItemTax CustomerInvoiceItemTaxCreationAttributes,
> { ItemTax
> {}
export class ItemTaxesDomainMapper
extends SequelizeDomainMapper<
CustomerInvoiceItemTaxModel,
CustomerInvoiceItemTaxCreationAttributes,
ItemTax
>
implements IItemTaxesDomainMapper
{
private _taxCatalog!: JsonTaxCatalogProvider; private _taxCatalog!: JsonTaxCatalogProvider;
constructor(params: MapperParamsType) { constructor(params: MapperParamsType) {
@ -60,10 +75,29 @@ export class ItemTaxesDomainMapper extends SequelizeDomainMapper<
return createResult; return createResult;
} }
/*public mapToPersistence( public mapToPersistence(
source: ItemTax, source: ItemTax,
params?: MapperParamsType params?: MapperParamsType
): CustomerInvoiceItemCreationAttributes { ): Result<CustomerInvoiceItemTaxCreationAttributes, Error> {
throw new Error("not implemented"); const { errors, parent } = params as {
}*/ parent: CustomerInvoiceItem;
errors: ValidationErrorDetail[];
};
const taxableAmount = parent.getTaxableAmount();
const taxAmount = source.getTaxAmount(taxableAmount);
return Result.ok({
tax_id: source.id.toPrimitive(),
item_id: parent.id.toPrimitive(),
tax_code: source.tax.code,
taxable_amount_value: taxableAmount.value,
taxable_amount_scale: taxableAmount.scale,
taxes_amount_value: taxAmount.value,
taxes_amount_scale: taxAmount.scale,
});
}
} }

View File

@ -40,6 +40,7 @@ export class CustomerInvoiceModel extends Model<
declare operation_date: string; declare operation_date: string;
declare language_code: string; declare language_code: string;
declare currency_code: string; declare currency_code: string;
//declare xxxxxx
declare notes: string; declare notes: string;

View File

@ -0,0 +1,23 @@
{
"name": "@erp/doc-numbering",
"version": "0.0.1",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
"./api": "./src/api/index.ts"
},
"peerDependencies": {
"sequelize": "^6.37.5",
"express": "^4.18.2",
"zod": "^4.1.11"
},
"devDependencies": { "@types/express": "^4.17.21" },
"dependencies": {
"@erp/auth": "workspace:*",
"@erp/core": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/rdx-logger": "workspace:*"
}
}

View File

@ -0,0 +1,43 @@
import { AggregateRoot, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { DocType } from "../value-objects";
export interface DocNumberProps {
docType: DocType; // INVOICE, QUOTATION, DELIVERY_NOTE, PAYMENT...
series: string; // opcional: "2025", "Sucursal-01"
currentValue: number;
formatPattern: string; // ej: "{year}/{number:000000}"
lastAssignedAt: UtcDate;
}
export class DocNumber extends AggregateRoot<DocNumberProps> {
static create(props: DocNumberProps, id?: UniqueID): Result<DocNumber, Error> {
const docNumber = new DocNumber(props, id);
// Reglas de negocio / validaciones
// ...
// ...
// 🔹 Disparar evento de dominio "CustomerAuthenticatedEvent"
//const { contact } = props;
//user.addDomainEvent(new CustomerAuthenticatedEvent(id, contact.toString()));
return Result.ok(docNumber);
}
public get docType(): DocType {
return this.props.docType;
}
public get series(): string {
return this.props.series;
}
public get currentValue(): number {
return this.props.currentValue;
}
public get formatPattern(): string {
return this.props.formatPattern;
}
}

View File

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

View File

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

View File

@ -0,0 +1,12 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { DocNumber } from "../aggregates/doc-number";
export interface IDocNumberingRepository {
getByReferenceInCompany(
companyId: UniqueID,
reference: string,
transaction?: any
): Promise<Result<DocNumber, Error>>;
save(reference: string, transaction?: any): Promise<Result<DocNumber, Error>>;
}

View File

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

View File

@ -0,0 +1,136 @@
import { ValueObject, translateZodValidationError } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
// 🔹 Conjunto canónico de tipos admitidos en todo el ERP
export const DOCUMENT_TYPE_CODES = [
"INVOICE",
"QUOTATION",
"DELIVERY_NOTE",
"PAYMENT",
"CREDIT_NOTE",
"RECEIPT",
"PURCHASE_ORDER",
] as const;
export type DocTypeCode = (typeof DOCUMENT_TYPE_CODES)[number];
// 🔹 Alias comunes (entrada libre) → código canónico
const ALIASES: Record<string, DocTypeCode> = {
// facturas
INVOICE: "INVOICE",
FACTURA: "INVOICE",
"FACTURA-RECTIFICATIVA": "CREDIT_NOTE",
FACTURA_RECTIFICATIVA: "CREDIT_NOTE",
"CREDIT-NOTE": "CREDIT_NOTE",
CREDIT_NOTE: "CREDIT_NOTE",
ABONO: "CREDIT_NOTE",
// presupuestos
QUOTATION: "QUOTATION",
QUOTE: "QUOTATION",
PRESUPUESTO: "QUOTATION",
// albaranes
"DELIVERY-NOTE": "DELIVERY_NOTE",
DELIVERY_NOTE: "DELIVERY_NOTE",
ALBARAN: "DELIVERY_NOTE",
ALBARÁN: "DELIVERY_NOTE",
// pagos / cobros
PAYMENT: "PAYMENT",
PAGO: "PAYMENT",
RECEIPT: "RECEIPT",
RECIBO: "RECEIPT",
// pedidos
"PURCHASE-ORDER": "PURCHASE_ORDER",
PURCHASE_ORDER: "PURCHASE_ORDER",
"ORDEN-DE-COMPRA": "PURCHASE_ORDER",
ORDEN_DE_COMPRA: "PURCHASE_ORDER",
};
// 🔹 Normaliza texto a forma comparable: mayúsculas, sin acentos, separadores como ""
function normalizeToken(input: string): string {
return (
input
.trim()
.toUpperCase()
// eliminar diacríticos (tildes)
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
// espacios/guiones → guion bajo
.replace(/[\s-]+/g, "")
);
}
interface DocTypeProps {
value: DocTypeCode;
}
export class DocType extends ValueObject<DocTypeProps> {
// Validación de código canónico con zod
protected static validateCanonical(value: string) {
const schema = z.enum(DOCUMENT_TYPE_CODES);
return schema.safeParse(value);
}
// Intenta mapear la entrada libre a un código canónico
protected static canonicalize(input: string): Result<DocTypeCode, Error> {
const token = normalizeToken(input);
const fromAlias = ALIASES[token];
const candidate = (fromAlias ?? token) as string;
const parsed = DocType.validateCanonical(candidate);
if (!parsed.success) {
return Result.fail(translateZodValidationError("DocType creation failed", parsed.error));
}
return Result.ok(parsed.data);
}
/**
* Factoría desde entrada libre (admite alias).
* Devuelve Result con el VO o el error de validación.
*/
static create(input: string) {
const canon = DocType.canonicalize(input);
if (canon.isFailure) {
return Result.fail(canon.error);
}
return Result.ok(new DocType({ value: canon.data }));
}
/**
* Factoría directa para código canónico ya validado.
* Útil en mappers desde persistencia.
*/
static from(code: DocTypeCode): DocType {
return new DocType({ value: code });
}
/**
* Lista de tipos canónicos soportados (para UI/validadores externos).
*/
static list(): readonly DocTypeCode[] {
return DOCUMENT_TYPE_CODES;
}
/**
* ¿Pertenece a alguno de los tipos indicados?
*/
isOneOf(...types: DocTypeCode[]): boolean {
return types.includes(this.props.value);
}
getProps(): DocTypeCode {
return this.props.value;
}
toPrimitive(): DocTypeCode {
return this.getProps();
}
toString(): string {
return this.getProps();
}
}

View File

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

View File

@ -0,0 +1,29 @@
import { IModuleServer, ModuleParams } from "@erp/core/api";
import { models } from "./infrastructure";
export * from "./infrastructure/sequelize";
export const docNumberingAPIModule: IModuleServer = {
name: "customers",
version: "1.0.0",
dependencies: [],
async init(params: ModuleParams) {
const { logger } = params;
logger.info("🚀 Document Numbering module initialized", { label: this.name });
},
async registerDependencies(params) {
const { logger } = params;
logger.info("🚀 Document Numbering module dependencies registered", {
label: this.name,
});
return {
models,
services: {
/*...*/
},
};
},
};
export default docNumberingAPIModule;

View File

@ -0,0 +1 @@
export * from "";

View File

@ -0,0 +1,2 @@
export * from "./models";
export * from "./repositories";

View File

@ -0,0 +1,74 @@
import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize";
export type DocNumberCreationAttributes = InferCreationAttributes<DocNumberModel, {}> & {};
export class DocNumberModel extends Model<
InferAttributes<DocNumberModel>,
InferCreationAttributes<DocNumberModel>
> {
declare id: string; // UUID
declare docType: string; // ej. "INVOICE"
declare series: string | null; // ej. "2025", "Sucursal-01"
declare currentValue: number; // último número asignado
declare formatPattern: string; // ej. "{series}/{number:000000}"
static associate(database: Sequelize) {}
static hooks(database: Sequelize) {}
}
export default (database: Sequelize) => {
DocNumberModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
docType: {
type: DataTypes.STRING(),
allowNull: false,
},
series: {
type: DataTypes.STRING(),
allowNull: true,
defaultValue: null,
},
currentValue: {
type: DataTypes.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
},
formatPattern: {
type: DataTypes.STRING(),
allowNull: false,
defaultValue: "{series}/{number:000000}",
},
},
{
sequelize: database,
tableName: "doc-numbers",
underscored: true,
paranoid: false, // no softs deletes
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [
{
unique: true,
fields: ["docType", "series"],
},
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return DocNumberModel;
};

View File

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

View File

@ -0,0 +1,41 @@
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { DocNumber, IDocNumberingRepository } from "../../../domain";
import { DocNumberModel } from "../models";
export class DocNumberRepository
extends SequelizeRepository<DocNumber>
implements IDocNumberingRepository
{
async getByReferenceInCompany(
companyId: UniqueID,
reference: string,
transaction?: any
): Promise<Result<DocNumber, Error>> {
try {
const mapper: IDocNumberDomainMapper = this._registry.getDomainMapper({
resource: "doc-number",
});
const row = await DocNumberModel.findOne({
where: { reference, company_id: companyId.toString() },
transaction,
});
if (!row) {
return Result.fail(new EntityNotFoundError("Customer", "id", id.toString()));
}
const customer = mapper.mapToDomain(row);
return customer;
} catch (error: any) {
return Result.fail(translateSequelizeError(error));
}
}
save(reference: string, transaction?: Transaction): Promise<Result<DocNumber, Error>> {
throw new Error("Method not implemented.");
}
}

View File

@ -0,0 +1,33 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/customer-invoices/*": ["./src/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -344,8 +344,8 @@ importers:
specifier: ^6.37.5 specifier: ^6.37.5
version: 6.37.7(mysql2@3.14.1) version: 6.37.7(mysql2@3.14.1)
zod: zod:
specifier: ^4.1.11 specifier: ^3.25.67
version: 4.1.11 version: 3.25.67
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: 1.9.4 specifier: 1.9.4
@ -663,6 +663,40 @@ importers:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.3)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.0)(stylus@0.62.0)(terser@5.40.0)(tsx@4.19.4) version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.3)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.0)(stylus@0.62.0)(terser@5.40.0)(tsx@4.19.4)
modules/document-numbering:
dependencies:
'@erp/auth':
specifier: workspace:*
version: link:../auth
'@erp/core':
specifier: workspace:*
version: link:../core
'@repo/rdx-criteria':
specifier: workspace:*
version: link:../../packages/rdx-criteria
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd
'@repo/rdx-logger':
specifier: workspace:*
version: link:../../packages/rdx-logger
'@repo/rdx-utils':
specifier: workspace:*
version: link:../../packages/rdx-utils
express:
specifier: ^4.18.2
version: 4.21.2
sequelize:
specifier: ^6.37.5
version: 6.37.7(mysql2@3.14.1)
zod:
specifier: ^4.1.11
version: 4.1.11
devDependencies:
'@types/express':
specifier: ^4.17.21
version: 4.17.23
modules/verifactu: modules/verifactu:
dependencies: dependencies:
'@erp/auth': '@erp/auth':