From eeb285f8b29b09f0b219c3d182aeb3bb1009b8cf Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Jul 2026 11:00:37 +0200 Subject: [PATCH] =?UTF-8?q?Configuraci=C3=B3n=20fiscal=20para=20proformas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proforma-create-contract.md | 12 +- .../proforma-series-contract.md | 1 + docs/customer-invoices/proforma-tax-config.md | 81 +++++++ ...lit-proformas-issued-invoices-migration.md | 1 + .../sql/add-proforma-tax-config-columns.sql | 15 ++ .../sql/validate-proforma-tax-config.sql | 28 +++ .../proformas/di/proforma-creator.di.ts | 3 + .../mappers/create-proforma-input.mapper.ts | 76 +++++++ .../mappers/update-proforma-input.mapper.ts | 80 ++++++- .../models/proforma-create-input.model.ts | 14 +- .../models/proforma-update-input.model.ts | 16 +- .../catalog-resolver/proforma-tax-resolver.ts | 16 ++ .../proformas/services/proforma-creator.ts | 211 +++++++++++++++++- .../proformas/services/proforma-updater.ts | 200 ++++++++++++++++- .../full/proforma-full-snapshot-builder.ts | 11 + .../aggregates/proforma.aggregate.ts | 13 +- .../domain/proformas/value-objects/index.ts | 1 + .../value-objects/proforma-tax-config.vo.ts | 64 ++++++ .../models/sequelize-proforma.model.ts | 36 +++ .../proformas/di/proforma-public-services.ts | 3 + .../proformas/di/proformas.di.ts | 3 + .../sequelize-proforma-domain.mapper.ts | 28 +++ .../sequelize-proforma-v2-domain.mapper.ts | 36 +++ .../proformas/create-proforma.request.dto.ts | 3 +- .../update-proforma-by-id.request.dto.ts | 3 +- .../get-proforma-by-id.response.dto.ts | 2 + .../src/common/dto/shared/index.ts | 1 + .../dto/shared/proforma-tax-config.dto.ts | 17 ++ .../adapters/get-proforma-by-id.adapter.ts | 8 + .../shared/entities/proforma.entity.ts | 10 + .../utils/proforma-fiscal-options.utils.ts | 38 ++++ ...roforma-to-proforma-update-form.adapter.ts | 59 ++--- .../build-update-proforma-by-id-params.ts | 23 ++ 33 files changed, 1051 insertions(+), 62 deletions(-) create mode 100644 docs/customer-invoices/proforma-tax-config.md create mode 100644 docs/customer-invoices/sql/add-proforma-tax-config-columns.sql create mode 100644 docs/customer-invoices/sql/validate-proforma-tax-config.sql create mode 100644 modules/customer-invoices/src/api/domain/proformas/value-objects/proforma-tax-config.vo.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/proforma-tax-config.dto.ts diff --git a/docs/customer-invoices/proforma-create-contract.md b/docs/customer-invoices/proforma-create-contract.md index 7331ce0b..2e490c3f 100644 --- a/docs/customer-invoices/proforma-create-contract.md +++ b/docs/customer-invoices/proforma-create-contract.md @@ -11,7 +11,7 @@ Definir la semantica correcta de `create proforma` como creacion de cabecera ini En create: - `taxRegimeCode` representa configuracion fiscal inicial -- `taxDefaults` sigue siendo un concepto de UI o resolucion interna, no un desglose fiscal persistido +- `tax_config` representa la configuracion fiscal persistida de cabecera - `taxes` representa impuestos realmente calculados desde lineas valoradas - `totals` representa importes realmente calculados desde lineas valoradas @@ -43,6 +43,14 @@ Si `items` se omite o se envia como `[]`: payment_method_id?: string | null; payment_term_id?: string | null; tax_regime_code?: string | null; + tax_config?: { + tax_mode?: "single" | "per_line"; + default_iva_code?: string | null; + uses_equivalence_surcharge?: boolean; + default_rec_code?: string | null; + uses_retention?: boolean; + default_retention_code?: string | null; + }; items?: []; } ``` @@ -52,6 +60,7 @@ Notas: - el contrato actual del modulo puede seguir exigiendo otros campos tecnicos ya existentes - `items` es opcional; si existe, puede ir vacio - no se usan defaults ocultos en Zod para disfrazar la intencion del request +- si no se informa `tax_config`, el backend resuelve defaults iniciales y los persiste ## Response esperada al crear sin lineas @@ -83,6 +92,7 @@ Adaptando nombres exactos al contrato publico actual: ## Punto correcto de calculo - `create` crea cabecera minima, numera y resuelve defaults +- `create` persiste tambien el `tax_config` resuelto - `update` permite anadir o editar lineas - al existir lineas valoradas, `update` recalcula `taxes` y `totals` diff --git a/docs/customer-invoices/proforma-series-contract.md b/docs/customer-invoices/proforma-series-contract.md index a28562cc..1dcfc1a9 100644 --- a/docs/customer-invoices/proforma-series-contract.md +++ b/docs/customer-invoices/proforma-series-contract.md @@ -59,6 +59,7 @@ En proformas: - el backend emite `target_invoice_series_code` como nombre preferente - `series` puede mantenerse solo como alias legacy de salida mientras existan consumidores antiguos - `taxes` representa impuestos calculados a partir de lineas valoradas +- `tax_config` representa la configuracion fiscal persistida de cabecera - `totals` representa importes calculados a partir de lineas valoradas - create no debe inventar filas fiscales sobre base `0` diff --git a/docs/customer-invoices/proforma-tax-config.md b/docs/customer-invoices/proforma-tax-config.md new file mode 100644 index 00000000..330c8ead --- /dev/null +++ b/docs/customer-invoices/proforma-tax-config.md @@ -0,0 +1,81 @@ +# Proforma Tax Config + +## Objetivo + +Persistir la configuracion fiscal de cabecera de una proforma sin confundirla con los impuestos realmente calculados desde lineas valoradas. + +## Alcance + +- `tax_regime_code` sigue representando el regimen fiscal general +- `tax_config` representa los controles fiscales de cabecera usados por la UI +- `taxes` sigue representando solo desglose fiscal materializado +- `totals` sigue representando solo importes calculados desde lineas valoradas + +## Campos persistidos en `proformas` + +- `tax_mode` +- `default_iva_code` +- `uses_equivalence_surcharge` +- `default_rec_code` +- `uses_retention` +- `default_retention_code` + +## Contrato publico + +`GET /proformas/:id` debe devolver: + +```ts +{ + tax_regime: { + code: string; + name: string; + description: string; + } | null; + tax_config: { + tax_mode: "single" | "per_line"; + default_iva_code: string | null; + uses_equivalence_surcharge: boolean; + default_rec_code: string | null; + uses_retention: boolean; + default_retention_code: string | null; + }; +} +``` + +`POST /proformas` y `PATCH /proformas/:id` pueden aceptar `tax_config` como bloque independiente de `items`. + +## Regla funcional + +- una proforma puede existir con `items = []` +- en ese caso `taxes = []` +- en ese caso todos los totales monetarios deben quedar a `0` +- la UI debe hidratar los controles de fiscalidad desde `tax_config`, no desde `taxes` + +## Resolucion de defaults en create + +Los defaults del cliente solo se usan al crear la proforma y el resultado se persiste. + +Fallback actual: + +- IVA por defecto: `iva_21` +- recargo de equivalencia: desactivado salvo indicacion explicita +- retencion: desactivada salvo indicacion explicita +- codigo de retencion por defecto si `uses_retention = true`: `retention_15` + +## Regla de update + +`tax_config` se actualiza de forma parcial y se mergea sobre la configuracion persistida actual. + +No debe reconstruirse desde: + +- `taxes` +- el cliente actual +- defaults implícitos de frontend + +## No incluido + +- nueva tabla para configuracion fiscal +- recalculo fiscal fuera del flujo normal de lineas +- cambios de Verifacti +- cobros o vencimientos vivos +- rectificativas diff --git a/docs/customer-invoices/split-proformas-issued-invoices-migration.md b/docs/customer-invoices/split-proformas-issued-invoices-migration.md index 3566860d..3182f902 100644 --- a/docs/customer-invoices/split-proformas-issued-invoices-migration.md +++ b/docs/customer-invoices/split-proformas-issued-invoices-migration.md @@ -151,6 +151,7 @@ Preparar el esquema fisico para separar: - create de proforma se consolida como creacion de cabecera/documento inicial - una proforma sin lineas es valida y debe persistirse con `items = []` - `tax_regime_code` queda como configuracion fiscal inicial de la cabecera +- `tax_config` queda persistido en `proformas` como configuracion fiscal de cabecera - `proforma_taxes` solo representa impuestos calculados, no defaults fiscales ni placeholders sobre base `0` - si una proforma nace sin lineas, la response debe devolver `taxes = []` y totales monetarios a `0` - update sigue siendo el punto donde se anaden lineas y se materializan impuestos/totales reales diff --git a/docs/customer-invoices/sql/add-proforma-tax-config-columns.sql b/docs/customer-invoices/sql/add-proforma-tax-config-columns.sql new file mode 100644 index 00000000..7ac928d8 --- /dev/null +++ b/docs/customer-invoices/sql/add-proforma-tax-config-columns.sql @@ -0,0 +1,15 @@ +ALTER TABLE proformas + ADD COLUMN IF NOT EXISTS tax_mode VARCHAR(20) NOT NULL DEFAULT 'single', + ADD COLUMN IF NOT EXISTS default_iva_code VARCHAR(64) NULL, + ADD COLUMN IF NOT EXISTS uses_equivalence_surcharge TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS default_rec_code VARCHAR(64) NULL, + ADD COLUMN IF NOT EXISTS uses_retention TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS default_retention_code VARCHAR(64) NULL; + +UPDATE proformas +SET + tax_mode = COALESCE(NULLIF(tax_mode, ''), 'single'), + default_iva_code = COALESCE(default_iva_code, 'iva_21'), + uses_equivalence_surcharge = COALESCE(uses_equivalence_surcharge, 0), + uses_retention = COALESCE(uses_retention, 0) +WHERE 1 = 1; diff --git a/docs/customer-invoices/sql/validate-proforma-tax-config.sql b/docs/customer-invoices/sql/validate-proforma-tax-config.sql new file mode 100644 index 00000000..ef492b2c --- /dev/null +++ b/docs/customer-invoices/sql/validate-proforma-tax-config.sql @@ -0,0 +1,28 @@ +SELECT + p.id, + p.proforma_reference, + p.tax_mode, + p.default_iva_code, + p.uses_equivalence_surcharge, + p.default_rec_code, + p.uses_retention, + p.default_retention_code +FROM proformas p +WHERE p.tax_mode NOT IN ('single', 'per_line') + OR (p.uses_equivalence_surcharge = 1 AND p.default_iva_code IS NULL) + OR (p.uses_equivalence_surcharge = 0 AND p.default_rec_code IS NOT NULL) + OR (p.uses_retention = 0 AND p.default_retention_code IS NOT NULL); + +SELECT + p.id, + p.proforma_reference, + COUNT(DISTINCT pi.item_id) AS item_rows, + COUNT(DISTINCT pt.tax_id) AS tax_rows +FROM proformas p +LEFT JOIN proforma_items pi + ON pi.proforma_id = p.id +LEFT JOIN proforma_taxes pt + ON pt.proforma_id = p.id +GROUP BY p.id, p.proforma_reference +HAVING COUNT(DISTINCT pi.item_id) = 0 + AND COUNT(DISTINCT pt.tax_id) > 0; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts index e1553382..e72ae510 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts @@ -1,3 +1,5 @@ +import type { ICustomerPublicServices } from "@erp/customers/api"; + import type { IProformaRepository } from "../repositories"; import type { ProformaPaymentResolver } from "../services"; import { @@ -16,6 +18,7 @@ export const buildProformaCreator = (params: { repository: IProformaRepository; proformaSeriesValidator: ProformaSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; + customerServices: ICustomerPublicServices; }): IProformaCreator => { return new ProformaCreator(params); }; diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts index 8766cfa1..cdadd6f4 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts @@ -20,11 +20,13 @@ import { ItemAmount, ItemDescription, ItemQuantity, + type ProformaTaxMode, type ProformaRecipient, } from "../../../domain"; import type { ProformaCreateInputProps, ProformaItemCreateInputProps, + ProformaTaxConfigInputProps, ProformaItemTaxCodesInput, } from "../models"; @@ -132,6 +134,8 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { errors ); + const taxConfig = this.mapTaxConfig(dto.tax_config, errors); + const items = this.mapItemsProps(dto.items ?? [], { languageCode: languageCode!, currencyCode: currencyCode!, @@ -164,6 +168,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { linkedInvoiceId: Maybe.none(), taxRegimeCode: taxRegimeCode!, + taxConfig, paymentMethodId: paymentMethodId!, globalDiscountPercentage: globalDiscountPercentage!, @@ -284,6 +289,77 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { }; } + private mapTaxConfig( + taxConfigDTO: CreateProformaRequestDTO["tax_config"], + errors: ValidationErrorDetail[] + ): ProformaTaxConfigInputProps | undefined { + if (!taxConfigDTO) { + return undefined; + } + + return { + taxMode: this.mapTaxMode(taxConfigDTO.tax_mode, "tax_config.tax_mode", errors), + defaultIvaCode: this.mapOptionalString( + taxConfigDTO.default_iva_code, + "tax_config.default_iva_code", + errors + ), + usesEquivalenceSurcharge: taxConfigDTO.uses_equivalence_surcharge, + defaultRecCode: this.mapOptionalString( + taxConfigDTO.default_rec_code, + "tax_config.default_rec_code", + errors + ), + usesRetention: taxConfigDTO.uses_retention, + defaultRetentionCode: this.mapOptionalString( + taxConfigDTO.default_retention_code, + "tax_config.default_retention_code", + errors + ), + }; + } + + private mapTaxMode( + taxMode: "single" | "per_line" | undefined, + path: string, + errors: ValidationErrorDetail[] + ): ProformaTaxMode | undefined { + if (taxMode === undefined) { + return undefined; + } + + if (taxMode === "single") { + return "single"; + } + + if (taxMode === "per_line") { + return "perLine"; + } + + errors.push({ + path, + message: "Invalid tax mode", + }); + + return undefined; + } + + private mapOptionalString( + value: string | null | undefined, + path: string, + errors: ValidationErrorDetail[] + ): Maybe | undefined { + if (value === undefined) { + return undefined; + } + + return extractOrPushError( + maybeFromNullableResult(value, (currentValue) => Result.ok(String(currentValue))), + path, + errors + ); + } + private throwIfValidationErrors(errors: ValidationErrorDetail[]): void { if (errors.length > 0) { throw new ValidationErrorCollection("Proforma props mapping failed", errors); diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts index c5253176..b68a21a9 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts @@ -15,11 +15,18 @@ import { import { Maybe, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; import type { UpdateProformaByIdRequestDTO } from "../../../../common/dto"; -import { InvoiceSerie, ItemAmount, ItemDescription, ItemQuantity } from "../../../domain"; +import { + InvoiceSerie, + ItemAmount, + ItemDescription, + ItemQuantity, + type ProformaTaxMode, +} from "../../../domain"; import type { ProformaItemPatchInputProps, ProformaItemTaxCodesInput, ProformaPatchInputProps, + ProformaTaxConfigPatchInputProps, } from "../models"; export interface IUpdateProformaInputMapper { @@ -183,6 +190,10 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { ); }); + if (dto.tax_config !== undefined) { + proformaPatchProps.taxConfig = this.mapTaxConfigPatch(dto.tax_config, errors); + } + if (dto.items !== undefined) { proformaPatchProps.items = this.mapItemsProps(dto.items, { errors }); } @@ -257,6 +268,73 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { return Maybe.some(String(code).trim()); } + private mapTaxConfigPatch( + taxConfigDTO: NonNullable, + errors: ValidationErrorDetail[] + ): ProformaTaxConfigPatchInputProps { + return { + taxMode: this.mapTaxMode(taxConfigDTO.tax_mode, "tax_config.tax_mode", errors), + defaultIvaCode: this.mapOptionalString( + taxConfigDTO.default_iva_code, + "tax_config.default_iva_code", + errors + ), + usesEquivalenceSurcharge: taxConfigDTO.uses_equivalence_surcharge, + defaultRecCode: this.mapOptionalString( + taxConfigDTO.default_rec_code, + "tax_config.default_rec_code", + errors + ), + usesRetention: taxConfigDTO.uses_retention, + defaultRetentionCode: this.mapOptionalString( + taxConfigDTO.default_retention_code, + "tax_config.default_retention_code", + errors + ), + }; + } + + private mapTaxMode( + taxMode: "single" | "per_line" | undefined, + path: string, + errors: ValidationErrorDetail[] + ): ProformaTaxMode | undefined { + if (taxMode === undefined) { + return undefined; + } + + if (taxMode === "single") { + return "single"; + } + + if (taxMode === "per_line") { + return "perLine"; + } + + errors.push({ + path, + message: "Invalid tax mode", + }); + + return undefined; + } + + private mapOptionalString( + value: string | null | undefined, + path: string, + errors: ValidationErrorDetail[] + ): Maybe | undefined { + if (value === undefined) { + return undefined; + } + + return extractOrPushError( + maybeFromNullableResult(value, (currentValue) => Result.ok(String(currentValue))), + path, + errors + ); + } + private throwIfValidationErrors(errors: ValidationErrorDetail[]): void { if (errors.length > 0) { throw new ValidationErrorCollection("Proforma props mapping failed", errors); diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts index bcf4e2ea..3c863870 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts @@ -1,4 +1,4 @@ -import type { IProformaCreateProps, IProformaItemCreateProps } from "../../../domain"; +import type { IProformaCreateProps, IProformaItemCreateProps, ProformaTaxMode } from "../../../domain"; import type { Maybe } from "@repo/rdx-utils"; import type { InvoiceSerie } from "../../../domain"; @@ -14,12 +14,22 @@ import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input. * - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio. */ +export interface ProformaTaxConfigInputProps { + taxMode?: ProformaTaxMode; + defaultIvaCode?: Maybe; + usesEquivalenceSurcharge?: boolean; + defaultRecCode?: Maybe; + usesRetention?: boolean; + defaultRetentionCode?: Maybe; +} + export type ProformaCreateInputProps = Omit< IProformaCreateProps, - "items" | "proformaReference" | "proformaNumber" | "documentSeriesId" | "series" + "items" | "proformaReference" | "proformaNumber" | "documentSeriesId" | "series" | "taxConfig" > & { proformaSeriesCode: Maybe; targetInvoiceSeriesCode: Maybe; + taxConfig?: ProformaTaxConfigInputProps; items: ProformaItemCreateInputProps[]; }; diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts index 46b1acd3..5ebf66f1 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-update-input.model.ts @@ -1,4 +1,8 @@ -import type { ProformaItemPatchProps, ProformaPatchProps } from "../../../domain"; +import type { + ProformaItemPatchProps, + ProformaPatchProps, + ProformaTaxMode, +} from "../../../domain"; import type { Maybe } from "@repo/rdx-utils"; import type { InvoiceSerie } from "../../../domain"; @@ -14,8 +18,18 @@ import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input. * - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio. */ +export interface ProformaTaxConfigPatchInputProps { + taxMode?: ProformaTaxMode; + defaultIvaCode?: Maybe; + usesEquivalenceSurcharge?: boolean; + defaultRecCode?: Maybe; + usesRetention?: boolean; + defaultRetentionCode?: Maybe; +} + export type ProformaPatchInputProps = Omit & { targetInvoiceSeriesCode?: Maybe; + taxConfig?: ProformaTaxConfigPatchInputProps; items?: ProformaItemPatchInputProps[]; }; diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts index 2a10b122..13272dff 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts @@ -22,6 +22,12 @@ export interface EnsureProformaItemTaxesParams { taxCodes: ProformaItemTaxCodesInput; } +export interface FindActiveProformaTaxDefinitionByCodeParams { + companyId: UniqueID; + atDate: UtcDate; + code: string; +} + /** * Resuelve datos fiscales externos al agregado de proforma usando servicios públicos de `catalogs`. * @@ -115,6 +121,16 @@ export class ProformaTaxResolver { }); } + public async findActiveTaxDefinitionByCode( + params: FindActiveProformaTaxDefinitionByCodeParams + ): Promise, Error>> { + return this.deps.taxDefinitionFinder.findActiveByCode({ + companyId: params.companyId, + code: params.code, + atDate: params.atDate, + }); + } + private collectCodes(taxCodes: ProformaItemTaxCodesInput): Collection { const codes = [taxCodes.ivaCode, taxCodes.recCode, taxCodes.retentionCode] .filter((code) => code.isSome()) diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts index 74215d5e..e2ce203a 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts @@ -1,3 +1,4 @@ +import type { ICustomerPublicServices } from "@erp/customers/api"; import type { UniqueID } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; @@ -6,8 +7,10 @@ import { type IProformaItemCreateProps, InvoiceNumber, Proforma, + ProformaTaxConfig, + type ProformaTaxMode, } from "../../../domain"; -import type { ProformaCreateInputProps } from "../models"; +import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models"; import type { IProformaRepository } from "../repositories"; import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver"; @@ -36,13 +39,14 @@ export class ProformaCreator implements IProformaCreator { paymentResolver: ProformaPaymentResolver; proformaSeriesValidator: ProformaSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; + customerServices: ICustomerPublicServices; } ) {} async create(params: IProformaCreatorParams): Promise> { const { companyId, id, props, transaction } = params; - const resolvedProps = await this.resolveCreateProps(props); + const resolvedProps = await this.resolveCreateProps(props, transaction); if (resolvedProps.isFailure) { return Result.fail(resolvedProps.error); @@ -132,14 +136,30 @@ export class ProformaCreator implements IProformaCreator { } private async resolveCreateProps( - props: ProformaCreateInputProps + props: ProformaCreateInputProps, + transaction?: unknown ): Promise< Result, Error> > { - // TODO: Esto hay que cambiarlo en el futuro para buscar valores por defecto, desde la empresa o desde el cliente + const customerResult = await this.deps.customerServices.getCustomerById(props.customerId, { + companyId: props.companyId, + transaction, + }); + + if (customerResult.isFailure) { + return Result.fail(customerResult.error); + } + + const customer = customerResult.data; + const _newProps = { ...props, - taxRegimeCode: props.taxRegimeCode.isNone() ? Maybe.some("01") : props.taxRegimeCode, + taxRegimeCode: + props.taxRegimeCode.isNone() + ? customer.taxRegimeCode.isSome() + ? customer.taxRegimeCode + : Maybe.some("01") + : props.taxRegimeCode, }; // Tax Regime => comprobar que existe @@ -162,6 +182,17 @@ export class ProformaCreator implements IProformaCreator { return Result.fail(paymentMethodResult.error); } + const taxConfigResult = await this.resolveTaxConfig({ + companyId: _newProps.companyId, + atDate: _newProps.proformaDate, + customer, + override: props.taxConfig, + }); + + if (taxConfigResult.isFailure) { + return Result.fail(taxConfigResult.error); + } + // En create una proforma puede nacer sin líneas: en ese caso solo persistimos // cabecera + defaults iniciales. Los impuestos y totales materializados salen // de `items`, así que con `items = []` no se generan filas fiscales. @@ -186,7 +217,177 @@ export class ProformaCreator implements IProformaCreator { return Result.ok({ ..._newProps, + taxConfig: taxConfigResult.data, items: resolvedItems, }); } + + private async resolveTaxConfig(params: { + companyId: UniqueID; + atDate: IProformaCreateProps["proformaDate"]; + customer: Awaited> extends Result< + infer T, + Error + > + ? T + : never; + override?: ProformaTaxConfigInputProps; + }): Promise> { + const taxMode = params.override?.taxMode ?? "single"; + const defaultIvaCode = + params.override?.defaultIvaCode ?? + Maybe.some(this.resolveDefaultIvaCodeFromCustomer(params.customer) ?? "iva_21"); + + const usesEquivalenceSurcharge = + params.override?.usesEquivalenceSurcharge ?? params.customer.usesEquivalenceSurcharge; + const usesRetention = params.override?.usesRetention ?? params.customer.usesRetention; + + const defaultRecCode = await this.resolveDefaultRecCode({ + companyId: params.companyId, + atDate: params.atDate, + defaultIvaCode, + usesEquivalenceSurcharge, + requestedCode: params.override?.defaultRecCode, + }); + + if (defaultRecCode.isFailure) { + return Result.fail(defaultRecCode.error); + } + + const defaultRetentionCode = await this.resolveRetentionCode({ + companyId: params.companyId, + atDate: params.atDate, + usesRetention, + requestedCode: params.override?.defaultRetentionCode, + }); + + if (defaultRetentionCode.isFailure) { + return Result.fail(defaultRetentionCode.error); + } + + const createdTaxConfig = ProformaTaxConfig.create({ + taxMode, + defaultIvaCode, + usesEquivalenceSurcharge, + defaultRecCode: defaultRecCode.data, + usesRetention, + defaultRetentionCode: defaultRetentionCode.data, + }); + + if (createdTaxConfig.isFailure) { + return Result.fail(createdTaxConfig.error); + } + + return Result.ok(createdTaxConfig.data); + } + + private resolveDefaultIvaCodeFromCustomer( + _customer: Awaited> extends Result< + infer T, + Error + > + ? T + : never + ): string | null { + return null; + } + + private async resolveDefaultRecCode(params: { + companyId: UniqueID; + atDate: IProformaCreateProps["proformaDate"]; + defaultIvaCode: Maybe; + usesEquivalenceSurcharge: boolean; + requestedCode?: Maybe; + }): Promise, Error>> { + if (!params.usesEquivalenceSurcharge) { + return Result.ok(Maybe.none()); + } + + const ivaCode = this.maybeValue(params.defaultIvaCode); + + if (!ivaCode) { + return Result.ok(Maybe.none()); + } + + const ivaDefinition = await this.deps.taxResolver.findActiveTaxDefinitionByCode({ + companyId: params.companyId, + atDate: params.atDate, + code: ivaCode, + }); + + if (ivaDefinition.isFailure) { + return Result.fail(ivaDefinition.error); + } + + if (ivaDefinition.data.isNone()) { + return Result.fail(new Error(`Tax definition not found: ${ivaCode}`)); + } + + const allowedSurchargeCodes = ivaDefinition.data.unwrap().allowedSurchargeCodes.getAll(); + const requestedCode = this.maybeValue(params.requestedCode) ?? this.defaultRecForIva(ivaCode); + + if (requestedCode && allowedSurchargeCodes.includes(requestedCode)) { + return Result.ok(Maybe.some(requestedCode)); + } + + const fallbackCode = allowedSurchargeCodes[0] ?? null; + + return Result.ok(fallbackCode ? Maybe.some(fallbackCode) : Maybe.none()); + } + + private async resolveRetentionCode(params: { + companyId: UniqueID; + atDate: IProformaCreateProps["proformaDate"]; + usesRetention: boolean; + requestedCode?: Maybe; + }): Promise, Error>> { + if (!params.usesRetention) { + return Result.ok(Maybe.none()); + } + + const retentionCode = this.maybeValue(params.requestedCode) ?? "retention_15"; + + const definition = await this.deps.taxResolver.findActiveTaxDefinitionByCode({ + companyId: params.companyId, + atDate: params.atDate, + code: retentionCode, + }); + + if (definition.isFailure) { + return Result.fail(definition.error); + } + + if (definition.data.isNone()) { + return Result.fail(new Error(`Tax definition not found: ${retentionCode}`)); + } + + return Result.ok(Maybe.some(retentionCode)); + } + + private defaultRecForIva(ivaCode: string): string | null { + const defaults: Record = { + iva_21: "rec_5_2", + iva_10: "rec_1_4", + iva_4: "rec_0_5", + iva_0: "rec_0", + }; + + return defaults[ivaCode] ?? null; + } + + private maybeValue(value: Maybe | string | null | undefined): string | null { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "object" && "isSome" in value && typeof value.isSome === "function") { + return value.isSome() ? value.unwrap() : null; + } + + return null; + } } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts index 221e0913..9f44d699 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts @@ -1,7 +1,12 @@ import { DomainValidationError, type UniqueID, type UtcDate } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; +import { Maybe, Result } from "@repo/rdx-utils"; -import type { Proforma, ProformaItemPatchProps, ProformaPatchProps } from "../../../domain"; +import { + ProformaTaxConfig, + type Proforma, + type ProformaItemPatchProps, + type ProformaPatchProps, +} from "../../../domain"; import type { ProformaPatchInputProps } from "../models"; import type { IProformaRepository } from "../repositories"; @@ -72,6 +77,7 @@ export class ProformaUpdater implements IProformaUpdater { const resolvedPatch = await this.resolvePatchProps({ companyId, currentInvoiceDate: proforma.invoiceDate, + currentTaxConfig: proforma.taxConfig, patch: patchProps, transaction, }); @@ -107,10 +113,11 @@ export class ProformaUpdater implements IProformaUpdater { private async resolvePatchProps(params: { companyId: UniqueID; currentInvoiceDate: UtcDate; + currentTaxConfig: Proforma["taxConfig"]; patch: ProformaPatchInputProps; transaction: unknown; }): Promise> { - const { patch, companyId, currentInvoiceDate, transaction } = params; + const { patch, companyId, currentInvoiceDate, transaction, currentTaxConfig } = params; if (patch.taxRegimeCode !== undefined) { const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({ @@ -134,6 +141,17 @@ export class ProformaUpdater implements IProformaUpdater { } } + const taxConfigResult = await this.resolveTaxConfig({ + companyId, + currentInvoiceDate, + currentTaxConfig, + patchTaxConfig: patch.taxConfig, + }); + + if (taxConfigResult.isFailure) { + return Result.fail(taxConfigResult.error); + } + if (patch.targetInvoiceSeriesCode !== undefined) { const targetInvoiceSeriesValidationResult = await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({ @@ -148,10 +166,16 @@ export class ProformaUpdater implements IProformaUpdater { } if (patch.items === undefined) { - return Result.ok({ + const resolvedPatch: ProformaPatchProps = { ...patch, series: patch.targetInvoiceSeriesCode, - } as ProformaPatchProps); + }; + + if (taxConfigResult.data !== undefined) { + resolvedPatch.taxConfig = taxConfigResult.data; + } + + return Result.ok(resolvedPatch); } const effectiveInvoiceDate = patch.proformaDate ?? currentInvoiceDate; @@ -174,11 +198,175 @@ export class ProformaUpdater implements IProformaUpdater { }); } - return Result.ok({ + const resolvedPatch: ProformaPatchProps = { ...patch, series: patch.targetInvoiceSeriesCode, items: resolvedItems, + }; + + if (taxConfigResult.data !== undefined) { + resolvedPatch.taxConfig = taxConfigResult.data; + } + + return Result.ok(resolvedPatch); + } + + private async resolveTaxConfig(params: { + companyId: UniqueID; + currentInvoiceDate: UtcDate; + currentTaxConfig: Proforma["taxConfig"]; + patchTaxConfig: ProformaPatchInputProps["taxConfig"]; + }): Promise> { + if (params.patchTaxConfig === undefined) { + return Result.ok(undefined); + } + + const currentTaxConfig = params.currentTaxConfig; + const patchTaxConfig = params.patchTaxConfig; + + const taxMode = patchTaxConfig.taxMode ?? currentTaxConfig.taxMode; + const defaultIvaCode = patchTaxConfig.defaultIvaCode ?? currentTaxConfig.defaultIvaCode; + const usesEquivalenceSurcharge = + patchTaxConfig.usesEquivalenceSurcharge ?? currentTaxConfig.usesEquivalenceSurcharge; + const usesRetention = patchTaxConfig.usesRetention ?? currentTaxConfig.usesRetention; + + const defaultRecCode = await this.resolveDefaultRecCode({ + companyId: params.companyId, + atDate: params.currentInvoiceDate, + defaultIvaCode, + usesEquivalenceSurcharge, + requestedCode: patchTaxConfig.defaultRecCode ?? currentTaxConfig.defaultRecCode, }); + + if (defaultRecCode.isFailure) { + return Result.fail(defaultRecCode.error); + } + + const defaultRetentionCode = await this.resolveRetentionCode({ + companyId: params.companyId, + atDate: params.currentInvoiceDate, + usesRetention, + requestedCode: + patchTaxConfig.defaultRetentionCode ?? currentTaxConfig.defaultRetentionCode, + }); + + if (defaultRetentionCode.isFailure) { + return Result.fail(defaultRetentionCode.error); + } + + const taxConfigResult = ProformaTaxConfig.create({ + taxMode, + defaultIvaCode, + usesEquivalenceSurcharge, + defaultRecCode: defaultRecCode.data, + usesRetention, + defaultRetentionCode: defaultRetentionCode.data, + }); + + if (taxConfigResult.isFailure) { + return Result.fail(taxConfigResult.error); + } + + return Result.ok(taxConfigResult.data); + } + + private async resolveDefaultRecCode(params: { + companyId: UniqueID; + atDate: UtcDate; + defaultIvaCode: Maybe; + usesEquivalenceSurcharge: boolean; + requestedCode?: Maybe; + }): Promise, Error>> { + if (!params.usesEquivalenceSurcharge) { + return Result.ok(Maybe.none()); + } + + const ivaCode = this.maybeValue(params.defaultIvaCode); + + if (!ivaCode) { + return Result.ok(Maybe.none()); + } + + const ivaDefinition = await this.deps.taxResolver.findActiveTaxDefinitionByCode({ + companyId: params.companyId, + atDate: params.atDate, + code: ivaCode, + }); + + if (ivaDefinition.isFailure) { + return Result.fail(ivaDefinition.error); + } + + if (ivaDefinition.data.isNone()) { + return Result.fail(new Error(`Tax definition not found: ${ivaCode}`)); + } + + const allowedSurchargeCodes = ivaDefinition.data.unwrap().allowedSurchargeCodes.getAll(); + const requestedCode = this.maybeValue(params.requestedCode) ?? this.defaultRecForIva(ivaCode); + + if (requestedCode && allowedSurchargeCodes.includes(requestedCode)) { + return Result.ok(Maybe.some(requestedCode)); + } + + const fallbackCode = allowedSurchargeCodes[0] ?? null; + + return Result.ok(fallbackCode ? Maybe.some(fallbackCode) : Maybe.none()); + } + + private async resolveRetentionCode(params: { + companyId: UniqueID; + atDate: UtcDate; + usesRetention: boolean; + requestedCode?: Maybe; + }): Promise, Error>> { + if (!params.usesRetention) { + return Result.ok(Maybe.none()); + } + + const retentionCode = this.maybeValue(params.requestedCode) ?? "retention_15"; + + const definition = await this.deps.taxResolver.findActiveTaxDefinitionByCode({ + companyId: params.companyId, + atDate: params.atDate, + code: retentionCode, + }); + + if (definition.isFailure) { + return Result.fail(definition.error); + } + + if (definition.data.isNone()) { + return Result.fail(new Error(`Tax definition not found: ${retentionCode}`)); + } + + return Result.ok(Maybe.some(retentionCode)); + } + + private defaultRecForIva(ivaCode: string): string | null { + const defaults: Record = { + iva_21: "rec_5_2", + iva_10: "rec_1_4", + iva_4: "rec_0_5", + iva_0: "rec_0", + }; + + return defaults[ivaCode] ?? null; + } + + private maybeValue(value: Maybe | string | null | undefined): string | null { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "object" && "isSome" in value && typeof value.isSome === "function") { + return value.isSome() ? value.unwrap() : null; + } + + return null; } private isApprovedPatchAllowed(patch: ProformaPatchInputProps): boolean { diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts index fb577d1b..c5557db2 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts @@ -69,6 +69,17 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder tax_regime: maybeToNullable(taxRegime, (taxRegime) => taxRegime), payment_term: null, + tax_config: { + tax_mode: proforma.taxConfig.taxMode === "perLine" ? "per_line" : "single", + default_iva_code: maybeToNullable(proforma.taxConfig.defaultIvaCode, (value) => value), + uses_equivalence_surcharge: proforma.taxConfig.usesEquivalenceSurcharge, + default_rec_code: maybeToNullable(proforma.taxConfig.defaultRecCode, (value) => value), + uses_retention: proforma.taxConfig.usesRetention, + default_retention_code: maybeToNullable( + proforma.taxConfig.defaultRetentionCode, + (value) => value + ), + }, subtotal_amount: allTotals.subtotalAmount.toObjectString(), items_discount_amount: allTotals.itemsDiscountAmount.toObjectString(), diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index f5523e89..9aaedaea 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -29,7 +29,7 @@ import { import { InvalidProformaTransitionError, ProformaItemMismatch } from "../errors"; import type { IProformaTaxTotals, ProformaCalculationContext } from "../services"; import { canManuallyTransitionProformaStatus } from "../services"; -import { ProformaItemTaxes, type ProformaRecipient } from "../value-objects"; +import { ProformaItemTaxes, ProformaTaxConfig, type ProformaRecipient } from "../value-objects"; export interface IProformaCreateProps { companyId: UniqueID; @@ -57,6 +57,7 @@ export interface IProformaCreateProps { paymentMethodId: Maybe; taxRegimeCode: Maybe; + taxConfig: ProformaTaxConfig; items: IProformaItemCreateProps[]; globalDiscountPercentage: DiscountPercentage; @@ -107,6 +108,7 @@ export interface IProforma { paymentMethodId: Maybe; taxRegimeCode: Maybe; + taxConfig: ProformaTaxConfig; linkedInvoiceId: Maybe; @@ -169,10 +171,13 @@ export class Proforma extends AggregateRoot implements IP // Mutabilidad public update(patchProps: ProformaPatchProps): Result { const { items, ...otherProps } = patchProps; + const definedProps = Object.fromEntries( + Object.entries(otherProps).filter(([, value]) => value !== undefined) + ) as Partial; const candidateProps: ProformaInternalProps = { ...this.props, - ...otherProps, + ...definedProps, }; // Validacciones @@ -281,6 +286,10 @@ export class Proforma extends AggregateRoot implements IP return this.props.taxRegimeCode; } + public get taxConfig(): ProformaTaxConfig { + return this.props.taxConfig; + } + public get linkedInvoiceId(): Maybe { return this.props.linkedInvoiceId; } diff --git a/modules/customer-invoices/src/api/domain/proformas/value-objects/index.ts b/modules/customer-invoices/src/api/domain/proformas/value-objects/index.ts index 3dabc5d4..1905ca2d 100644 --- a/modules/customer-invoices/src/api/domain/proformas/value-objects/index.ts +++ b/modules/customer-invoices/src/api/domain/proformas/value-objects/index.ts @@ -1,2 +1,3 @@ export * from "./proforma-item-taxes.vo"; export * from "./proforma-recipient.vo"; +export * from "./proforma-tax-config.vo"; diff --git a/modules/customer-invoices/src/api/domain/proformas/value-objects/proforma-tax-config.vo.ts b/modules/customer-invoices/src/api/domain/proformas/value-objects/proforma-tax-config.vo.ts new file mode 100644 index 00000000..45d2871d --- /dev/null +++ b/modules/customer-invoices/src/api/domain/proformas/value-objects/proforma-tax-config.vo.ts @@ -0,0 +1,64 @@ +import { DomainValidationError } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +export type ProformaTaxMode = "single" | "perLine"; + +export interface ProformaTaxConfigProps { + taxMode: ProformaTaxMode; + defaultIvaCode: Maybe; + usesEquivalenceSurcharge: boolean; + defaultRecCode: Maybe; + usesRetention: boolean; + defaultRetentionCode: Maybe; +} + +export class ProformaTaxConfig { + private constructor(private readonly props: ProformaTaxConfigProps) {} + + public static create(props: ProformaTaxConfigProps): Result { + if (props.taxMode !== "single" && props.taxMode !== "perLine") { + return Result.fail( + new DomainValidationError("INVALID_TAX_MODE", "taxConfig.taxMode", "Invalid tax mode") + ); + } + + return Result.ok( + new ProformaTaxConfig({ + taxMode: props.taxMode, + defaultIvaCode: props.defaultIvaCode, + usesEquivalenceSurcharge: props.usesEquivalenceSurcharge, + defaultRecCode: props.usesEquivalenceSurcharge ? props.defaultRecCode : Maybe.none(), + usesRetention: props.usesRetention, + defaultRetentionCode: props.usesRetention ? props.defaultRetentionCode : Maybe.none(), + }) + ); + } + + public get taxMode(): ProformaTaxMode { + return this.props.taxMode; + } + + public get defaultIvaCode(): Maybe { + return this.props.defaultIvaCode; + } + + public get usesEquivalenceSurcharge(): boolean { + return this.props.usesEquivalenceSurcharge; + } + + public get defaultRecCode(): Maybe { + return this.props.defaultRecCode; + } + + public get usesRetention(): boolean { + return this.props.usesRetention; + } + + public get defaultRetentionCode(): Maybe { + return this.props.defaultRetentionCode; + } + + public getProps(): ProformaTaxConfigProps { + return this.props; + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts index bc6ff9b6..66c53ede 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts @@ -53,6 +53,12 @@ export class ProformaModel extends Model< declare payment_term_id: CreationOptional; declare tax_regime_code: CreationOptional; + declare tax_mode: CreationOptional; + declare default_iva_code: CreationOptional; + declare uses_equivalence_surcharge: CreationOptional; + declare default_rec_code: CreationOptional; + declare uses_retention: CreationOptional; + declare default_retention_code: CreationOptional; declare subtotal_amount_value: number; declare subtotal_amount_scale: number; @@ -249,6 +255,36 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + tax_mode: { + type: DataTypes.STRING(16), + allowNull: false, + defaultValue: "single", + }, + default_iva_code: { + type: DataTypes.STRING(32), + allowNull: true, + defaultValue: null, + }, + uses_equivalence_surcharge: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + default_rec_code: { + type: DataTypes.STRING(32), + allowNull: true, + defaultValue: null, + }, + uses_retention: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + default_retention_code: { + type: DataTypes.STRING(32), + allowNull: true, + defaultValue: null, + }, subtotal_amount_value: { type: new DataTypes.BIGINT(), allowNull: false, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts index cb3eabe9..33e108eb 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts @@ -1,4 +1,5 @@ import type { SetupParams } from "@erp/core/api"; +import type { ICustomerPublicServices } from "@erp/customers/api"; import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; @@ -51,6 +52,7 @@ export function buildProformaPublicServices( const { database } = params; const catalogs = resolveProformaCatalogsDeps(params); + const customerServices = params.getService("customers:general"); const documentSeriesServices = params.getService("document-series:general"); @@ -92,6 +94,7 @@ export function buildProformaPublicServices( paymentResolver: catalogResolvers.paymentResolver, proformaSeriesValidator, targetInvoiceSeriesValidator, + customerServices, }); return { diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index 9fb10f02..eb7fd1ec 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -1,4 +1,5 @@ import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; +import type { ICustomerPublicServices } from "@erp/customers/api"; import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; import type { ICompanyPublicServices } from "../../../../../../companies/src/api"; @@ -65,6 +66,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter const catalogs = resolveProformaCatalogsDeps(params); const companiesServices = params.getService("companies:general"); + const customerServices = params.getService("customers:general"); const documentSeriesServices = params.getService("document-series:general"); @@ -109,6 +111,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter repository, proformaSeriesValidator, targetInvoiceSeriesValidator, + customerServices, }); const updater = buildProformaUpdater({ diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts index 0c3ff51b..7c9b8767 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts @@ -18,6 +18,7 @@ import { InvoiceSerie, InvoiceStatus, Proforma, + ProformaTaxConfig, type ProformaInternalProps, ProformaItems, } from "../../../../../../domain"; @@ -177,6 +178,25 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< errors ); + const taxConfig = extractOrPushError( + ProformaTaxConfig.create({ + taxMode: raw.tax_mode === "per_line" ? "perLine" : "single", + defaultIvaCode: maybeFromNullableResult(raw.default_iva_code, (value) => + Result.ok(String(value)) + ), + usesEquivalenceSurcharge: Boolean(raw.uses_equivalence_surcharge), + defaultRecCode: maybeFromNullableResult(raw.default_rec_code, (value) => + Result.ok(String(value)) + ), + usesRetention: Boolean(raw.uses_retention), + defaultRetentionCode: maybeFromNullableResult(raw.default_retention_code, (value) => + Result.ok(String(value)) + ), + }), + "tax_config", + errors + ); + return { invoiceId, companyId, @@ -193,6 +213,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< currencyCode, paymentMethodId, taxRegimeCode, + taxConfig, globalDiscountPercentage, linkedInvoiceId, @@ -265,6 +286,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< paymentMethodId: attributes.paymentMethodId!, taxRegimeCode: attributes.taxRegimeCode!, + taxConfig: attributes.taxConfig!, linkedInvoiceId: attributes.linkedInvoiceId!, // El id de la factura emitida (linked_invoice) se asigna al hacer issue() desde la proforma, no viene en el modelo de persistencia. }; @@ -357,6 +379,12 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value), tax_regime_description: null, + tax_mode: source.taxConfig.taxMode === "perLine" ? "per_line" : "single", + default_iva_code: maybeToNullable(source.taxConfig.defaultIvaCode, (value) => value), + uses_equivalence_surcharge: source.taxConfig.usesEquivalenceSurcharge, + default_rec_code: maybeToNullable(source.taxConfig.defaultRecCode, (value) => value), + uses_retention: source.taxConfig.usesRetention, + default_retention_code: maybeToNullable(source.taxConfig.defaultRetentionCode, (value) => value), subtotal_amount_value: allAmounts.subtotalAmount.value, subtotal_amount_scale: allAmounts.subtotalAmount.scale, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts index 9758a32e..3ad846cd 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts @@ -18,6 +18,7 @@ import { InvoiceSerie, InvoiceStatus, Proforma, + ProformaTaxConfig, type ProformaInternalProps, ProformaItems, } from "../../../../../../domain"; @@ -117,6 +118,30 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< "tax_regime_code", errors ); + const taxConfig = extractOrPushError( + ProformaTaxConfig.create({ + taxMode: raw.tax_mode === "per_line" ? "perLine" : "single", + defaultIvaCode: extractOrPushError( + maybeFromNullableResult(raw.default_iva_code, (value) => Result.ok(String(value))), + "default_iva_code", + errors + )!, + usesEquivalenceSurcharge: Boolean(raw.uses_equivalence_surcharge), + defaultRecCode: extractOrPushError( + maybeFromNullableResult(raw.default_rec_code, (value) => Result.ok(String(value))), + "default_rec_code", + errors + )!, + usesRetention: Boolean(raw.uses_retention), + defaultRetentionCode: extractOrPushError( + maybeFromNullableResult(raw.default_retention_code, (value) => Result.ok(String(value))), + "default_retention_code", + errors + )!, + }), + "tax_config", + errors + ); const globalDiscountPercentage = extractOrPushError( DiscountPercentage.create({ value: Number(raw.global_discount_percentage_value ?? 0), @@ -148,6 +173,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< currencyCode, paymentMethodId, taxRegimeCode, + taxConfig, globalDiscountPercentage, linkedInvoiceId, }; @@ -201,6 +227,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< globalDiscountPercentage: attributes.globalDiscountPercentage!, paymentMethodId: attributes.paymentMethodId!, taxRegimeCode: attributes.taxRegimeCode!, + taxConfig: attributes.taxConfig!, linkedInvoiceId: attributes.linkedInvoiceId!, }; @@ -264,6 +291,15 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()), payment_term_id: null, tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value), + tax_mode: source.taxConfig.taxMode === "perLine" ? "per_line" : "single", + default_iva_code: maybeToNullable(source.taxConfig.defaultIvaCode, (value) => value), + uses_equivalence_surcharge: source.taxConfig.usesEquivalenceSurcharge, + default_rec_code: maybeToNullable(source.taxConfig.defaultRecCode, (value) => value), + uses_retention: source.taxConfig.usesRetention, + default_retention_code: maybeToNullable( + source.taxConfig.defaultRetentionCode, + (value) => value + ), subtotal_amount_value: allAmounts.subtotalAmount.value, subtotal_amount_scale: allAmounts.subtotalAmount.scale, diff --git a/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts index 10b442c9..e9b318a2 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/create-proforma.request.dto.ts @@ -7,7 +7,7 @@ import { } from "@erp/core"; import { z } from "zod/v4"; -import { ItemPositionSchema, TaxCombinationCodeSchema } from "../../shared"; +import { ItemPositionSchema, ProformaTaxConfigPatchSchema, TaxCombinationCodeSchema } from "../../shared"; export const CreateProformaItemRequestSchema = z.object({ position: ItemPositionSchema, @@ -49,6 +49,7 @@ export const CreateProformaRequestSchema = z.object({ payment_method_id: z.uuid().nullable().optional(), payment_term_id: z.uuid().nullable().optional(), tax_regime_code: z.string().nullable().optional(), + tax_config: ProformaTaxConfigPatchSchema.optional(), items: z.array(CreateProformaItemRequestSchema).optional(), }); diff --git a/modules/customer-invoices/src/common/dto/request/proformas/update-proforma-by-id.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/update-proforma-by-id.request.dto.ts index 36303249..4a1560ed 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/update-proforma-by-id.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/update-proforma-by-id.request.dto.ts @@ -8,7 +8,7 @@ import { } from "@erp/core"; import { z } from "zod/v4"; -import { ItemPositionSchema } from "../../shared"; +import { ItemPositionSchema, ProformaTaxConfigPatchSchema } from "../../shared"; export const UpdateProformaItemRequestSchema = z.object({ position: ItemPositionSchema, @@ -54,6 +54,7 @@ export const UpdateProformaByIdRequestSchema = z.object({ payment_method_id: z.uuid().nullable().optional(), payment_term_id: z.uuid().nullable().optional(), tax_regime_code: z.string().nullable().optional(), + tax_config: ProformaTaxConfigPatchSchema.optional(), // retención como código??? retencion_15 diff --git a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts index d7d59850..49265b2b 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts @@ -11,6 +11,7 @@ import { z } from "zod/v4"; import { PaymentMethodRefSchema, PaymentTermRefSchema, + ProformaTaxConfigSchema, TaxRegimeRefSchema, TaxesBreakdownSchema, } from "../../shared"; @@ -50,6 +51,7 @@ export const GetProformaByIdResponseSchema = z.object({ payment_term: PaymentTermRefSchema.nullable(), tax_regime: TaxRegimeRefSchema.nullable(), + tax_config: ProformaTaxConfigSchema, subtotal_amount: MoneySchema, items_discount_amount: MoneySchema, diff --git a/modules/customer-invoices/src/common/dto/shared/index.ts b/modules/customer-invoices/src/common/dto/shared/index.ts index 33adbd78..453e7cfa 100644 --- a/modules/customer-invoices/src/common/dto/shared/index.ts +++ b/modules/customer-invoices/src/common/dto/shared/index.ts @@ -3,6 +3,7 @@ export * from "./item-position.dto"; export * from "./payment-method-ref.dto"; export * from "./payment-term-ref.dto"; export * from "./proforma"; +export * from "./proforma-tax-config.dto"; export * from "./tax-combination-code.dto"; export * from "./tax-regime-ref.dto"; export * from "./taxes-breakdown.dto"; diff --git a/modules/customer-invoices/src/common/dto/shared/proforma-tax-config.dto.ts b/modules/customer-invoices/src/common/dto/shared/proforma-tax-config.dto.ts new file mode 100644 index 00000000..930523f9 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/proforma-tax-config.dto.ts @@ -0,0 +1,17 @@ +import { z } from "zod/v4"; + +const ProformaTaxModeSchema = z.enum(["single", "per_line"]); + +export const ProformaTaxConfigSchema = z.object({ + tax_mode: ProformaTaxModeSchema, + default_iva_code: z.string().nullable(), + uses_equivalence_surcharge: z.boolean(), + default_rec_code: z.string().nullable(), + uses_retention: z.boolean(), + default_retention_code: z.string().nullable(), +}); + +export const ProformaTaxConfigPatchSchema = ProformaTaxConfigSchema.partial(); + +export type ProformaTaxConfigDTO = z.infer; +export type ProformaTaxConfigPatchDTO = z.infer; diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts index 2398e8c2..17d85ce7 100644 --- a/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts @@ -42,6 +42,14 @@ export const GetProformaByIdAdapter = { customerId: dto.customer_id, recipient: mapRecipient(dto), + taxConfig: { + taxMode: dto.tax_config.tax_mode === "per_line" ? "perLine" : "single", + defaultIvaCode: dto.tax_config.default_iva_code, + usesEquivalenceSurcharge: dto.tax_config.uses_equivalence_surcharge, + defaultRecCode: dto.tax_config.default_rec_code, + usesRetention: dto.tax_config.uses_retention, + defaultRetentionCode: dto.tax_config.default_retention_code, + }, taxes: dto.taxes.map(mapTaxSummary), paymentMethodId: dto.payment_method?.id ?? null, diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts index 25a9b417..9a019f06 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts @@ -3,6 +3,15 @@ import type { ProformaRecipient } from "./proforma-recipient.entity"; import type { ProformaStatus } from "./proforma-status.entity"; import type { ProformaTaxSummary } from "./proforma-tax-summary.entity"; +export interface ProformaTaxConfig { + taxMode: "single" | "perLine"; + defaultIvaCode: string | null; + usesEquivalenceSurcharge: boolean; + defaultRecCode: string | null; + usesRetention: boolean; + defaultRetentionCode: string | null; +} + /** * Interface que representa una proforma en el sistema, * adaptada desde la respuesta de la API. @@ -33,6 +42,7 @@ export interface Proforma { recipient: ProformaRecipient; taxRegimeCode: string | null; + taxConfig: ProformaTaxConfig; taxes: ProformaTaxSummary[]; paymentMethodId: string | null; diff --git a/modules/customer-invoices/src/web/proformas/shared/utils/proforma-fiscal-options.utils.ts b/modules/customer-invoices/src/web/proformas/shared/utils/proforma-fiscal-options.utils.ts index 67737b37..84e45d9e 100644 --- a/modules/customer-invoices/src/web/proformas/shared/utils/proforma-fiscal-options.utils.ts +++ b/modules/customer-invoices/src/web/proformas/shared/utils/proforma-fiscal-options.utils.ts @@ -79,6 +79,30 @@ export const getProformaRecCode = ( return definition?.recCode ?? null; }; +export const getProformaTaxPercentageFromCode = (taxCode: string | null | undefined): number | null => { + if (!taxCode) { + return null; + } + + const definition = Object.values(PROFORMA_TAX_DEFINITIONS).find( + (candidate) => candidate.taxCode === taxCode + ); + + return definition?.taxPercentage ?? null; +}; + +export const getProformaRecPercentageFromCode = (recCode: string | null | undefined): number | null => { + if (!recCode) { + return null; + } + + const definition = Object.values(PROFORMA_TAX_DEFINITIONS).find( + (candidate) => candidate.recCode === recCode + ); + + return definition?.recPercentage ?? null; +}; + export const getProformaRetentionCode = ( retentionPercentage: number | string | null | undefined ): string | null => { @@ -89,6 +113,20 @@ export const getProformaRetentionCode = ( return PROFORMA_RETENTION_DEFINITIONS[parsedRetentionPercentage].retentionCode; }; +export const getProformaRetentionPercentageFromCode = ( + retentionCode: string | null | undefined +): number | null => { + if (!retentionCode) { + return null; + } + + const definition = Object.values(PROFORMA_RETENTION_DEFINITIONS).find( + (candidate) => candidate.retentionCode === retentionCode + ); + + return definition?.retentionPercentage ?? null; +}; + export const getProformaTaxOptions = () => PROFORMA_TAX_OPTIONS; export const getProformaRecOptions = () => PROFORMA_REC_OPTIONS; diff --git a/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-proforma-update-form.adapter.ts b/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-proforma-update-form.adapter.ts index 4f217a8e..2e018bee 100644 --- a/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-proforma-update-form.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-proforma-update-form.adapter.ts @@ -1,4 +1,9 @@ import type { Proforma } from "../../shared"; +import { + getProformaRecPercentageFromCode, + getProformaRetentionPercentageFromCode, + getProformaTaxPercentageFromCode, +} from "../../shared"; import type { ProformaTaxMode, ProformaUpdateForm } from "../entities"; import { buildProformaUpdateDefault } from "../utils"; @@ -68,49 +73,19 @@ const resolveFiscalState = ( proforma: Proforma, proformaDefaults: ProformaUpdateForm ): FiscalState => { - // Caso habitual: una sola combinación de impuestos - if (proforma.taxes.length === 1) { - const taxSummary = proforma.taxes[0]; - - const hasTaxPercentage = taxSummary.ivaCode !== null; - const hasRecPercentage = taxSummary.recCode !== null; - const hasRetentionPercentage = taxSummary.retentionCode !== null; - - return { - taxMode: "single", - defaultTaxPercentage: hasTaxPercentage ? taxSummary.ivaPercentage : null, - defaultRecPercentage: hasRecPercentage ? taxSummary.recPercentage : null, - defaultRetentionPercentage: hasRetentionPercentage ? taxSummary.retentionPercentage : null, - hasTaxPercentage, - hasRecPercentage, - hasRetentionPercentage, - }; - } - - // Caso excepcional: proforma sin impuestos - if (proforma.taxes.length === 0) { - return { - taxMode: "single", - defaultTaxPercentage: null, - defaultRecPercentage: null, - defaultRetentionPercentage: null, - hasTaxPercentage: false, - hasRecPercentage: false, - hasRetentionPercentage: false, - }; - } - - // Proforma con varias combinaciones de impuestos - const taxSummary = proforma.taxes[0]; - const hasRecPercentage = taxSummary.recCode !== null; + const defaultTaxPercentage = getProformaTaxPercentageFromCode(proforma.taxConfig.defaultIvaCode); + const defaultRecPercentage = getProformaRecPercentageFromCode(proforma.taxConfig.defaultRecCode); + const defaultRetentionPercentage = getProformaRetentionPercentageFromCode( + proforma.taxConfig.defaultRetentionCode + ); return { - taxMode: "perLine", - defaultTaxPercentage: null, - defaultRecPercentage: hasRecPercentage ? taxSummary.recPercentage : null, - defaultRetentionPercentage: null, - hasTaxPercentage: false, - hasRecPercentage, - hasRetentionPercentage: false, + taxMode: proforma.taxConfig.taxMode ?? proformaDefaults.taxMode, + defaultTaxPercentage, + defaultRecPercentage, + defaultRetentionPercentage, + hasTaxPercentage: proforma.taxConfig.defaultIvaCode !== null, + hasRecPercentage: proforma.taxConfig.usesEquivalenceSurcharge, + hasRetentionPercentage: proforma.taxConfig.usesRetention, }; }; diff --git a/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts index c31ca51d..6f6b8f74 100644 --- a/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts +++ b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts @@ -95,6 +95,17 @@ export const buildUpdateProformaByIdParams = ( ); } + if (shouldSendTaxConfig(patch)) { + data.tax_config = { + tax_mode: formData.taxMode === "perLine" ? "per_line" : "single", + default_iva_code: resolveSingleIvaCode(formData), + uses_equivalence_surcharge: formData.hasRecPercentage, + default_rec_code: resolveSingleRecCode(formData), + uses_retention: formData.hasRetentionPercentage, + default_retention_code: resolveRetentionCode(formData), + }; + } + // Si se han tocado los detalles, se envían todos if (shouldReplaceItems(patch)) { const context = buildProformaItemUpdateDTOContext(formData); @@ -158,6 +169,18 @@ const shouldReplaceItems = (patch: ProformaUpdatePatch): boolean => { ); }; +const shouldSendTaxConfig = (patch: ProformaUpdatePatch): boolean => { + return ( + ObjectHelper.hasOwn(patch, "taxMode") || + ObjectHelper.hasOwn(patch, "hasTaxPercentage") || + ObjectHelper.hasOwn(patch, "taxPercentage") || + ObjectHelper.hasOwn(patch, "hasRecPercentage") || + ObjectHelper.hasOwn(patch, "recPercentage") || + ObjectHelper.hasOwn(patch, "hasRetentionPercentage") || + ObjectHelper.hasOwn(patch, "retentionPercentage") + ); +}; + const resolveSingleIvaCode = (formData: ProformaUpdateForm): string | null => { if (!formData.hasTaxPercentage) { return null;