diff --git a/modules/supplier-invoices/package.json b/modules/supplier-invoices/package.json new file mode 100644 index 00000000..ce8d7eb5 --- /dev/null +++ b/modules/supplier-invoices/package.json @@ -0,0 +1,33 @@ +{ + "name": "@erp/supplier-invoices", + "description": "Supplier invoices", + "version": "0.5.0", + "private": true, + "type": "module", + "sideEffects": false, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "clean": "rimraf .turbo node_modules dist" + }, + "exports": { + ".": "./src/common/index.ts", + "./common": "./src/common/index.ts", + "./api": "./src/api/index.ts" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "typescript": "^5.9.3" + }, + "dependencies": { + "@erp/auth": "workspace:*", + "@erp/core": "workspace:*", + "@repo/i18next": "workspace:*", + "@repo/rdx-criteria": "workspace:*", + "@repo/rdx-ddd": "workspace:*", + "@repo/rdx-logger": "workspace:*", + "@repo/rdx-utils": "workspace:*", + "express": "^4.18.2", + "sequelize": "^6.37.5", + "zod": "^4.1.11" + } +} \ No newline at end of file diff --git a/modules/supplier-invoices/src/api/application/di/index.ts b/modules/supplier-invoices/src/api/application/di/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/application/index.ts b/modules/supplier-invoices/src/api/application/index.ts new file mode 100644 index 00000000..21fd64a4 --- /dev/null +++ b/modules/supplier-invoices/src/api/application/index.ts @@ -0,0 +1,6 @@ +export * from "./di"; +export * from "./models"; +export * from "./repositories"; +export * from "./services"; +export * from "./snapshot-builders"; +export * from "./use-cases"; diff --git a/modules/supplier-invoices/src/api/application/models/index.ts b/modules/supplier-invoices/src/api/application/models/index.ts new file mode 100644 index 00000000..fa775f9e --- /dev/null +++ b/modules/supplier-invoices/src/api/application/models/index.ts @@ -0,0 +1 @@ +export * from "./supplier-invoice-summary"; diff --git a/modules/supplier-invoices/src/api/application/models/supplier-invoice-summary.ts b/modules/supplier-invoices/src/api/application/models/supplier-invoice-summary.ts new file mode 100644 index 00000000..25e91ee6 --- /dev/null +++ b/modules/supplier-invoices/src/api/application/models/supplier-invoice-summary.ts @@ -0,0 +1,29 @@ +import type { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; + +import type { InvoiceAmount, SupplierInvoiceStatus } from "../../domain"; + +export type SupplierInvoiceSummary = { + id: UniqueID; + companyId: UniqueID; + + //invoiceNumber: InvoiceNumber; + status: SupplierInvoiceStatus; + //series: Maybe; + + invoiceDate: UtcDate; + dueDate: Maybe; + + reference: Maybe; + description: Maybe; + + supplierId: UniqueID; + //supplier: InvoiceSupplier; + + languageCode: LanguageCode; + currencyCode: CurrencyCode; + + taxableAmount: InvoiceAmount; + taxesAmount: InvoiceAmount; + totalAmount: InvoiceAmount; +}; diff --git a/modules/supplier-invoices/src/api/application/repositories/index.ts b/modules/supplier-invoices/src/api/application/repositories/index.ts new file mode 100644 index 00000000..14ff9ab8 --- /dev/null +++ b/modules/supplier-invoices/src/api/application/repositories/index.ts @@ -0,0 +1 @@ +export * from "./supplier-invoice-repository.interface"; diff --git a/modules/supplier-invoices/src/api/application/repositories/supplier-invoice-repository.interface.ts b/modules/supplier-invoices/src/api/application/repositories/supplier-invoice-repository.interface.ts new file mode 100644 index 00000000..40d76ef8 --- /dev/null +++ b/modules/supplier-invoices/src/api/application/repositories/supplier-invoice-repository.interface.ts @@ -0,0 +1,40 @@ +import type { Criteria } from "@repo/rdx-criteria/server"; +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Collection, Result } from "@repo/rdx-utils"; + +import type { SupplierInvoice } from "../../domain"; +import type { SupplierInvoiceSummary } from "../models"; + +export interface ISupplierInvoiceRepository { + create(invoice: SupplierInvoice, transaction?: unknown): Promise>; + + update(invoice: SupplierInvoice, transaction?: unknown): Promise>; + + save(invoice: SupplierInvoice, transaction?: unknown): Promise>; + + findByIdInCompany( + companyId: UniqueID, + invoiceId: UniqueID, + transaction?: unknown + ): Promise>; + + findBySupplierAndNumberInCompany( + companyId: UniqueID, + supplierId: UniqueID, + invoiceNumber: string, + transaction?: unknown + ): Promise>; + + existsBySupplierAndNumberInCompany( + companyId: UniqueID, + supplierId: UniqueID, + invoiceNumber: string, + transaction?: unknown + ): Promise>; + + findByCriteriaInCompany( + companyId: UniqueID, + criteria: Criteria, + transaction?: unknown + ): Promise, Error>>; +} diff --git a/modules/supplier-invoices/src/api/application/services/supplier-invoice-creator.ts b/modules/supplier-invoices/src/api/application/services/supplier-invoice-creator.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/application/services/supplier-invoice-finder.ts b/modules/supplier-invoices/src/api/application/services/supplier-invoice-finder.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/application/services/supplier-invoice-updater.ts b/modules/supplier-invoices/src/api/application/services/supplier-invoice-updater.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/application/use-cases/cancel-supplier-invoice.use-case.ts b/modules/supplier-invoices/src/api/application/use-cases/cancel-supplier-invoice.use-case.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/application/use-cases/confirm-supplier-invoice.use-case.ts b/modules/supplier-invoices/src/api/application/use-cases/confirm-supplier-invoice.use-case.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/application/use-cases/create-supplier-invoice.use-case.ts b/modules/supplier-invoices/src/api/application/use-cases/create-supplier-invoice.use-case.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/application/use-cases/get-supplier-invoice.use-case.ts b/modules/supplier-invoices/src/api/application/use-cases/get-supplier-invoice.use-case.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/application/use-cases/list-supplier-invoices.use-case.ts b/modules/supplier-invoices/src/api/application/use-cases/list-supplier-invoices.use-case.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/application/use-cases/update-supplier-invoice.use-case.ts b/modules/supplier-invoices/src/api/application/use-cases/update-supplier-invoice.use-case.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/domain/aggregates/index.ts b/modules/supplier-invoices/src/api/domain/aggregates/index.ts new file mode 100644 index 00000000..d1cd8192 --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/aggregates/index.ts @@ -0,0 +1 @@ +export * from "./supplier-invoice.aggregate"; diff --git a/modules/supplier-invoices/src/api/domain/aggregates/supplier-invoice.aggregate.ts b/modules/supplier-invoices/src/api/domain/aggregates/supplier-invoice.aggregate.ts new file mode 100644 index 00000000..3723a6ea --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/aggregates/supplier-invoice.aggregate.ts @@ -0,0 +1,313 @@ +import { + AggregateRoot, + type CurrencyCode, + type LanguageCode, + type UniqueID, + type UtcDate, +} from "@repo/rdx-ddd"; +import { type Maybe, Result } from "@repo/rdx-utils"; + +import type { InvoicePaymentMethod, SupplierInvoiceTaxes } from "../entities"; +import type { SupplierInvoiceSourceType } from "../enums"; +import { type InvoiceAmount, SupplierInvoiceStatus } from "../value-objects"; + +export interface ISupplierInvoiceCreateProps { + companyId: UniqueID; + status: SupplierInvoiceStatus; + + supplierInvoiceCategoryId: UniqueID; + + supplierId: UniqueID; + + invoiceNumber: string; + invoiceDate: UtcDate; + dueDate: Maybe; + + description: Maybe; + notes: Maybe; + + paymentMethod: Maybe; + + taxes: SupplierInvoiceTaxes; + + currencyCode: CurrencyCode; + languageCode: LanguageCode; + + sourceType: SupplierInvoiceSourceType; + documentId: Maybe; + + version?: number; +} + +export interface ISupplierInvoiceTotals { + taxableAmount: InvoiceAmount; + + ivaAmount: InvoiceAmount; + recAmount: InvoiceAmount; + retentionAmount: InvoiceAmount; + + taxesAmount: InvoiceAmount; + + transferredTaxesAmount: InvoiceAmount; + netTaxesAmount: InvoiceAmount; + + totalAmount: InvoiceAmount; +} + +interface ISupplierInvoice { + companyId: UniqueID; + status: SupplierInvoiceStatus; + + supplierInvoiceCategoryId: UniqueID; + + supplierId: UniqueID; + + invoiceNumber: string; + invoiceDate: UtcDate; + dueDate: Maybe; + + description: Maybe; + notes: Maybe; + + paymentMethod: Maybe; + + currencyCode: CurrencyCode; + languageCode: LanguageCode; + + sourceType: SupplierInvoiceSourceType; + documentId: Maybe; + + version: number; + + taxes: SupplierInvoiceTaxes; + totals(): ISupplierInvoiceTotals; +} + +export type SupplierInvoicePatchProps = Partial< + Omit +>; + +export type InternalSupplierInvoiceProps = Omit & { + version: number; +}; + +/** + * Aggregate raíz de factura de proveedor. + * + * Reglas MVP: + * - supplierId requerido + * - invoiceNumber requerido + * - invoiceDate requerido + * - totalAmount > 0 + * - companyId requerido + * - editable en cualquier estado + * + * Notas: + * - create() aplica validaciones de negocio de alta. + * - rehydrate() reconstruye desde persistencia sin revalidar. + */ +export class SupplierInvoice + extends AggregateRoot + implements ISupplierInvoice +{ + private constructor(props: InternalSupplierInvoiceProps, id?: UniqueID) { + super(props, id); + } + + public static create( + props: ISupplierInvoiceCreateProps, + id?: UniqueID + ): Result { + const validationResult = SupplierInvoice.validateCreateProps(props); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + const supplierInvoice = new SupplierInvoice( + { + ...props, + status: props.status ?? SupplierInvoiceStatus.draft(), + version: props.version ?? 1, + }, + id + ); + + return Result.ok(supplierInvoice); + } + + /** + * Reconstruye el agregado desde persistencia. + * + * Debe usarse exclusivamente desde infraestructura. + */ + public static rehydrate(props: InternalSupplierInvoiceProps, id: UniqueID): SupplierInvoice { + return new SupplierInvoice(props, id); + } + + public get companyId(): UniqueID { + return this.props.companyId; + } + + public get supplierId(): UniqueID { + return this.props.supplierId; + } + + public get invoiceNumber(): string { + return this.props.invoiceNumber; + } + + public get invoiceDate(): UtcDate { + return this.props.invoiceDate; + } + + public get currencyCode(): CurrencyCode { + return this.props.currencyCode; + } + + public get languageCode(): LanguageCode { + return this.props.languageCode; + } + + public get taxes(): SupplierInvoiceTaxes { + return this.props.taxes; + } + + public get status(): SupplierInvoiceStatus { + return this.props.status; + } + + public get sourceType(): SupplierInvoiceSourceType { + return this.props.sourceType; + } + + public get documentId(): Maybe { + return this.props.documentId; + } + + public get version(): number { + return this.props.version; + } + + public get description(): Maybe { + return this.props.description; + } + + public get notes(): Maybe { + return this.props.notes; + } + + public get paymentMethod(): Maybe { + return this.props.paymentMethod; + } + + public get dueDate(): Maybe { + return this.props.dueDate; + } + + public get supplierInvoiceCategoryId(): UniqueID { + return this.props.supplierInvoiceCategoryId; + } + + public totals(): ISupplierInvoiceTotals { + return { + taxableAmount: this.taxes.getTaxableAmount(), + + ivaAmount: this.taxes.getIvaAmount(), + recAmount: this.taxes.getRecAmount(), + retentionAmount: this.taxes.getRetentionAmount(), + + taxesAmount: this.taxes.getTaxesAmount(), + + transferredTaxesAmount: this.taxes.getTransferredTaxesAmount(), + netTaxesAmount: this.taxes.getNetTaxesAmount(), + + totalAmount: this.taxes.getTotalAmount(), + } as const; + } + + /** + * Actualiza campos editables del agregado. + * + * Regla MVP: + * - la factura es editable en cualquier estado + * + * Notas: + * - no permite alterar companyId, status, sourceType ni version externamente + * - incrementa version tras mutación válida + */ + public update(patch: SupplierInvoicePatchProps): Result { + const candidateProps: InternalSupplierInvoiceProps = { + ...this.props, + ...patch, + version: this.props.version + 1, + }; + + // Validacciones + + Object.assign(this.props, candidateProps); + + return Result.ok(); + } + + /** + * Marca la factura como confirmada. + */ + public confirm(): Result { + if (this.props.status === SupplierInvoiceStatus.confirmed()) { + return Result.ok(); + } + + this.props.status = SupplierInvoiceStatus.confirmed(); + this.incrementVersion(); + + return Result.ok(); + } + + /** + * Marca la factura como cancelada. + */ + public cancel(): Result { + if (this.props.status === SupplierInvoiceStatus.cancelled()) { + return Result.ok(); + } + + this.props.status = SupplierInvoiceStatus.cancelled(); + this.incrementVersion(); + + return Result.ok(); + } + + private incrementVersion(): void { + this.props.version += 1; + } + + private static validateCreateProps(props: ISupplierInvoiceCreateProps): Result { + if (props.dueDate.isSome() && props.dueDate.unwrap() < props.invoiceDate) { + return Result.fail( + new Error("La fecha de vencimiento no puede ser anterior a la fecha de factura") + ); + } + /*if (!props.companyId?.trim()) { + return Result.fail(new InvalidSupplierInvoiceCompanyError()); + } + + if (!props.supplierId?.trim()) { + return Result.fail(new InvalidSupplierInvoiceSupplierError()); + } + + if (!props.invoiceNumber?.trim()) { + return Result.fail(new InvalidSupplierInvoiceNumberError()); + } + + if (!(props.invoiceDate instanceof Date) || Number.isNaN(props.invoiceDate.getTime())) { + return Result.fail(new InvalidSupplierInvoiceDateError()); + } + + if (props.totalAmount <= 0) { + return Result.fail(new InvalidSupplierInvoiceAmountError()); + }*/ + + return Result.ok(); + } +} diff --git a/modules/supplier-invoices/src/api/domain/entities/index.ts b/modules/supplier-invoices/src/api/domain/entities/index.ts new file mode 100644 index 00000000..01a2d5ce --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/entities/index.ts @@ -0,0 +1,2 @@ +export * from "./invoice-payment-method"; +export * from "./supplier-invoice-taxes"; diff --git a/modules/supplier-invoices/src/api/domain/entities/invoice-payment-method.ts b/modules/supplier-invoices/src/api/domain/entities/invoice-payment-method.ts new file mode 100644 index 00000000..5c6507ca --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/entities/invoice-payment-method.ts @@ -0,0 +1,32 @@ +import { DomainEntity, type UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +export interface InvoicePaymentMethodProps { + paymentDescription: string; +} + +export class InvoicePaymentMethod extends DomainEntity { + public static create( + props: InvoicePaymentMethodProps, + id?: UniqueID + ): Result { + const item = new InvoicePaymentMethod(props, id); + + return Result.ok(item); + } + + get paymentDescription(): string { + return this.props.paymentDescription; + } + + getProps(): InvoicePaymentMethodProps { + return this.props; + } + + toObjectString() { + return { + id: String(this.id), + payment_description: String(this.paymentDescription), + }; + } +} diff --git a/modules/supplier-invoices/src/api/domain/entities/supplier-invoice-taxes/index.ts b/modules/supplier-invoices/src/api/domain/entities/supplier-invoice-taxes/index.ts new file mode 100644 index 00000000..e2ad8571 --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/entities/supplier-invoice-taxes/index.ts @@ -0,0 +1,2 @@ +export * from "./supplier-invoice-tax.entity"; +export * from "./supplier-invoice-taxes.collection"; diff --git a/modules/supplier-invoices/src/api/domain/entities/supplier-invoice-taxes/supplier-invoice-tax.entity.ts b/modules/supplier-invoices/src/api/domain/entities/supplier-invoice-taxes/supplier-invoice-tax.entity.ts new file mode 100644 index 00000000..0a2ca8a9 --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/entities/supplier-invoice-taxes/supplier-invoice-tax.entity.ts @@ -0,0 +1,74 @@ +import type { TaxPercentage } from "@erp/core/api"; +import { DomainEntity, type Percentage, type UniqueID } from "@repo/rdx-ddd"; +import { type Maybe, Result } from "@repo/rdx-utils"; + +import type { InvoiceAmount } from "../../value-objects"; + +export type SupplierInvoiceTaxProps = { + taxableAmount: InvoiceAmount; + + ivaCode: string; + ivaPercentage: Percentage; + ivaAmount: InvoiceAmount; + + recCode: Maybe; + recPercentage: Maybe; + recAmount: InvoiceAmount; + + retentionCode: Maybe; + retentionPercentage: Maybe; + retentionAmount: InvoiceAmount; + + taxesAmount: InvoiceAmount; +}; + +export class SupplierInvoiceTax extends DomainEntity { + public static create( + props: SupplierInvoiceTaxProps, + id?: UniqueID + ): Result { + return Result.ok(new SupplierInvoiceTax(props, id)); + } + + public get taxableAmount(): InvoiceAmount { + return this.props.taxableAmount; + } + + public get ivaCode(): string { + return this.props.ivaCode; + } + public get ivaPercentage(): TaxPercentage { + return this.props.ivaPercentage; + } + public get ivaAmount(): InvoiceAmount { + return this.props.ivaAmount; + } + + public get recCode(): Maybe { + return this.props.recCode; + } + public get recPercentage(): Maybe { + return this.props.recPercentage; + } + public get recAmount(): InvoiceAmount { + return this.props.recAmount; + } + + public get retentionCode(): Maybe { + return this.props.retentionCode; + } + public get retentionPercentage(): Maybe { + return this.props.retentionPercentage; + } + public get retentionAmount(): InvoiceAmount { + return this.props.retentionAmount; + } + + public get taxesAmount(): InvoiceAmount { + return this.props.taxesAmount; + } + + public getProps(): SupplierInvoiceTaxProps { + return this.props; + } +} diff --git a/modules/supplier-invoices/src/api/domain/entities/supplier-invoice-taxes/supplier-invoice-taxes.collection.ts b/modules/supplier-invoices/src/api/domain/entities/supplier-invoice-taxes/supplier-invoice-taxes.collection.ts new file mode 100644 index 00000000..ef5c51a2 --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/entities/supplier-invoice-taxes/supplier-invoice-taxes.collection.ts @@ -0,0 +1,74 @@ +import type { CurrencyCode, LanguageCode } from "@repo/rdx-ddd"; +import { Collection } from "@repo/rdx-utils"; + +import { InvoiceAmount } from "../../value-objects"; + +import type { SupplierInvoiceTax } from "./supplier-invoice-tax.entity"; + +export type SupplierInvoiceTaxesProps = { + taxes?: SupplierInvoiceTax[]; + languageCode: LanguageCode; + currencyCode: CurrencyCode; +}; + +export class SupplierInvoiceTaxes extends Collection { + private languageCode!: LanguageCode; + private currencyCode!: CurrencyCode; + + constructor(props: SupplierInvoiceTaxesProps) { + super(props.taxes ?? []); + this.languageCode = props.languageCode; + this.currencyCode = props.currencyCode; + } + + public static create(props: SupplierInvoiceTaxesProps): SupplierInvoiceTaxes { + return new SupplierInvoiceTaxes(props); + } + + public getTaxableAmount(): InvoiceAmount { + return this.items.reduce( + (acc, tax) => acc.add(tax.taxableAmount), + InvoiceAmount.zero(this.currencyCode.toString()) + ); + } + + public getIvaAmount(): InvoiceAmount { + return this.items.reduce( + (acc, tax) => acc.add(tax.ivaAmount), + InvoiceAmount.zero(this.currencyCode.toString()) + ); + } + + public getRecAmount(): InvoiceAmount { + return this.items.reduce( + (acc, tax) => acc.add(tax.recAmount), + InvoiceAmount.zero(this.currencyCode.toString()) + ); + } + + public getRetentionAmount(): InvoiceAmount { + return this.items.reduce( + (acc, tax) => acc.add(tax.retentionAmount), + InvoiceAmount.zero(this.currencyCode.toString()) + ); + } + + public getTaxesAmount(): InvoiceAmount { + return this.items.reduce( + (acc, tax) => acc.add(tax.taxesAmount), + InvoiceAmount.zero(this.currencyCode.toString()) + ); + } + + public getTransferredTaxesAmount(): InvoiceAmount { + return this.getIvaAmount().add(this.getRecAmount()); + } + + public getNetTaxesAmount(): InvoiceAmount { + return this.getTransferredTaxesAmount().subtract(this.getRetentionAmount()); + } + + public getTotalAmount(): InvoiceAmount { + return this.getTaxableAmount().add(this.getNetTaxesAmount()); + } +} diff --git a/modules/supplier-invoices/src/api/domain/enums/index.ts b/modules/supplier-invoices/src/api/domain/enums/index.ts new file mode 100644 index 00000000..687713fd --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/enums/index.ts @@ -0,0 +1 @@ +export * from "./supplier-invoice-source-type.enum"; diff --git a/modules/supplier-invoices/src/api/domain/enums/supplier-invoice-source-type.enum.ts b/modules/supplier-invoices/src/api/domain/enums/supplier-invoice-source-type.enum.ts new file mode 100644 index 00000000..4a353558 --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/enums/supplier-invoice-source-type.enum.ts @@ -0,0 +1,20 @@ +/** + * Indica el origen de la factura. + * + * Reglas: + * - Determina cómo se ha creado la factura en el sistema + * - No implica calidad de datos ni estado de validación + * - No debe usarse para lógica de negocio compleja en MVP + */ +export enum SupplierInvoiceSourceType { + /** + * Creada manualmente por el usuario. + */ + MANUAL = "MANUAL", + + /** + * Creada a partir de un documento (PDF) subido. + * Puede haber sido enriquecida parcialmente por parsing. + */ + DOCUMENT = "DOCUMENT", +} diff --git a/modules/supplier-invoices/src/api/domain/enums/supplier-invoice-status.enum.ts b/modules/supplier-invoices/src/api/domain/enums/supplier-invoice-status.enum.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/domain/errors/supplier-invoice.errors.ts b/modules/supplier-invoices/src/api/domain/errors/supplier-invoice.errors.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/domain/index.ts b/modules/supplier-invoices/src/api/domain/index.ts new file mode 100644 index 00000000..59e9091f --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/index.ts @@ -0,0 +1,3 @@ +export * from "./aggregates"; +export * from "./entities"; +export * from "./value-objects"; diff --git a/modules/supplier-invoices/src/api/domain/value-objects/index.ts b/modules/supplier-invoices/src/api/domain/value-objects/index.ts new file mode 100644 index 00000000..1056c7e3 --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/value-objects/index.ts @@ -0,0 +1,4 @@ +export * from "./invoice-amount.vo"; +export * from "./invoice-date.vo"; +export * from "./invoice-number.vo"; +export * from "./supplier-invoice-status.vo"; diff --git a/modules/supplier-invoices/src/api/domain/value-objects/invoice-amount.vo.ts b/modules/supplier-invoices/src/api/domain/value-objects/invoice-amount.vo.ts new file mode 100644 index 00000000..fc8e8030 --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/value-objects/invoice-amount.vo.ts @@ -0,0 +1,96 @@ +import { MoneyValue, type MoneyValueProps, type Percentage, type Quantity } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +type InvoiceAmountProps = Pick; + +export class InvoiceAmount extends MoneyValue { + public static DEFAULT_SCALE = 2; + + static create({ value, currency_code }: InvoiceAmountProps) { + const props = { + value: Number(value), + scale: InvoiceAmount.DEFAULT_SCALE, + currency_code, + }; + return Result.ok(new InvoiceAmount(props)); + } + + static zero(currency_code: string) { + const props = { + value: 0, + currency_code, + }; + return InvoiceAmount.create(props).data; + } + + toObjectString() { + return { + value: String(this.value), + scale: String(this.scale), + currency_code: this.currencyCode, + }; + } + + // Ensure fluent operations keep the subclass type + roundUsingScale(intermediateScale: number) { + const scaled = super.convertScale(intermediateScale); + const normalized = scaled.convertScale(InvoiceAmount.DEFAULT_SCALE); + const p = normalized.toPrimitive(); + + return new InvoiceAmount({ + value: p.value, + currency_code: p.currency_code, + scale: InvoiceAmount.DEFAULT_SCALE, + }); + } + + add(addend: MoneyValue) { + const mv = super.add(addend); + const p = mv.toPrimitive(); + return new InvoiceAmount({ + value: p.value, + currency_code: p.currency_code, + scale: InvoiceAmount.DEFAULT_SCALE, + }); + } + + subtract(subtrahend: MoneyValue) { + const mv = super.subtract(subtrahend); + const p = mv.toPrimitive(); + return new InvoiceAmount({ + value: p.value, + currency_code: p.currency_code, + scale: InvoiceAmount.DEFAULT_SCALE, + }); + } + + multiply(multiplier: number | Quantity) { + const mv = super.multiply(multiplier); + const p = mv.toPrimitive(); + return new InvoiceAmount({ + value: p.value, + currency_code: p.currency_code, + scale: InvoiceAmount.DEFAULT_SCALE, + }); + } + + divide(divisor: number | Quantity) { + const mv = super.divide(divisor); + const p = mv.toPrimitive(); + return new InvoiceAmount({ + value: p.value, + currency_code: p.currency_code, + scale: InvoiceAmount.DEFAULT_SCALE, + }); + } + + percentage(percentage: number | Percentage) { + const mv = super.percentage(percentage); + const p = mv.toPrimitive(); + return new InvoiceAmount({ + value: p.value, + currency_code: p.currency_code, + scale: InvoiceAmount.DEFAULT_SCALE, + }); + } +} diff --git a/modules/supplier-invoices/src/api/domain/value-objects/invoice-date.vo.ts b/modules/supplier-invoices/src/api/domain/value-objects/invoice-date.vo.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/domain/value-objects/invoice-number.vo.ts b/modules/supplier-invoices/src/api/domain/value-objects/invoice-number.vo.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/domain/value-objects/supplier-invoice-status.vo.ts b/modules/supplier-invoices/src/api/domain/value-objects/supplier-invoice-status.vo.ts new file mode 100644 index 00000000..33ec87d8 --- /dev/null +++ b/modules/supplier-invoices/src/api/domain/value-objects/supplier-invoice-status.vo.ts @@ -0,0 +1,115 @@ +import { DomainValidationError, ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +type ISupplierInvoiceStatusProps = { + value: string; +}; + +export enum SUPPLIER_INVOICE_STATUS { + DRAFT = "DRAFT", + NEEDS_REVIEW = "NEEDS_REVIEW", + CONFIRMED = "CONFIRMED", + CANCELLED = "CANCELLED", +} + +const SUPPLIER_INVOICE_TRANSITIONS: Record = { + [SUPPLIER_INVOICE_STATUS.DRAFT]: [ + SUPPLIER_INVOICE_STATUS.NEEDS_REVIEW, + SUPPLIER_INVOICE_STATUS.CONFIRMED, + SUPPLIER_INVOICE_STATUS.CANCELLED, + ], + [SUPPLIER_INVOICE_STATUS.NEEDS_REVIEW]: [ + SUPPLIER_INVOICE_STATUS.DRAFT, + SUPPLIER_INVOICE_STATUS.CONFIRMED, + SUPPLIER_INVOICE_STATUS.CANCELLED, + ], + [SUPPLIER_INVOICE_STATUS.CONFIRMED]: [ + SUPPLIER_INVOICE_STATUS.NEEDS_REVIEW, + SUPPLIER_INVOICE_STATUS.CANCELLED, + ], + [SUPPLIER_INVOICE_STATUS.CANCELLED]: [], +}; + +export class SupplierInvoiceStatus extends ValueObject { + private static readonly ALLOWED_STATUSES = [ + SUPPLIER_INVOICE_STATUS.DRAFT, + SUPPLIER_INVOICE_STATUS.NEEDS_REVIEW, + SUPPLIER_INVOICE_STATUS.CONFIRMED, + SUPPLIER_INVOICE_STATUS.CANCELLED, + ]; + + private static readonly FIELD = "supplierInvoiceStatus"; + private static readonly ERROR_CODE = "INVALID_SUPPLIER_INVOICE_STATUS"; + + public static create(value: string): Result { + if (!SupplierInvoiceStatus.ALLOWED_STATUSES.includes(value as SUPPLIER_INVOICE_STATUS)) { + const detail = `Estado de la factura de proveedor no válido: ${value}`; + + return Result.fail( + new DomainValidationError( + SupplierInvoiceStatus.ERROR_CODE, + SupplierInvoiceStatus.FIELD, + detail + ) + ); + } + + return Result.ok( + value === SUPPLIER_INVOICE_STATUS.NEEDS_REVIEW + ? SupplierInvoiceStatus.needsReview() + : value === SUPPLIER_INVOICE_STATUS.CONFIRMED + ? SupplierInvoiceStatus.confirmed() + : value === SUPPLIER_INVOICE_STATUS.CANCELLED + ? SupplierInvoiceStatus.cancelled() + : SupplierInvoiceStatus.draft() + ); + } + + public static draft(): SupplierInvoiceStatus { + return new SupplierInvoiceStatus({ value: SUPPLIER_INVOICE_STATUS.DRAFT }); + } + + public static needsReview(): SupplierInvoiceStatus { + return new SupplierInvoiceStatus({ value: SUPPLIER_INVOICE_STATUS.NEEDS_REVIEW }); + } + + public static confirmed(): SupplierInvoiceStatus { + return new SupplierInvoiceStatus({ value: SUPPLIER_INVOICE_STATUS.CONFIRMED }); + } + + public static cancelled(): SupplierInvoiceStatus { + return new SupplierInvoiceStatus({ value: SUPPLIER_INVOICE_STATUS.CANCELLED }); + } + + public isDraft(): boolean { + return this.props.value === SUPPLIER_INVOICE_STATUS.DRAFT; + } + + public isNeedsReview(): boolean { + return this.props.value === SUPPLIER_INVOICE_STATUS.NEEDS_REVIEW; + } + + public isConfirmed(): boolean { + return this.props.value === SUPPLIER_INVOICE_STATUS.CONFIRMED; + } + + public isCancelled(): boolean { + return this.props.value === SUPPLIER_INVOICE_STATUS.CANCELLED; + } + + public canTransitionTo(nextStatus: string): boolean { + return SUPPLIER_INVOICE_TRANSITIONS[this.props.value].includes(nextStatus); + } + + public getProps(): string { + return this.props.value; + } + + public toPrimitive(): string { + return this.getProps(); + } + + public toString(): string { + return String(this.props.value); + } +} diff --git a/modules/supplier-invoices/src/api/infrastucture/express/supplier-invoice/supplier-invoice.controller.ts b/modules/supplier-invoices/src/api/infrastucture/express/supplier-invoice/supplier-invoice.controller.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/infrastucture/express/supplier-invoice/supplier-invoice.routes.ts b/modules/supplier-invoices/src/api/infrastucture/express/supplier-invoice/supplier-invoice.routes.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/infrastucture/express/supplier-invoice/supplier-invoice.schemas.ts b/modules/supplier-invoices/src/api/infrastucture/express/supplier-invoice/supplier-invoice.schemas.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/infrastucture/index.ts b/modules/supplier-invoices/src/api/infrastucture/index.ts new file mode 100644 index 00000000..a1b229d5 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/index.ts @@ -0,0 +1,70 @@ +import type { IModuleServer } from "@erp/core/api"; + +export const supplierInvoicesAPIModule: IModuleServer = { + name: "supplier-invoices", + version: "1.0.0", + dependencies: [], + + /** + * Fase de SETUP + * ---------------- + * - Construye el dominio (una sola vez) + * - Define qué expone el módulo + * - NO conecta infraestructura + */ + async setup(params) { + const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; + + // 1) Dominio interno + const internal = buildSupplierInvoicesDependencies(params); + + // 2) Servicios públicos (Application Services) + const supplierinvoicesServices: ISupplierInvoicePublicServices = + buildSupplierInvoicePublicServices(params, internal); + + logger.info("🚀 Supplier invoices module dependencies registered", { + label: this.name, + }); + + return { + // Modelos Sequelize del módulo + models, + + // Servicios expuestos a otros módulos + services: { + general: supplierinvoicesServices, // 'supplierinvoices:general' + }, + + // Implementación privada del módulo + internal, + }; + }, + + /** + * Fase de START + * ------------- + * - Conecta el módulo al runtime + * - Puede usar servicios e internals ya construidos + * - NO construye dominio + */ + async start(params) { + const { app, baseRoutePath, logger, getInternal } = params; + + // Registro de rutas HTTP + supplierInvoicesRouter(params); + + logger.info("🚀 Supplier invoices module started", { + label: this.name, + }); + }, + + /** + * Warmup opcional (si lo necesitas en el futuro) + * ---------------------------------------------- + * warmup(params) { + * ... + * } + */ +}; + +export default supplierInvoicesAPIModule; diff --git a/modules/supplier-invoices/src/api/infrastucture/jobs/supplier-invoice-processing.job.ts b/modules/supplier-invoices/src/api/infrastucture/jobs/supplier-invoice-processing.job.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/index.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/index.ts new file mode 100644 index 00000000..502505aa --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/index.ts @@ -0,0 +1,7 @@ +import supplierInvoiceModelInit from "./models/supplier-invoice.model"; +import supplierInvoiceTaxesModelInit from "./models/supplier-invoice-tax.model"; + +export * from "./models"; + +// Array de inicializadores para que registerModels() lo use +export const models = [supplierInvoiceModelInit, supplierInvoiceTaxesModelInit]; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/index.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/index.ts new file mode 100644 index 00000000..2b35b3f8 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-issued-invoice-domain.mapper"; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts new file mode 100644 index 00000000..3a9819d3 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-domain.mapper.ts @@ -0,0 +1,525 @@ +import { DiscountPercentage, type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; +import { + CurrencyCode, + LanguageCode, + TextValue, + UniqueID, + UtcDate, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, + maybeToNullable, +} from "@repo/rdx-ddd"; +import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils"; + +import { + type InternalIssuedInvoiceProps, + InvoiceAmount, + InvoiceNumber, + InvoicePaymentMethod, + InvoiceSerie, + InvoiceStatus, + IssuedInvoice, + IssuedInvoiceItems, + IssuedInvoiceTaxes, +} from "../../../../../../domain"; +import type { + CustomerInvoiceCreationAttributes, + CustomerInvoiceModel, +} from "../../../../../common"; + +import { SequelizeIssuedInvoiceItemDomainMapper } from "./sequelize-issued-invoice-item-domain.mapper"; +import { SequelizeIssuedInvoiceRecipientDomainMapper } from "./sequelize-issued-invoice-recipient-domain.mapper"; +import { SequelizeIssuedInvoiceTaxesDomainMapper } from "./sequelize-issued-invoice-taxes-domain.mapper"; +import { SequelizeIssuedInvoiceVerifactuDomainMapper } from "./sequelize-verifactu-record-domain.mapper"; + +export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper< + CustomerInvoiceModel, + CustomerInvoiceCreationAttributes, + IssuedInvoice +> { + private _itemsMapper: SequelizeIssuedInvoiceItemDomainMapper; + private _recipientMapper: SequelizeIssuedInvoiceRecipientDomainMapper; + private _taxesMapper: SequelizeIssuedInvoiceTaxesDomainMapper; + private _verifactuMapper: SequelizeIssuedInvoiceVerifactuDomainMapper; + + constructor(params: MapperParamsType) { + super(); + + this._itemsMapper = new SequelizeIssuedInvoiceItemDomainMapper(params); // Instanciar el mapper de items + this._recipientMapper = new SequelizeIssuedInvoiceRecipientDomainMapper(); + this._taxesMapper = new SequelizeIssuedInvoiceTaxesDomainMapper(params); + this._verifactuMapper = new SequelizeIssuedInvoiceVerifactuDomainMapper(); + } + + private _mapAttributesToDomain(raw: CustomerInvoiceModel, params?: MapperParamsType) { + const { errors } = params as { + errors: ValidationErrorDetail[]; + }; + + const invoiceId = extractOrPushError(UniqueID.create(raw.id), "id", errors); + const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors); + + const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors); + + // Para issued invoices, proforma_id debe estar relleno + const proformaId = extractOrPushError( + UniqueID.create(String(raw.proforma_id)), + "proforma_id", + errors + ); + + const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors); + + const series = extractOrPushError( + maybeFromNullableResult(raw.series, (v) => InvoiceSerie.create(v)), + "series", + errors + ); + + const invoiceNumber = extractOrPushError( + InvoiceNumber.create(raw.invoice_number), + "invoice_number", + errors + ); + + // Fechas + const invoiceDate = extractOrPushError( + UtcDate.createFromISO(raw.invoice_date), + "invoice_date", + errors + ); + + const operationDate = extractOrPushError( + maybeFromNullableResult(raw.operation_date, (v) => UtcDate.createFromISO(v)), + "operation_date", + errors + ); + + // Idioma / divisa + const languageCode = extractOrPushError( + LanguageCode.create(raw.language_code), + "language_code", + errors + ); + + const currencyCode = extractOrPushError( + CurrencyCode.create(raw.currency_code), + "currency_code", + errors + ); + + // Textos opcionales + const reference = extractOrPushError( + maybeFromNullableResult(raw.reference, (value) => Result.ok(String(value))), + "reference", + errors + ); + + const description = extractOrPushError( + maybeFromNullableResult(raw.description, (value) => Result.ok(String(value))), + "description", + errors + ); + + const notes = extractOrPushError( + maybeFromNullableResult(raw.notes, (value) => TextValue.create(value)), + "notes", + errors + ); + + // Método de pago (VO opcional con id + descripción) + let paymentMethod = Maybe.none(); + + if (!isNullishOrEmpty(raw.payment_method_id)) { + const paymentId = extractOrPushError( + UniqueID.create(String(raw.payment_method_id)), + "paymentMethod.id", + errors + ); + + const paymentVO = extractOrPushError( + InvoicePaymentMethod.create( + { paymentDescription: String(raw.payment_method_description ?? "") }, + paymentId ?? undefined + ), + "payment_method_description", + errors + ); + + if (paymentVO) { + paymentMethod = Maybe.some(paymentVO); + } + } + + const subtotalAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.subtotal_amount_value, + currency_code: currencyCode?.code, + }), + "subtotal_amount_value", + errors + ); + + // Total descuento de líneas + + const itemsDiscountAmount = extractOrPushError( + InvoiceAmount.create({ + value: Number(raw.items_discount_amount_value ?? 0), + currency_code: currencyCode?.code, + }), + "items_discount_amount_value", + errors + ); + + // % descuento global (VO) + const globalDiscountPercentage = extractOrPushError( + DiscountPercentage.create({ + value: Number(raw.global_discount_percentage_value ?? 0), + }), + "global_discount_percentage_value", + errors + ); + + const globalDiscountAmount = extractOrPushError( + InvoiceAmount.create({ + value: Number(raw.global_discount_amount_value ?? 0), + currency_code: currencyCode?.code, + }), + "global_discount_amount_value", + errors + ); + + const totalDiscountAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.total_discount_amount_value, + currency_code: currencyCode?.code, + }), + "total_discount_amount_value", + errors + ); + + const taxableAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.taxable_amount_value, + currency_code: currencyCode?.code, + }), + "taxable_amount_value", + errors + ); + + const ivaAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.iva_amount_value, + currency_code: currencyCode?.code, + }), + "iva_amount_value", + errors + ); + + const recAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.rec_amount_value, + currency_code: currencyCode?.code, + }), + "rec_amount_value", + errors + ); + + const retentionAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.retention_amount_value, + currency_code: currencyCode?.code, + }), + "retention_amount_value", + errors + ); + + const taxesAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.taxes_amount_value, + currency_code: currencyCode?.code, + }), + "taxes_amount_value", + errors + ); + + const totalAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.total_amount_value, + currency_code: currencyCode?.code, + }), + "total_amount_value", + errors + ); + + return { + invoiceId, + companyId, + customerId, + proformaId, + status, + series, + invoiceNumber, + invoiceDate, + operationDate, + reference, + description, + notes, + languageCode, + currencyCode, + paymentMethod, + + subtotalAmount, + itemsDiscountAmount, + globalDiscountPercentage, + globalDiscountAmount, + totalDiscountAmount, + taxableAmount, + ivaAmount, + recAmount, + retentionAmount, + taxesAmount, + totalAmount, + }; + } + + public mapToDomain( + raw: CustomerInvoiceModel, + params?: MapperParamsType + ): Result { + try { + const errors: ValidationErrorDetail[] = []; + + // 1) Valores escalares (atributos generales) + const attributes = this._mapAttributesToDomain(raw, { errors, ...params }); + + // 2) Recipient (snapshot en la factura o include) + const recipientResult = this._recipientMapper.mapToDomain(raw, { + errors, + attributes, + ...params, + }); + + // 3) Verifactu (snapshot en la factura o include) + const verifactuResult = this._verifactuMapper.mapToDomain(raw.verifactu, { + errors, + attributes, + ...params, + }); + + // 4) Items (colección) + const itemsResults = this._itemsMapper.mapToDomainCollection(raw.items, raw.items.length, { + errors, + attributes, + ...params, + }); + + // 5) Taxes (colección) + const taxesResults = this._taxesMapper.mapToDomainCollection(raw.taxes, raw.taxes.length, { + errors, + attributes, + ...params, + }); + + // 6) Si hubo errores de mapeo, devolvemos colección de validación + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Customer invoice mapping failed [mapToDomain]", errors) + ); + } + + // 6) Construcción del agregado (Dominio) + + const verifactu = verifactuResult.data; + + const items = IssuedInvoiceItems.create({ + items: itemsResults.data.getAll(), + languageCode: attributes.languageCode!, + currencyCode: attributes.currencyCode!, + globalDiscountPercentage: attributes.globalDiscountPercentage!, + }); + + const taxes = IssuedInvoiceTaxes.create({ + taxes: taxesResults.data.getAll(), + languageCode: attributes.languageCode!, + currencyCode: attributes.currencyCode!, + }); + + const invoiceProps: InternalIssuedInvoiceProps = { + companyId: attributes.companyId!, + + proformaId: attributes.proformaId!, + status: attributes.status!, + series: attributes.series!, + invoiceNumber: attributes.invoiceNumber!, + invoiceDate: attributes.invoiceDate!, + operationDate: attributes.operationDate!, + + customerId: attributes.customerId!, + recipient: recipientResult.data, + + reference: attributes.reference!, + description: attributes.description!, + notes: attributes.notes!, + + languageCode: attributes.languageCode!, + currencyCode: attributes.currencyCode!, + + subtotalAmount: attributes.subtotalAmount!, + + itemsDiscountAmount: attributes.itemsDiscountAmount!, + globalDiscountPercentage: attributes.globalDiscountPercentage!, + globalDiscountAmount: attributes.globalDiscountAmount!, + totalDiscountAmount: attributes.totalDiscountAmount!, + + taxableAmount: attributes.taxableAmount!, + ivaAmount: attributes.ivaAmount!, + recAmount: attributes.recAmount!, + retentionAmount: attributes.retentionAmount!, + + taxesAmount: attributes.taxesAmount!, + totalAmount: attributes.totalAmount!, + + paymentMethod: attributes.paymentMethod!, + + taxes, + verifactu, + }; + + const invoiceId = attributes.invoiceId!; + const invoice = IssuedInvoice.rehydrate(invoiceProps, items, invoiceId); + + return Result.ok(invoice); + } catch (err: unknown) { + return Result.fail(err as Error); + } + } + + public mapToPersistence( + source: IssuedInvoice, + params?: MapperParamsType + ): Result { + const errors: ValidationErrorDetail[] = []; + + // 1) Items + const itemsResult = this._itemsMapper.mapToPersistenceArray(source.items, { + errors, + parent: source, + ...params, + }); + + if (itemsResult.isFailure) { + errors.push({ + path: "items", + message: itemsResult.error.message, + }); + } + + // 2) Taxes + const taxesResult = this._taxesMapper.mapToPersistenceArray(source.taxes, { + errors, + parent: source, + ...params, + }); + + if (taxesResult.isFailure) { + errors.push({ + path: "taxes", + message: taxesResult.error.message, + }); + } + + // 3) Cliente + const recipient = this._recipientMapper.mapToPersistence(source.recipient, { + errors, + parent: source, + ...params, + }); + + // 4) Verifactu + const verifactuResult = this._verifactuMapper.mapToPersistence(source.verifactu, { + errors, + parent: source, + ...params, + }); + + // 5) Si hubo errores de mapeo, devolvemos colección de validación + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Customer invoice mapping to persistence failed", errors) + ); + } + + const items = itemsResult.data; + const taxes = taxesResult.data; + const verifactu = verifactuResult.data; + + const invoiceValues: Partial = { + // Identificación + id: source.id.toPrimitive(), + company_id: source.companyId.toPrimitive(), + + // Flags / estado / serie / número + is_proforma: false, + status: source.status.toPrimitive(), + proforma_id: source.proformaId.toPrimitive(), + + series: maybeToNullable(source.series, (v) => v.toPrimitive()), + invoice_number: source.invoiceNumber.toPrimitive(), + invoice_date: source.invoiceDate.toPrimitive(), + operation_date: maybeToNullable(source.operationDate, (v) => v.toPrimitive()), + language_code: source.languageCode.toPrimitive(), + currency_code: source.currencyCode.toPrimitive(), + + reference: maybeToNullable(source.reference, (reference) => reference), + description: maybeToNullable(source.description, (description) => description), + notes: maybeToNullable(source.notes, (v) => v.toPrimitive()), + + payment_method_id: maybeToNullable( + source.paymentMethod, + (payment) => payment.toObjectString().id + ), + payment_method_description: maybeToNullable( + source.paymentMethod, + (payment) => payment.toObjectString().payment_description + ), + + subtotal_amount_value: source.subtotalAmount.value, + subtotal_amount_scale: source.subtotalAmount.scale, + + items_discount_amount_value: source.itemsDiscountAmount.value, + items_discount_amount_scale: source.itemsDiscountAmount.scale, + + global_discount_percentage_value: source.globalDiscountPercentage.toPrimitive().value, + global_discount_percentage_scale: source.globalDiscountPercentage.toPrimitive().scale, + + global_discount_amount_value: source.globalDiscountAmount.value, + global_discount_amount_scale: source.globalDiscountAmount.scale, + + total_discount_amount_value: source.totalDiscountAmount.value, + total_discount_amount_scale: source.totalDiscountAmount.scale, + + taxable_amount_value: source.taxableAmount.value, + taxable_amount_scale: source.taxableAmount.scale, + + taxes_amount_value: source.taxesAmount.value, + taxes_amount_scale: source.taxesAmount.scale, + + total_amount_value: source.totalAmount.value, + total_amount_scale: source.totalAmount.scale, + + customer_id: source.customerId.toPrimitive(), + ...recipient, + + taxes, + items, + verifactu, + }; + + return Result.ok( + invoiceValues as CustomerInvoiceCreationAttributes + ); + } +} diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-item-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-item-domain.mapper.ts new file mode 100644 index 00000000..17248d7c --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-item-domain.mapper.ts @@ -0,0 +1,416 @@ +import type { JsonTaxCatalogProvider } from "@erp/core"; +import { + DiscountPercentage, + type MapperParamsType, + SequelizeDomainMapper, + TaxPercentage, +} from "@erp/core/api"; +import { + UniqueID, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableOrEmptyString, + maybeFromNullableResult, + maybeToNullable, + maybeToNullableString, +} from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { + type IIssuedInvoiceCreateProps, + type IIssuedInvoiceItemCreateProps, + type IssuedInvoice, + IssuedInvoiceItem, + ItemAmount, + ItemDescription, + ItemQuantity, +} from "../../../../../../domain"; +import type { + CustomerInvoiceItemCreationAttributes, + CustomerInvoiceItemModel, +} from "../../../../../common"; + +export class SequelizeIssuedInvoiceItemDomainMapper extends SequelizeDomainMapper< + CustomerInvoiceItemModel, + CustomerInvoiceItemCreationAttributes, + IssuedInvoiceItem +> { + private readonly taxCatalog!: JsonTaxCatalogProvider; + + constructor(params: MapperParamsType) { + super(); + const { taxCatalog } = params as { + taxCatalog: JsonTaxCatalogProvider; + }; + + if (!taxCatalog) { + throw new Error('taxCatalog not defined ("SequelizeIssuedInvoiceItemDomainMapper")'); + } + + this.taxCatalog = taxCatalog; + } + + private mapAttributesToDomain( + raw: CustomerInvoiceItemModel, + params?: MapperParamsType + ): Partial & { itemId?: UniqueID } { + const { errors, index, attributes } = params as { + index: number; + errors: ValidationErrorDetail[]; + attributes: Partial; + }; + + const itemId = extractOrPushError( + UniqueID.create(raw.item_id), + `items[${index}].item_id`, + errors + ); + + const description = extractOrPushError( + maybeFromNullableResult(raw.description, (v) => ItemDescription.create(v)), + `items[${index}].description`, + errors + ); + + const quantity = extractOrPushError( + maybeFromNullableResult(raw.quantity_value, (v) => ItemQuantity.create({ value: v })), + `items[${index}].quantity_value`, + errors + ); + + const unitAmount = extractOrPushError( + maybeFromNullableResult(raw.unit_amount_value, (value) => + ItemAmount.create({ value, currency_code: attributes.currencyCode?.code }) + ), + `items[${index}].unit_amount_value`, + errors + ); + + const subtotalAmount = extractOrPushError( + ItemAmount.create({ + value: raw.subtotal_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].subtotal_amount_value`, + errors + ); + + const itemDiscountPercentage = extractOrPushError( + maybeFromNullableResult(raw.item_discount_percentage_value, (v) => + DiscountPercentage.create({ value: v }) + ), + `items[${index}].item_discount_percentage_value`, + errors + ); + + const itemDiscountAmount = extractOrPushError( + ItemAmount.create({ + value: raw.item_discount_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].item_discount_amount_value`, + errors + ); + + const globalDiscountPercentage = extractOrPushError( + DiscountPercentage.create({ value: raw.global_discount_percentage_value }), + `items[${index}].global_discount_percentage_value`, + errors + ); + + const globalDiscountAmount = extractOrPushError( + ItemAmount.create({ + value: raw.global_discount_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].global_discount_amount_value`, + errors + ); + + const totalDiscountAmount = extractOrPushError( + ItemAmount.create({ + value: raw.total_discount_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].total_discount_amount_value`, + errors + ); + + const taxableAmount = extractOrPushError( + ItemAmount.create({ + value: raw.taxable_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].taxable_amount_value`, + errors + ); + + const ivaCode = maybeFromNullableOrEmptyString(raw.iva_code); + + const ivaPercentage = extractOrPushError( + maybeFromNullableResult(raw.iva_percentage_value, (value) => TaxPercentage.create({ value })), + `items[${index}].iva_percentage_value`, + errors + ); + + const ivaAmount = extractOrPushError( + ItemAmount.create({ + value: raw.iva_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].iva_amount_value`, + errors + ); + + const recCode = maybeFromNullableOrEmptyString(raw.rec_code); + + const recPercentage = extractOrPushError( + maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })), + `items[${index}].rec_percentage_value`, + errors + ); + + const recAmount = extractOrPushError( + ItemAmount.create({ + value: raw.rec_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].rec_amount_value`, + errors + ); + + const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code); + + const retentionPercentage = extractOrPushError( + maybeFromNullableResult(raw.retention_percentage_value, (value) => + TaxPercentage.create({ value }) + ), + `items[${index}].retention_percentage_value`, + errors + ); + + const retentionAmount = extractOrPushError( + ItemAmount.create({ + value: raw.retention_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].retention_amount_value`, + errors + ); + + const taxesAmount = extractOrPushError( + ItemAmount.create({ + value: raw.taxes_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].taxes_amount_value`, + errors + ); + + const totalAmount = extractOrPushError( + ItemAmount.create({ + value: raw.total_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `items[${index}].total_amount_value`, + errors + ); + + return { + itemId, + languageCode: attributes.languageCode, + currencyCode: attributes.currencyCode, + description, + + quantity, + unitAmount, + subtotalAmount, + + itemDiscountPercentage, + itemDiscountAmount, + globalDiscountPercentage, + globalDiscountAmount, + totalDiscountAmount, + + taxableAmount, + + ivaCode, + ivaPercentage, + ivaAmount, + + recCode, + recPercentage, + recAmount, + + retentionCode, + retentionPercentage, + retentionAmount, + + taxesAmount, + totalAmount, + }; + } + + public mapToDomain( + source: CustomerInvoiceItemModel, + params?: MapperParamsType + ): Result { + const { errors, index } = params as { + index: number; + errors: ValidationErrorDetail[]; + attributes: Partial; + }; + + // 1) Valores escalares (atributos generales) + const attributes = this.mapAttributesToDomain(source, params); + + // Si hubo errores de mapeo, devolvemos colección de validación + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Customer invoice item mapping failed [mapToDomain]", errors) + ); + } + + // 2) Construcción del elemento de dominio + const itemId = attributes.itemId!; + const newItem = IssuedInvoiceItem.rehydrate( + { + description: attributes.description!, + + quantity: attributes.quantity!, + unitAmount: attributes.unitAmount!, + + subtotalAmount: attributes.subtotalAmount!, + + itemDiscountPercentage: attributes.itemDiscountPercentage!, + itemDiscountAmount: attributes.itemDiscountAmount!, + + globalDiscountPercentage: attributes.globalDiscountPercentage!, + globalDiscountAmount: attributes.globalDiscountAmount!, + + totalDiscountAmount: attributes.totalDiscountAmount!, + + taxableAmount: attributes.taxableAmount!, + + ivaCode: attributes.ivaCode!, + ivaPercentage: attributes.ivaPercentage!, + ivaAmount: attributes.ivaAmount!, + + recCode: attributes.recCode!, + recPercentage: attributes.recPercentage!, + recAmount: attributes.recAmount!, + + retentionCode: attributes.retentionCode!, + retentionPercentage: attributes.retentionPercentage!, + retentionAmount: attributes.retentionAmount!, + + taxesAmount: attributes.taxesAmount!, + totalAmount: attributes.totalAmount!, + + languageCode: attributes.languageCode!, + currencyCode: attributes.currencyCode!, + }, + itemId + ); + + return Result.ok(newItem); + } + + public mapToPersistence( + source: IssuedInvoiceItem, + params?: MapperParamsType + ): Result { + const { errors, index, parent } = params as { + index: number; + parent: IssuedInvoice; + errors: ValidationErrorDetail[]; + }; + + return Result.ok({ + item_id: source.id.toPrimitive(), + invoice_id: parent.id.toPrimitive(), + position: index, + + description: maybeToNullable(source.description, (v) => v.toPrimitive()), + + quantity_value: maybeToNullable(source.quantity, (v) => v.toPrimitive().value), + quantity_scale: + maybeToNullable(source.quantity, (v) => v.toPrimitive().scale) ?? + ItemQuantity.DEFAULT_SCALE, + + unit_amount_value: maybeToNullable(source.unitAmount, (v) => v.toPrimitive().value), + unit_amount_scale: + maybeToNullable(source.unitAmount, (v) => v.toPrimitive().scale) ?? + ItemAmount.DEFAULT_SCALE, + + subtotal_amount_value: source.subtotalAmount.toPrimitive().value, + subtotal_amount_scale: source.subtotalAmount.toPrimitive().scale, + + item_discount_percentage_value: maybeToNullable( + source.itemDiscountPercentage, + (v) => v.toPrimitive().value + ), + item_discount_percentage_scale: + maybeToNullable(source.itemDiscountPercentage, (v) => v.toPrimitive().scale) ?? + DiscountPercentage.DEFAULT_SCALE, + + item_discount_amount_value: source.itemDiscountAmount.toPrimitive().value, + item_discount_amount_scale: source.itemDiscountAmount.toPrimitive().scale, + + global_discount_percentage_value: source.globalDiscountPercentage.toPrimitive().value, + global_discount_percentage_scale: + source.globalDiscountPercentage.toPrimitive().scale ?? DiscountPercentage.DEFAULT_SCALE, + + global_discount_amount_value: source.globalDiscountAmount.value, + global_discount_amount_scale: source.globalDiscountAmount.scale, + + total_discount_amount_value: source.totalDiscountAmount.value, + total_discount_amount_scale: source.totalDiscountAmount.scale, + + taxable_amount_value: source.taxableAmount.value, + taxable_amount_scale: source.taxableAmount.scale, + + // IVA + iva_code: maybeToNullableString(source.ivaCode), + + iva_percentage_value: maybeToNullable(source.ivaPercentage, (v) => v.toPrimitive().value), + iva_percentage_scale: + maybeToNullable(source.ivaPercentage, (v) => v.toPrimitive().scale) ?? 2, + + iva_amount_value: source.ivaAmount.value, + iva_amount_scale: source.ivaAmount.scale, + + // REC + rec_code: maybeToNullableString(source.recCode), + + rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value), + rec_percentage_scale: + maybeToNullable(source.recPercentage, (v) => v.toPrimitive().scale) ?? 2, + + rec_amount_value: source.recAmount.value, + rec_amount_scale: source.recAmount.scale, + + // RET + retention_code: maybeToNullableString(source.retentionCode), + + retention_percentage_value: maybeToNullable( + source.retentionPercentage, + (v) => v.toPrimitive().value + ), + retention_percentage_scale: + maybeToNullable(source.retentionPercentage, (v) => v.toPrimitive().scale) ?? 2, + + retention_amount_value: source.retentionAmount.value, + retention_amount_scale: source.retentionAmount.scale, + + // + taxes_amount_value: source.taxesAmount.value, + taxes_amount_scale: source.taxesAmount.scale, + + // + total_amount_value: source.totalAmount.value, + total_amount_scale: source.totalAmount.scale, + }); + } +} diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts new file mode 100644 index 00000000..3a0ab89c --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-recipient-domain.mapper.ts @@ -0,0 +1,156 @@ +import type { MapperParamsType } from "@erp/core/api"; +import { + City, + Country, + Name, + PostalCode, + Province, + Street, + TINNumber, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, + maybeToNullable, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + type IIssuedInvoiceCreateProps, + InvoiceRecipient, + type IssuedInvoice, +} from "../../../../../../domain"; +import type { CustomerInvoiceModel } from "../../../../../common"; + +export class SequelizeIssuedInvoiceRecipientDomainMapper { + public mapToDomain( + source: CustomerInvoiceModel, + params?: MapperParamsType + ): Result, Error> { + /** + * - Issued invoice -> snapshot de los datos (campos customer_*) + */ + + const { errors, attributes } = params as { + errors: ValidationErrorDetail[]; + attributes: Partial; + }; + + const _name = source.customer_name!; + const _tin = source.customer_tin!; + const _street = source.customer_street!; + const _street2 = source.customer_street2!; + const _city = source.customer_city!; + const _postal_code = source.customer_postal_code!; + const _province = source.customer_province!; + const _country = source.customer_country!; + + // Customer (snapshot) + const customerName = extractOrPushError(Name.create(_name!), "customer_name", errors); + + const customerTin = extractOrPushError(TINNumber.create(_tin!), "customer_tin", errors); + + const customerStreet = extractOrPushError( + maybeFromNullableResult(_street, (value) => Street.create(value)), + "customer_street", + errors + ); + + const customerStreet2 = extractOrPushError( + maybeFromNullableResult(_street2, (value) => Street.create(value)), + "customer_street2", + errors + ); + + const customerCity = extractOrPushError( + maybeFromNullableResult(_city, (value) => City.create(value)), + "customer_city", + errors + ); + + const customerProvince = extractOrPushError( + maybeFromNullableResult(_province, (value) => Province.create(value)), + "customer_province", + errors + ); + + const customerPostalCode = extractOrPushError( + maybeFromNullableResult(_postal_code, (value) => PostalCode.create(value)), + "customer_postal_code", + errors + ); + + const customerCountry = extractOrPushError( + maybeFromNullableResult(_country, (value) => Country.create(value)), + "customer_country", + errors + ); + + const createResult = InvoiceRecipient.create({ + name: customerName!, + tin: customerTin!, + street: customerStreet!, + street2: customerStreet2!, + city: customerCity!, + postalCode: customerPostalCode!, + province: customerProvince!, + country: customerCountry!, + }); + + if (createResult.isFailure) { + return Result.fail( + new ValidationErrorCollection("Invoice recipient entity creation failed", [ + { path: "recipient", message: createResult.error.message }, + ]) + ); + } + + return Result.ok(Maybe.some(createResult.data)); + } + + /** + * Mapea los datos del destinatario (recipient) de una factura de cliente + * al formato esperado por la capa de persistencia. + * + * Reglas: + * - Si la factura es proforma (`isProforma === true`), todos los campos de recipient son `null`. + * - Si la factura no es proforma (`isProforma === false`), debe existir `recipient`. + * En caso contrario, se agrega un error de validación. + */ + mapToPersistence(source: Maybe, params?: MapperParamsType) { + const { errors, parent } = params as { + parent: IssuedInvoice; + errors: ValidationErrorDetail[]; + }; + + const { hasRecipient } = parent; + + // Validación: facturas emitidas deben tener destinatario. + if (!hasRecipient) { + errors.push({ + path: "recipient", + message: "[InvoiceRecipientDomainMapper] Issued customer invoice without recipient data", + }); + } + + // Si hay errores previos, devolvemos fallo de validación inmediatamente. + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Customer invoice mapping to persistence failed", errors) + ); + } + + const recipient = source.unwrap(); + + return { + customer_tin: recipient.tin.toPrimitive(), + customer_name: recipient.name.toPrimitive(), + customer_street: maybeToNullable(recipient.street, (v) => v.toPrimitive()), + customer_street2: maybeToNullable(recipient.street2, (v) => v.toPrimitive()), + customer_city: maybeToNullable(recipient.city, (v) => v.toPrimitive()), + customer_province: maybeToNullable(recipient.province, (v) => v.toPrimitive()), + customer_postal_code: maybeToNullable(recipient.postalCode, (v) => v.toPrimitive()), + customer_country: maybeToNullable(recipient.country, (v) => v.toPrimitive()), + }; + } +} diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-taxes-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-taxes-domain.mapper.ts new file mode 100644 index 00000000..d35130d5 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-issued-invoice-taxes-domain.mapper.ts @@ -0,0 +1,249 @@ +import type { JsonTaxCatalogProvider } from "@erp/core"; +import { + DiscountPercentage, + type MapperParamsType, + SequelizeDomainMapper, + TaxPercentage, +} from "@erp/core/api"; +import { + Percentage, + UniqueID, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableOrEmptyString, + maybeFromNullableResult, + maybeToNullable, + maybeToNullableString, +} from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { + type IIssuedInvoiceCreateProps, + InvoiceAmount, + type IssuedInvoice, + IssuedInvoiceTax, + ItemAmount, +} from "../../../../../../domain"; +import type { + CustomerInvoiceTaxCreationAttributes, + CustomerInvoiceTaxModel, +} from "../../../../../common"; + +/** + * Mapper para customer_invoice_taxes + * + * Domina estructuras: + * { + * tax: Tax + * taxableAmount: ItemAmount + * taxesAmount: ItemAmount + * } + * + * Cada fila = un impuesto agregado en toda la factura. + */ +export class SequelizeIssuedInvoiceTaxesDomainMapper extends SequelizeDomainMapper< + CustomerInvoiceTaxModel, + CustomerInvoiceTaxCreationAttributes, + IssuedInvoiceTax +> { + private taxCatalog!: JsonTaxCatalogProvider; + + constructor(params: MapperParamsType) { + super(); + const { taxCatalog } = params as { + taxCatalog: JsonTaxCatalogProvider; + }; + + if (!taxCatalog) { + throw new Error('taxCatalog not defined ("SequelizeIssuedInvoiceTaxesDomainMapper")'); + } + + this.taxCatalog = taxCatalog; + } + + public mapToDomain( + raw: CustomerInvoiceTaxModel, + params?: MapperParamsType + ): Result { + const { errors, index, attributes } = params as { + index: number; + errors: ValidationErrorDetail[]; + attributes: Partial; + }; + + const taxableAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.taxable_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `taxes[${index}].taxable_amount_value`, + errors + ); + + const ivaCode = raw.iva_code; + + // Una issued invoice debe traer IVA + const ivaPercentage = extractOrPushError( + TaxPercentage.create({ + value: Number(raw.iva_percentage_value), + }), + `taxes[${index}].iva_percentage_value`, + errors + ); + + const ivaAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.iva_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `taxes[${index}].iva_amount_value`, + errors + ); + + const recCode = maybeFromNullableOrEmptyString(raw.rec_code); + + const recPercentage = extractOrPushError( + maybeFromNullableResult(raw.rec_percentage_value, (value) => TaxPercentage.create({ value })), + `taxes[${index}].rec_percentage_value`, + errors + ); + + const recAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.rec_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `taxes[${index}].rec_amount_value`, + errors + ); + + const retentionCode = maybeFromNullableOrEmptyString(raw.retention_code); + + const retentionPercentage = extractOrPushError( + maybeFromNullableResult(raw.retention_percentage_value, (value) => + TaxPercentage.create({ value }) + ), + `taxes[${index}].retention_percentage_value`, + errors + ); + + const retentionAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.retention_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `taxes[${index}].retention_amount_value`, + errors + ); + + const taxesAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.taxes_amount_value, + currency_code: attributes.currencyCode?.code, + }), + `taxes[${index}].taxes_amount_value`, + errors + ); + + // Si hubo errores de mapeo, devolvemos colección de validación + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Customer invoice tax mapping failed [mapToDomain]", errors) + ); + } + + // 2) Construcción del elemento de dominio + const createResult = IssuedInvoiceTax.create({ + taxableAmount: taxableAmount!, + + ivaCode: ivaCode!, + ivaPercentage: ivaPercentage!, + ivaAmount: ivaAmount!, + + recCode: recCode, + recPercentage: recPercentage!, + recAmount: recAmount!, + + retentionCode: retentionCode, + retentionPercentage: retentionPercentage!, + retentionAmount: retentionAmount!, + + taxesAmount: taxesAmount!, + }); + + if (createResult.isFailure) { + return Result.fail( + new ValidationErrorCollection("Invoice tax group entity creation failed", [ + { path: `taxes[${index}]`, message: "Invoice tax group entity creation failed" }, + ]) + ); + } + + return createResult; + } + + public mapToPersistence( + source: IssuedInvoiceTax, + params?: MapperParamsType + ): Result { + const { errors, parent } = params as { + parent: IssuedInvoice; + errors: ValidationErrorDetail[]; + }; + + try { + const dto: CustomerInvoiceTaxCreationAttributes = { + tax_id: UniqueID.generateNewID().toPrimitive(), + invoice_id: parent.id.toPrimitive(), + + // TAXABLE AMOUNT + taxable_amount_value: source.taxableAmount.value, + taxable_amount_scale: source.taxableAmount.scale, + + // IVA + iva_code: source.ivaCode, + + iva_percentage_value: source.ivaPercentage.value, + iva_percentage_scale: source.ivaPercentage.scale, + + iva_amount_value: source.ivaAmount.value, + iva_amount_scale: source.ivaAmount.scale, + + // REC + rec_code: maybeToNullableString(source.recCode), + + rec_percentage_value: maybeToNullable(source.recPercentage, (v) => v.toPrimitive().value), + rec_percentage_scale: + maybeToNullable(source.recPercentage, (v) => v.toPrimitive().scale) ?? + DiscountPercentage.DEFAULT_SCALE, + + rec_amount_value: source.recAmount.toPrimitive().value, + rec_amount_scale: source.recAmount.toPrimitive().scale ?? ItemAmount.DEFAULT_SCALE, + + // RET + retention_code: maybeToNullableString(source.retentionCode), + + retention_percentage_value: maybeToNullable( + source.retentionPercentage, + (v) => v.toPrimitive().value + ), + retention_percentage_scale: + maybeToNullable(source.retentionPercentage, (v) => v.toPrimitive().scale) ?? + Percentage.DEFAULT_SCALE, + + retention_amount_value: source.retentionAmount.toPrimitive().value, + retention_amount_scale: + source.retentionAmount.toPrimitive().scale ?? ItemAmount.DEFAULT_SCALE, + + // TOTAL + taxes_amount_value: source.taxesAmount.value, + taxes_amount_scale: source.taxesAmount.scale, + }; + + return Result.ok(dto); + } catch (error: unknown) { + return Result.fail(error as Error); + } + } +} diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts new file mode 100644 index 00000000..ff110981 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/domain/sequelize-verifactu-record-domain.mapper.ts @@ -0,0 +1,135 @@ +import type { MapperParamsType } from "@erp/core/api"; +import { SequelizeDomainMapper } from "@erp/core/api"; +import { + URLAddress, + UniqueID, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, + maybeToEmptyString, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + type IIssuedInvoiceCreateProps, + type IssuedInvoice, + VerifactuRecord, + VerifactuRecordEstado, +} from "../../../../../../domain"; +import type { + VerifactuRecordCreationAttributes, + VerifactuRecordModel, +} from "../../../../../common"; + +export class SequelizeIssuedInvoiceVerifactuDomainMapper extends SequelizeDomainMapper< + VerifactuRecordModel, + VerifactuRecordCreationAttributes, + Maybe +> { + public mapToDomain( + source: VerifactuRecordModel, + params?: MapperParamsType + ): Result, Error> { + const { errors, attributes } = params as { + errors: ValidationErrorDetail[]; + attributes: Partial; + }; + + if (!source) { + return Result.ok(Maybe.none()); + } + + const recordId = extractOrPushError(UniqueID.create(source.id), "id", errors); + const estado = extractOrPushError( + VerifactuRecordEstado.create(source.estado), + "estado", + errors + ); + + const qr = extractOrPushError( + maybeFromNullableResult(source.qr, (value) => Result.ok(String(value))), + "qr", + errors + ); + + const url = extractOrPushError( + maybeFromNullableResult(source.url, (value) => URLAddress.create(value)), + "url", + errors + ); + + const uuid = extractOrPushError( + maybeFromNullableResult(source.uuid, (value) => Result.ok(String(value))), + "uuid", + errors + ); + + const operacion = extractOrPushError( + maybeFromNullableResult(source.operacion, (value) => Result.ok(String(value))), + "operacion", + errors + ); + + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Verifactu record mapping failed [mapToDTO]", errors) + ); + } + + const createResult = VerifactuRecord.create( + { + estado: estado!, + qrCode: qr!, + url: url!, + uuid: uuid!, + operacion: operacion!, + }, + recordId! + ); + + if (createResult.isFailure) { + return Result.fail( + new ValidationErrorCollection("Invoice verifactu entity creation failed", [ + { path: "verifactu", message: createResult.error.message }, + ]) + ); + } + + return Result.ok(Maybe.some(createResult.data)); + } + + mapToPersistence( + source: Maybe, + params?: MapperParamsType + ): Result { + const { errors, parent } = params as { + parent: IssuedInvoice; + errors: ValidationErrorDetail[]; + }; + + if (source.isNone()) { + return Result.ok({ + id: UniqueID.generateNewID().toPrimitive(), + invoice_id: parent.id.toPrimitive(), + estado: VerifactuRecordEstado.createPendiente().toPrimitive(), + qr: "", + url: "", + uuid: "", + operacion: "", + }); + } + + const verifactu = source.unwrap(); + + return Result.ok({ + id: verifactu.id.toPrimitive(), + invoice_id: parent.id.toPrimitive(), + estado: verifactu.estado.toPrimitive(), + qr: maybeToEmptyString(verifactu.qrCode, (v) => v), + url: maybeToEmptyString(verifactu.url, (v) => v.toPrimitive()), + uuid: maybeToEmptyString(verifactu.uuid, (v) => v), + operacion: maybeToEmptyString(verifactu.operacion, (v) => v), + }); + } +} diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/index.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/index.ts new file mode 100644 index 00000000..b7726c46 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/index.ts @@ -0,0 +1,2 @@ +export * from "./domain"; +export * from "./summary"; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/index.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/index.ts new file mode 100644 index 00000000..e5586981 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-issued-invoice-summary.mapper"; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-recipient-summary.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-recipient-summary.mapper.ts new file mode 100644 index 00000000..07f3b567 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-recipient-summary.mapper.ts @@ -0,0 +1,118 @@ +import { type MapperParamsType, SequelizeQueryMapper } from "@erp/core/api"; +import { + City, + Country, + Name, + PostalCode, + Province, + Street, + TINNumber, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, +} from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IssuedInvoiceSummary } from "../../../../../../application"; +import { InvoiceRecipient } from "../../../../../../domain"; +import type { CustomerInvoiceModel } from "../../../../../common"; + +export class SequelizeIssuedInvoiceRecipientListMapper extends SequelizeQueryMapper< + CustomerInvoiceModel, + InvoiceRecipient +> { + public mapToReadModel( + raw: CustomerInvoiceModel, + params?: MapperParamsType + ): Result { + /** + * - Issued invoice => snapshot de los datos (campos customer_*) + */ + + const { errors, attributes } = params as { + errors: ValidationErrorDetail[]; + attributes: Partial; + }; + + const { isProforma } = attributes; + + if (isProforma && !raw.current_customer) { + errors.push({ + path: "current_customer", + message: "Current customer not included in query (InvoiceRecipientListMapper)", + }); + } + + const _name = raw.customer_name!; + const _tin = raw.customer_tin!; + const _street = raw.customer_street!; + const _street2 = raw.customer_street2!; + const _city = raw.customer_city!; + const _postal_code = raw.customer_postal_code!; + const _province = raw.customer_province!; + const _country = raw.customer_country!; + + // Customer (snapshot) + const customerName = extractOrPushError(Name.create(_name!), "customer_name", errors); + + const customerTin = extractOrPushError(TINNumber.create(_tin!), "customer_tin", errors); + + const customerStreet = extractOrPushError( + maybeFromNullableResult(_street, (value) => Street.create(value)), + "customer_street", + errors + ); + + const customerStreet2 = extractOrPushError( + maybeFromNullableResult(_street2, (value) => Street.create(value)), + "customer_street2", + errors + ); + + const customerCity = extractOrPushError( + maybeFromNullableResult(_city, (value) => City.create(value)), + "customer_city", + errors + ); + + const customerProvince = extractOrPushError( + maybeFromNullableResult(_province, (value) => Province.create(value)), + "customer_province", + errors + ); + + const customerPostalCode = extractOrPushError( + maybeFromNullableResult(_postal_code, (value) => PostalCode.create(value)), + "customer_postal_code", + errors + ); + + const customerCountry = extractOrPushError( + maybeFromNullableResult(_country, (value) => Country.create(value)), + "customer_country", + errors + ); + + const createResult = InvoiceRecipient.create({ + name: customerName!, + tin: customerTin!, + street: customerStreet!, + street2: customerStreet2!, + city: customerCity!, + postalCode: customerPostalCode!, + province: customerProvince!, + country: customerCountry!, + }); + + if (createResult.isFailure) { + return Result.fail( + new ValidationErrorCollection("Invoice recipient entity creation failed", [ + { path: "recipient", message: createResult.error.message }, + ]) + ); + } + + return Result.ok(createResult.data); + } +} diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts new file mode 100644 index 00000000..be6877bb --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/summary/sequelize-issued-invoice-summary.mapper.ts @@ -0,0 +1,246 @@ +import { type MapperParamsType, SequelizeQueryMapper } from "@erp/core/api"; +import { + CurrencyCode, + LanguageCode, + UniqueID, + UtcDate, + ValidationErrorCollection, + type ValidationErrorDetail, + extractOrPushError, + maybeFromNullableResult, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { IssuedInvoiceSummary } from "../../../../../../application"; +import { + InvoiceAmount, + InvoiceNumber, + InvoiceSerie, + InvoiceStatus, + type VerifactuRecord, +} from "../../../../../../domain"; +import type { CustomerInvoiceModel } from "../../../../../common"; + +import { SequelizeIssuedInvoiceRecipientListMapper } from "./sequelize-issued-invoice-recipient-summary.mapper"; +import { SequelizeVerifactuRecordSummaryMapper } from "./sequelize-verifactu-record-summary.mapper"; + +export class SequelizeIssuedInvoiceSummaryMapper extends SequelizeQueryMapper< + CustomerInvoiceModel, + IssuedInvoiceSummary +> { + private _recipientMapper: SequelizeIssuedInvoiceRecipientListMapper; + private _verifactuMapper: SequelizeVerifactuRecordSummaryMapper; + + constructor() { + super(); + this._recipientMapper = new SequelizeIssuedInvoiceRecipientListMapper(); + this._verifactuMapper = new SequelizeVerifactuRecordSummaryMapper(); + } + + public mapToReadModel( + raw: CustomerInvoiceModel, + params?: MapperParamsType + ): Result { + const errors: ValidationErrorDetail[] = []; + + // 1) Valores escalares (atributos generales) + const attributes = this._mapAttributesToReadModel(raw, { errors, ...params }); + + // 2) Recipient (snapshot en la factura o include) + const recipientResult = this._recipientMapper.mapToReadModel(raw, { + errors, + attributes, + ...params, + }); + + if (recipientResult.isFailure) { + errors.push({ + path: "recipient", + message: recipientResult.error.message, + }); + } + + // 4) Verifactu record + let verifactu: Maybe = Maybe.none(); + if (raw.verifactu) { + const verifactuResult = this._verifactuMapper.mapToReadModel(raw.verifactu, { + errors, + ...params, + }); + + if (verifactuResult.isFailure) { + errors.push({ + path: "verifactu", + message: verifactuResult.error.message, + }); + } else { + verifactu = Maybe.some(verifactuResult.data); + } + } + + // 5) Si hubo errores de mapeo, devolvemos colección de validación + if (errors.length > 0) { + return Result.fail( + new ValidationErrorCollection("Customer invoice mapping failed [mapToDTO]", errors) + ); + } + + return Result.ok({ + id: attributes.invoiceId!, + companyId: attributes.companyId!, + isProforma: attributes.isProforma, + status: attributes.status!, + series: attributes.series!, + invoiceNumber: attributes.invoiceNumber!, + invoiceDate: attributes.invoiceDate!, + operationDate: attributes.operationDate!, + + description: attributes.description!, + reference: attributes.reference!, + + customerId: attributes.customerId!, + recipient: recipientResult.data, + + languageCode: attributes.languageCode!, + currencyCode: attributes.currencyCode!, + + subtotalAmount: attributes.subtotalAmount!, + totalDiscountAmount: attributes.totalDiscountAmount!, + taxableAmount: attributes.taxableAmount!, + taxesAmount: attributes.taxesAmount!, + totalAmount: attributes.totalAmount!, + + verifactu, + }); + } + + private _mapAttributesToReadModel(raw: CustomerInvoiceModel, params?: MapperParamsType) { + const { errors } = params as { + errors: ValidationErrorDetail[]; + }; + + const invoiceId = extractOrPushError(UniqueID.create(raw.id), "id", errors); + const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors); + + const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors); + + const isProforma = Boolean(raw.is_proforma); + + const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors); + + const series = extractOrPushError( + maybeFromNullableResult(raw.series, (value) => InvoiceSerie.create(value)), + "serie", + errors + ); + + const invoiceNumber = extractOrPushError( + InvoiceNumber.create(raw.invoice_number), + "invoice_number", + errors + ); + + const invoiceDate = extractOrPushError( + UtcDate.createFromISO(raw.invoice_date), + "invoice_date", + errors + ); + + const operationDate = extractOrPushError( + maybeFromNullableResult(raw.operation_date, (value) => UtcDate.createFromISO(value)), + "operation_date", + errors + ); + + const reference = extractOrPushError( + maybeFromNullableResult(raw.reference, (value) => Result.ok(String(value))), + "description", + errors + ); + + const description = extractOrPushError( + maybeFromNullableResult(raw.description, (value) => Result.ok(String(value))), + "description", + errors + ); + + const languageCode = extractOrPushError( + LanguageCode.create(raw.language_code), + "language_code", + errors + ); + + const currencyCode = extractOrPushError( + CurrencyCode.create(raw.currency_code), + "currency_code", + errors + ); + + const subtotalAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.subtotal_amount_value, + currency_code: currencyCode?.code, + }), + "subtotal_amount_value", + errors + ); + + const totalDiscountAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.total_discount_amount_value, + currency_code: currencyCode?.code, + }), + "total_discount_amount_value", + errors + ); + + const taxableAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.taxable_amount_value, + currency_code: currencyCode?.code, + }), + "taxable_amount_value", + errors + ); + + const taxesAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.taxes_amount_value, + currency_code: currencyCode?.code, + }), + "taxes_amount_value", + errors + ); + + const totalAmount = extractOrPushError( + InvoiceAmount.create({ + value: raw.total_amount_value, + currency_code: currencyCode?.code, + }), + "total_amount_value", + errors + ); + + return { + invoiceId, + companyId, + customerId, + isProforma, + status, + series, + invoiceNumber, + invoiceDate, + operationDate, + reference, + description, + languageCode, + currencyCode, + + subtotalAmount, + totalDiscountAmount, + taxableAmount, + taxesAmount, + totalAmount, + }; + } +} diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/supplier-invoice.domain-mapper.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/mappers/supplier-invoice.domain-mapper.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/models/index.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/models/index.ts new file mode 100644 index 00000000..47d19829 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/models/index.ts @@ -0,0 +1,2 @@ +export * from "./supplier-invoice.model"; +export * from "./supplier-invoice-tax.model"; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/models/supplier-invoice-tax.model.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/models/supplier-invoice-tax.model.ts new file mode 100644 index 00000000..ddf00fce --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/models/supplier-invoice-tax.model.ts @@ -0,0 +1,241 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + type NonAttribute, + type Sequelize, +} from "sequelize"; + +import type { SupplierInvoiceModel } from "./supplier-invoice.model"; + +export type SupplierInvoiceTaxCreationAttributes = InferCreationAttributes< + SupplierInvoiceTaxModel, + { omit: "invoice" } +>; + +export class SupplierInvoiceTaxModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare tax_id: string; + declare invoice_id: string; + + // Taxable amount (base imponible) + declare taxable_amount_value: number; + declare taxable_amount_scale: number; + + declare iva_code: string; + declare iva_percentage_value: number; + declare iva_percentage_scale: number; + declare iva_amount_value: number; + declare iva_amount_scale: number; + + declare rec_code: CreationOptional; + declare rec_percentage_value: CreationOptional; + declare rec_percentage_scale: number; + declare rec_amount_value: number; + declare rec_amount_scale: number; + + declare retention_code: CreationOptional; + declare retention_percentage_value: CreationOptional; + declare retention_percentage_scale: number; + declare retention_amount_value: number; + declare retention_amount_scale: number; + + // Total taxes amount / taxes total + declare taxes_amount_value: number; + declare taxes_amount_scale: number; + + declare invoice: NonAttribute; + + static associate(database: Sequelize) { + const models = database.models; + const requiredModels = ["SupplierInvoiceModel", "SupplierInvoiceTaxModel"]; + + for (const name of requiredModels) { + if (!models[name]) { + throw new Error(`[SupplierInvoiceTaxModel.associate] Missing model: ${name}`); + } + } + + const { SupplierInvoiceModel, SupplierInvoiceTaxModel } = models; + + SupplierInvoiceTaxModel.belongsTo(SupplierInvoiceModel, { + as: "invoice", + foreignKey: "invoice_id", + targetKey: "id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + }); + } + + static hooks(_database: Sequelize) { + // + } +} + +export default (database: Sequelize) => { + SupplierInvoiceTaxModel.init( + { + tax_id: { + type: DataTypes.UUID, + primaryKey: true, + }, + + invoice_id: { + type: DataTypes.UUID, + allowNull: false, + }, + + taxable_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + taxable_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + iva_code: { + type: DataTypes.STRING(40), + allowNull: false, + }, + + iva_percentage_value: { + type: DataTypes.SMALLINT, + allowNull: false, + }, + + iva_percentage_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 2, + }, + + iva_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + iva_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + rec_code: { + type: DataTypes.STRING(40), + allowNull: true, + defaultValue: null, + }, + + rec_percentage_value: { + type: DataTypes.SMALLINT, + allowNull: true, + defaultValue: null, + }, + + rec_percentage_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 2, + }, + + rec_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + rec_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + retention_code: { + type: DataTypes.STRING(40), + allowNull: true, + defaultValue: null, + }, + + retention_percentage_value: { + type: DataTypes.SMALLINT, + allowNull: true, + defaultValue: null, + }, + + retention_percentage_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 2, + }, + + retention_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + retention_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + taxes_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + taxes_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + }, + { + sequelize: database, + modelName: "SupplierInvoiceTaxModel", + tableName: "supplier_invoice_taxes", + + underscored: true, + + indexes: [ + { + name: "idx_supplier_invoice_tax_invoice_id", + fields: ["invoice_id"], + }, + { + name: "uq_supplier_invoice_tax_signature", + fields: [ + "invoice_id", + "iva_code", + "iva_percentage_value", + "iva_percentage_scale", + "rec_code", + "rec_percentage_value", + "rec_percentage_scale", + "retention_code", + "retention_percentage_value", + "retention_percentage_scale", + ], + unique: true, + }, + ], + + whereMergeStrategy: "and", + defaultScope: {}, + scopes: {}, + } + ); + + return SupplierInvoiceTaxModel; +}; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/models/supplier-invoice.model.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/models/supplier-invoice.model.ts new file mode 100644 index 00000000..66b6b487 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/models/supplier-invoice.model.ts @@ -0,0 +1,333 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + type NonAttribute, + type Sequelize, +} from "sequelize"; + +import type { + SupplierInvoiceTaxCreationAttributes, + SupplierInvoiceTaxModel, +} from "./supplier-invoice-tax.model"; + +export type SupplierInvoiceCreationAttributes = InferCreationAttributes< + SupplierInvoiceModel, + { omit: "taxes" } +> & { + taxes?: SupplierInvoiceTaxCreationAttributes[]; +}; + +export class SupplierInvoiceModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare company_id: string; + + declare supplier_id: string; + + declare status: string; + declare source_type: string; + + declare invoice_number: string; + declare invoice_date: string; + declare due_date: CreationOptional; + + declare currency_code: string; + + // Método de pago + declare payment_method_id: CreationOptional; + declare payment_method_description: CreationOptional; + + declare description: CreationOptional; + declare notes: CreationOptional; + declare supplier_invoice_category_id: CreationOptional; + + declare document_id: CreationOptional; + + declare taxable_amount_value: number; + declare taxable_amount_scale: number; + + declare iva_amount_value: number; + declare iva_amount_scale: number; + + declare rec_amount_value: number; + declare rec_amount_scale: number; + + declare retention_amount_value: number; + declare retention_amount_scale: number; + + declare taxes_amount_value: number; + declare taxes_amount_scale: number; + + declare total_amount_value: number; + declare total_amount_scale: number; + + declare version: number; + + declare taxes: NonAttribute; + + static associate(database: Sequelize) { + const models = database.models; + const requiredModels = ["SupplierInvoiceModel", "SupplierInvoiceTaxModel"]; + + for (const name of requiredModels) { + if (!models[name]) { + throw new Error(`[SupplierInvoiceModel.associate] Missing model: ${name}`); + } + } + + const { SupplierInvoiceModel, SupplierInvoiceTaxModel } = models; + + SupplierInvoiceModel.hasMany(SupplierInvoiceTaxModel, { + as: "taxes", + foreignKey: "invoice_id", + sourceKey: "id", + constraints: true, + onDelete: "CASCADE", + onUpdate: "CASCADE", + }); + } + + static hooks(_database: Sequelize) { + // + } +} + +export default (database: Sequelize) => { + SupplierInvoiceModel.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + }, + + company_id: { + type: DataTypes.UUID, + allowNull: false, + }, + + supplier_id: { + type: DataTypes.UUID, + allowNull: false, + }, + + status: { + type: DataTypes.STRING(40), + allowNull: false, + }, + + source_type: { + type: DataTypes.STRING(40), + allowNull: false, + }, + + invoice_number: { + type: DataTypes.STRING(80), + allowNull: false, + }, + + invoice_date: { + type: DataTypes.DATEONLY, + allowNull: false, + }, + + due_date: { + type: DataTypes.DATEONLY, + allowNull: true, + defaultValue: null, + }, + + currency_code: { + type: DataTypes.STRING(3), + allowNull: false, + defaultValue: "EUR", + }, + + payment_method_id: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, + + payment_method_description: { + type: new DataTypes.STRING(), + allowNull: true, + defaultValue: null, + }, + + description: { + type: DataTypes.STRING(500), + allowNull: true, + defaultValue: null, + }, + + notes: { + type: DataTypes.TEXT, + allowNull: true, + defaultValue: null, + }, + + supplier_invoice_category_id: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, + + document_id: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, + + taxable_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + taxable_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + iva_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + iva_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + rec_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + rec_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + retention_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + retention_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + taxes_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + taxes_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + total_amount_value: { + type: DataTypes.BIGINT, + allowNull: false, + defaultValue: 0, + }, + + total_amount_scale: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 4, + }, + + version: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + }, + }, + { + sequelize: database, + modelName: "SupplierInvoiceModel", + tableName: "supplier_invoices", + + underscored: true, + paranoid: true, + timestamps: true, + + createdAt: "created_at", + updatedAt: "updated_at", + deletedAt: "deleted_at", + + indexes: [ + { + name: "uq_supplier_invoice_company_supplier_number", + fields: ["company_id", "supplier_id", "invoice_number"], + unique: true, + }, + { + name: "idx_supplier_invoice_company_date", + fields: ["company_id", "deleted_at", { name: "invoice_date", order: "DESC" }], + }, + { + name: "idx_supplier_invoice_company_status", + fields: ["company_id", "status"], + }, + { + name: "idx_supplier_invoice_company_supplier", + fields: ["company_id", "supplier_id"], + }, + { + name: "idx_supplier_invoice_due_date", + fields: ["due_date"], + }, + { + name: "idx_supplier_invoice_document_id", + fields: ["document_id"], + }, + { + name: "ft_supplier_invoice", + type: "FULLTEXT", + fields: ["invoice_number", "description", "notes"], + }, + ], + + whereMergeStrategy: "and", + defaultScope: {}, + scopes: {}, + + hooks: { + /** + * Incrementa la versión en cada update exitoso. + * + * Nota: + * - Si aplicas OCC en el repositorio con cláusula WHERE version = x, + * este hook sigue siendo útil. + * - Si prefieres controlar la versión solo desde dominio/repositorio, + * elimínalo para evitar dobles incrementos. + */ + beforeUpdate: (instance) => { + const current = instance.get("version") as number; + instance.set("version", current + 1); + }, + }, + } + ); + + return SupplierInvoiceModel; +}; diff --git a/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/repositories/supplier-invoice.sequelize-repository.ts b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/repositories/supplier-invoice.sequelize-repository.ts new file mode 100644 index 00000000..b5115351 --- /dev/null +++ b/modules/supplier-invoices/src/api/infrastucture/persistence/sequelize/repositories/supplier-invoice.sequelize-repository.ts @@ -0,0 +1,372 @@ +import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api"; +import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { type Collection, Result } from "@repo/rdx-utils"; +import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize"; + +import type { + ISupplierInvoiceRepository, + SupplierInvoiceSummary, +} from "../../../../../application"; +import type { SupplierInvoice } from "../../../../../domain"; +import type { + SequelizeSupplierInvoiceDomainMapper, + SequelizeSupplierInvoiceSummaryMapper, +} from "../mappers"; +import { SupplierInvoiceModel, SupplierInvoiceTaxModel } from "../models"; + +export class SupplierInvoiceRepository + extends SequelizeRepository + implements ISupplierInvoiceRepository +{ + constructor( + private readonly domainMapper: SequelizeSupplierInvoiceDomainMapper, + private readonly summaryMapper: SequelizeSupplierInvoiceSummaryMapper, + database: Sequelize + ) { + super({ database }); + } + + /** + * Crea una nueva factura de proveedor junto con su desglose fiscal. + */ + public async create( + invoice: SupplierInvoice, + transaction?: Transaction + ): Promise> { + try { + const dtoResult = this.domainMapper.mapToPersistence(invoice); + + if (dtoResult.isFailure()) { + return Result.fail(dtoResult.error); + } + + const dto = dtoResult.value; + const { id, taxes, ...createPayload } = dto; + + await SupplierInvoiceModel.create( + { + ...createPayload, + id, + }, + { transaction } + ); + + if (Array.isArray(taxes) && taxes.length > 0) { + await SupplierInvoiceTaxModel.bulkCreate(taxes, { transaction }); + } + + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** + * Actualiza la cabecera y reemplaza completamente el desglose fiscal. + * + * Nota: + * - Este enfoque es simple y robusto para MVP. + * - Si más adelante necesitas actualización parcial de taxes, + * se puede optimizar. + */ + public async update( + invoice: SupplierInvoice, + transaction?: Transaction + ): Promise> { + try { + const dtoResult = this.domainMapper.mapToPersistence(invoice); + + if (dtoResult.isFailure()) { + return Result.fail(dtoResult.error); + } + + const dto = dtoResult.value; + const { id, company_id, version, taxes, ...updatePayload } = dto; + + const [updatedRows] = await SupplierInvoiceModel.update( + { + ...updatePayload, + version, + }, + { + where: { + id, + company_id, + }, + transaction, + } + ); + + if (updatedRows === 0) { + return Result.fail(new EntityNotFoundError("SupplierInvoice", "id", id)); + } + + await SupplierInvoiceTaxModel.destroy({ + where: { invoice_id: id }, + transaction, + }); + + if (Array.isArray(taxes) && taxes.length > 0) { + await SupplierInvoiceTaxModel.bulkCreate(taxes, { transaction }); + } + + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** + * Guarda la factura creando o actualizando según exista previamente. + * + * Nota: + * - Mantengo save() como conveniencia. + * - La política sigue siendo explícita: primero existencia, luego create/update. + */ + public async save( + invoice: SupplierInvoice, + transaction?: Transaction + ): Promise> { + const existsResult = await this.existsByIdInCompany(invoice.companyId, invoice.id, transaction); + + if (existsResult.isFailure) { + return Result.fail(existsResult.error); + } + + if (existsResult.data) { + return this.update(invoice, transaction); + } + + return this.create(invoice, transaction); + } + + /** + * Comprueba si existe una factura por id dentro de una empresa. + */ + public async existsByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: Transaction, + options: FindOptions> = {} + ): Promise> { + try { + const count = await SupplierInvoiceModel.count({ + ...options, + where: { + id: id.toString(), + company_id: companyId.toString(), + ...(options.where ?? {}), + }, + transaction, + }); + + return Result.ok(count > 0); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** + * Busca una factura por id dentro de una empresa. + */ + public async findByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: Transaction, + options: FindOptions> = {} + ): Promise> { + try { + const normalizedOrder = Array.isArray(options.order) + ? options.order + : options.order + ? [options.order] + : []; + + const normalizedInclude = Array.isArray(options.include) + ? options.include + : options.include + ? [options.include] + : []; + + const mergedOptions: FindOptions> = { + ...options, + where: { + ...(options.where ?? {}), + id: id.toString(), + company_id: companyId.toString(), + }, + order: [...normalizedOrder], + include: [ + ...normalizedInclude, + { + model: SupplierInvoiceTaxModel, + as: "taxes", + required: false, + }, + ], + transaction, + }; + + const row = await SupplierInvoiceModel.findOne(mergedOptions); + + if (!row) { + return Result.fail(new EntityNotFoundError("SupplierInvoice", "id", id.toString())); + } + + return this.domainMapper.mapToDomain(row); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** + * Busca una factura por proveedor + número dentro de una empresa. + */ + public async findBySupplierAndNumberInCompany( + companyId: UniqueID, + supplierId: UniqueID, + invoiceNumber: string, + transaction?: Transaction, + options: FindOptions> = {} + ): Promise> { + try { + const normalizedInclude = Array.isArray(options.include) + ? options.include + : options.include + ? [options.include] + : []; + + const row = await SupplierInvoiceModel.findOne({ + ...options, + where: { + ...(options.where ?? {}), + company_id: companyId.toString(), + supplier_id: supplierId.toString(), + invoice_number: invoiceNumber, + }, + include: [ + ...normalizedInclude, + { + model: SupplierInvoiceTaxModel, + as: "taxes", + required: false, + }, + ], + transaction, + }); + + if (!row) { + return Result.fail( + new EntityNotFoundError( + "SupplierInvoice", + "invoice_number", + `${supplierId.toString()}:${invoiceNumber}` + ) + ); + } + + return this.domainMapper.mapToDomain(row); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** + * Comprueba si existe una factura por proveedor + número dentro de una empresa. + */ + public async existsBySupplierAndNumberInCompany( + companyId: UniqueID, + supplierId: UniqueID, + invoiceNumber: string, + transaction?: Transaction, + options: FindOptions> = {} + ): Promise> { + try { + const count = await SupplierInvoiceModel.count({ + ...options, + where: { + ...(options.where ?? {}), + company_id: companyId.toString(), + supplier_id: supplierId.toString(), + invoice_number: invoiceNumber, + }, + transaction, + }); + + return Result.ok(count > 0); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + /** + * Busca facturas de proveedor mediante Criteria. + */ + public async findByCriteriaInCompany( + companyId: UniqueID, + criteria: Criteria, + transaction?: Transaction, + options: FindOptions> = {} + ): Promise, Error>> { + try { + const criteriaConverter = new CriteriaToSequelizeConverter(); + + const query = criteriaConverter.convert(criteria, { + searchableFields: ["invoice_number", "description", "internal_notes"], + mappings: {}, + allowedFields: ["invoice_date", "due_date", "id", "created_at"], + enableFullText: true, + database: this.database, + strictMode: true, + }); + + const normalizedOrder = Array.isArray(options.order) + ? options.order + : options.order + ? [options.order] + : []; + + const normalizedInclude = Array.isArray(options.include) + ? options.include + : options.include + ? [options.include] + : []; + + query.where = { + ...query.where, + ...(options.where ?? {}), + company_id: companyId.toString(), + deleted_at: null, + }; + + query.order = [...(query.order as OrderItem[]), ...normalizedOrder]; + + query.include = [ + ...normalizedInclude, + { + model: SupplierInvoiceTaxModel, + as: "taxes", + required: false, + separate: true, + }, + ]; + + const [rows, count] = await Promise.all([ + SupplierInvoiceModel.findAll({ + ...query, + transaction, + }), + SupplierInvoiceModel.count({ + where: query.where, + distinct: true, + transaction, + }), + ]); + + return this.summaryMapper.mapToReadModelCollection(rows, count); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } +} diff --git a/modules/supplier-invoices/tsconfig.json b/modules/supplier-invoices/tsconfig.json new file mode 100644 index 00000000..6f0dd460 --- /dev/null +++ b/modules/supplier-invoices/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@erp/supplier-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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f18c8d6c..be904869 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -691,6 +691,46 @@ importers: specifier: ^5.9.3 version: 5.9.3 + modules/supplier-invoices: + dependencies: + '@erp/auth': + specifier: workspace:* + version: link:../auth + '@erp/core': + specifier: workspace:* + version: link:../core + '@repo/i18next': + specifier: workspace:* + version: link:../../packages/i18n + '@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.15.3)(pg-hstore@2.3.4) + zod: + specifier: ^4.1.11 + version: 4.1.12 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/i18n: dependencies: i18next: