Configuración fiscal para proformas

This commit is contained in:
David Arranz 2026-07-29 11:00:37 +02:00
parent 659df116a2
commit eeb285f8b2
33 changed files with 1051 additions and 62 deletions

View File

@ -11,7 +11,7 @@ Definir la semantica correcta de `create proforma` como creacion de cabecera ini
En create: En create:
- `taxRegimeCode` representa configuracion fiscal inicial - `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 - `taxes` representa impuestos realmente calculados desde lineas valoradas
- `totals` representa importes 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_method_id?: string | null;
payment_term_id?: string | null; payment_term_id?: string | null;
tax_regime_code?: 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?: []; items?: [];
} }
``` ```
@ -52,6 +60,7 @@ Notas:
- el contrato actual del modulo puede seguir exigiendo otros campos tecnicos ya existentes - el contrato actual del modulo puede seguir exigiendo otros campos tecnicos ya existentes
- `items` es opcional; si existe, puede ir vacio - `items` es opcional; si existe, puede ir vacio
- no se usan defaults ocultos en Zod para disfrazar la intencion del request - 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 ## Response esperada al crear sin lineas
@ -83,6 +92,7 @@ Adaptando nombres exactos al contrato publico actual:
## Punto correcto de calculo ## Punto correcto de calculo
- `create` crea cabecera minima, numera y resuelve defaults - `create` crea cabecera minima, numera y resuelve defaults
- `create` persiste tambien el `tax_config` resuelto
- `update` permite anadir o editar lineas - `update` permite anadir o editar lineas
- al existir lineas valoradas, `update` recalcula `taxes` y `totals` - al existir lineas valoradas, `update` recalcula `taxes` y `totals`

View File

@ -59,6 +59,7 @@ En proformas:
- el backend emite `target_invoice_series_code` como nombre preferente - el backend emite `target_invoice_series_code` como nombre preferente
- `series` puede mantenerse solo como alias legacy de salida mientras existan consumidores antiguos - `series` puede mantenerse solo como alias legacy de salida mientras existan consumidores antiguos
- `taxes` representa impuestos calculados a partir de lineas valoradas - `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 - `totals` representa importes calculados a partir de lineas valoradas
- create no debe inventar filas fiscales sobre base `0` - create no debe inventar filas fiscales sobre base `0`

View File

@ -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

View File

@ -151,6 +151,7 @@ Preparar el esquema fisico para separar:
- create de proforma se consolida como creacion de cabecera/documento inicial - create de proforma se consolida como creacion de cabecera/documento inicial
- una proforma sin lineas es valida y debe persistirse con `items = []` - una proforma sin lineas es valida y debe persistirse con `items = []`
- `tax_regime_code` queda como configuracion fiscal inicial de la cabecera - `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` - `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` - 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 - update sigue siendo el punto donde se anaden lineas y se materializan impuestos/totales reales

View File

@ -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;

View File

@ -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;

View File

@ -1,3 +1,5 @@
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { ProformaPaymentResolver } from "../services"; import type { ProformaPaymentResolver } from "../services";
import { import {
@ -16,6 +18,7 @@ export const buildProformaCreator = (params: {
repository: IProformaRepository; repository: IProformaRepository;
proformaSeriesValidator: ProformaSeriesValidator; proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
customerServices: ICustomerPublicServices;
}): IProformaCreator => { }): IProformaCreator => {
return new ProformaCreator(params); return new ProformaCreator(params);
}; };

View File

@ -20,11 +20,13 @@ import {
ItemAmount, ItemAmount,
ItemDescription, ItemDescription,
ItemQuantity, ItemQuantity,
type ProformaTaxMode,
type ProformaRecipient, type ProformaRecipient,
} from "../../../domain"; } from "../../../domain";
import type { import type {
ProformaCreateInputProps, ProformaCreateInputProps,
ProformaItemCreateInputProps, ProformaItemCreateInputProps,
ProformaTaxConfigInputProps,
ProformaItemTaxCodesInput, ProformaItemTaxCodesInput,
} from "../models"; } from "../models";
@ -132,6 +134,8 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
errors errors
); );
const taxConfig = this.mapTaxConfig(dto.tax_config, errors);
const items = this.mapItemsProps(dto.items ?? [], { const items = this.mapItemsProps(dto.items ?? [], {
languageCode: languageCode!, languageCode: languageCode!,
currencyCode: currencyCode!, currencyCode: currencyCode!,
@ -164,6 +168,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
linkedInvoiceId: Maybe.none(), linkedInvoiceId: Maybe.none(),
taxRegimeCode: taxRegimeCode!, taxRegimeCode: taxRegimeCode!,
taxConfig,
paymentMethodId: paymentMethodId!, paymentMethodId: paymentMethodId!,
globalDiscountPercentage: globalDiscountPercentage!, 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<string> | undefined {
if (value === undefined) {
return undefined;
}
return extractOrPushError(
maybeFromNullableResult(value, (currentValue) => Result.ok(String(currentValue))),
path,
errors
);
}
private throwIfValidationErrors(errors: ValidationErrorDetail[]): void { private throwIfValidationErrors(errors: ValidationErrorDetail[]): void {
if (errors.length > 0) { if (errors.length > 0) {
throw new ValidationErrorCollection("Proforma props mapping failed", errors); throw new ValidationErrorCollection("Proforma props mapping failed", errors);

View File

@ -15,11 +15,18 @@ import {
import { Maybe, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; import { Maybe, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils";
import type { UpdateProformaByIdRequestDTO } from "../../../../common/dto"; import type { UpdateProformaByIdRequestDTO } from "../../../../common/dto";
import { InvoiceSerie, ItemAmount, ItemDescription, ItemQuantity } from "../../../domain"; import {
InvoiceSerie,
ItemAmount,
ItemDescription,
ItemQuantity,
type ProformaTaxMode,
} from "../../../domain";
import type { import type {
ProformaItemPatchInputProps, ProformaItemPatchInputProps,
ProformaItemTaxCodesInput, ProformaItemTaxCodesInput,
ProformaPatchInputProps, ProformaPatchInputProps,
ProformaTaxConfigPatchInputProps,
} from "../models"; } from "../models";
export interface IUpdateProformaInputMapper { 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) { if (dto.items !== undefined) {
proformaPatchProps.items = this.mapItemsProps(dto.items, { errors }); proformaPatchProps.items = this.mapItemsProps(dto.items, { errors });
} }
@ -257,6 +268,73 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
return Maybe.some(String(code).trim()); return Maybe.some(String(code).trim());
} }
private mapTaxConfigPatch(
taxConfigDTO: NonNullable<UpdateProformaByIdRequestDTO["tax_config"]>,
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<string> | undefined {
if (value === undefined) {
return undefined;
}
return extractOrPushError(
maybeFromNullableResult(value, (currentValue) => Result.ok(String(currentValue))),
path,
errors
);
}
private throwIfValidationErrors(errors: ValidationErrorDetail[]): void { private throwIfValidationErrors(errors: ValidationErrorDetail[]): void {
if (errors.length > 0) { if (errors.length > 0) {
throw new ValidationErrorCollection("Proforma props mapping failed", errors); throw new ValidationErrorCollection("Proforma props mapping failed", errors);

View File

@ -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 { Maybe } from "@repo/rdx-utils";
import type { InvoiceSerie } from "../../../domain"; 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. * - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio.
*/ */
export interface ProformaTaxConfigInputProps {
taxMode?: ProformaTaxMode;
defaultIvaCode?: Maybe<string>;
usesEquivalenceSurcharge?: boolean;
defaultRecCode?: Maybe<string>;
usesRetention?: boolean;
defaultRetentionCode?: Maybe<string>;
}
export type ProformaCreateInputProps = Omit< export type ProformaCreateInputProps = Omit<
IProformaCreateProps, IProformaCreateProps,
"items" | "proformaReference" | "proformaNumber" | "documentSeriesId" | "series" "items" | "proformaReference" | "proformaNumber" | "documentSeriesId" | "series" | "taxConfig"
> & { > & {
proformaSeriesCode: Maybe<InvoiceSerie>; proformaSeriesCode: Maybe<InvoiceSerie>;
targetInvoiceSeriesCode: Maybe<InvoiceSerie>; targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
taxConfig?: ProformaTaxConfigInputProps;
items: ProformaItemCreateInputProps[]; items: ProformaItemCreateInputProps[];
}; };

View File

@ -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 { Maybe } from "@repo/rdx-utils";
import type { InvoiceSerie } from "../../../domain"; 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. * - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio.
*/ */
export interface ProformaTaxConfigPatchInputProps {
taxMode?: ProformaTaxMode;
defaultIvaCode?: Maybe<string>;
usesEquivalenceSurcharge?: boolean;
defaultRecCode?: Maybe<string>;
usesRetention?: boolean;
defaultRetentionCode?: Maybe<string>;
}
export type ProformaPatchInputProps = Omit<ProformaPatchProps, "items" | "series"> & { export type ProformaPatchInputProps = Omit<ProformaPatchProps, "items" | "series"> & {
targetInvoiceSeriesCode?: Maybe<InvoiceSerie>; targetInvoiceSeriesCode?: Maybe<InvoiceSerie>;
taxConfig?: ProformaTaxConfigPatchInputProps;
items?: ProformaItemPatchInputProps[]; items?: ProformaItemPatchInputProps[];
}; };

View File

@ -22,6 +22,12 @@ export interface EnsureProformaItemTaxesParams {
taxCodes: ProformaItemTaxCodesInput; 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`. * 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<Result<Maybe<TaxDefinitionPublicModel>, Error>> {
return this.deps.taxDefinitionFinder.findActiveByCode({
companyId: params.companyId,
code: params.code,
atDate: params.atDate,
});
}
private collectCodes(taxCodes: ProformaItemTaxCodesInput): Collection<string> { private collectCodes(taxCodes: ProformaItemTaxCodesInput): Collection<string> {
const codes = [taxCodes.ivaCode, taxCodes.recCode, taxCodes.retentionCode] const codes = [taxCodes.ivaCode, taxCodes.recCode, taxCodes.retentionCode]
.filter((code) => code.isSome()) .filter((code) => code.isSome())

View File

@ -1,3 +1,4 @@
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
@ -6,8 +7,10 @@ import {
type IProformaItemCreateProps, type IProformaItemCreateProps,
InvoiceNumber, InvoiceNumber,
Proforma, Proforma,
ProformaTaxConfig,
type ProformaTaxMode,
} from "../../../domain"; } from "../../../domain";
import type { ProformaCreateInputProps } from "../models"; import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver"; import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver";
@ -36,13 +39,14 @@ export class ProformaCreator implements IProformaCreator {
paymentResolver: ProformaPaymentResolver; paymentResolver: ProformaPaymentResolver;
proformaSeriesValidator: ProformaSeriesValidator; proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
customerServices: ICustomerPublicServices;
} }
) {} ) {}
async create(params: IProformaCreatorParams): Promise<Result<Proforma, Error>> { async create(params: IProformaCreatorParams): Promise<Result<Proforma, Error>> {
const { companyId, id, props, transaction } = params; const { companyId, id, props, transaction } = params;
const resolvedProps = await this.resolveCreateProps(props); const resolvedProps = await this.resolveCreateProps(props, transaction);
if (resolvedProps.isFailure) { if (resolvedProps.isFailure) {
return Result.fail(resolvedProps.error); return Result.fail(resolvedProps.error);
@ -132,14 +136,30 @@ export class ProformaCreator implements IProformaCreator {
} }
private async resolveCreateProps( private async resolveCreateProps(
props: ProformaCreateInputProps props: ProformaCreateInputProps,
transaction?: unknown
): Promise< ): Promise<
Result<Omit<IProformaCreateProps, "proformaReference" | "proformaNumber" | "documentSeriesId">, Error> Result<Omit<IProformaCreateProps, "proformaReference" | "proformaNumber" | "documentSeriesId">, 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 = { const _newProps = {
...props, ...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 // Tax Regime => comprobar que existe
@ -162,6 +182,17 @@ export class ProformaCreator implements IProformaCreator {
return Result.fail(paymentMethodResult.error); 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 // En create una proforma puede nacer sin líneas: en ese caso solo persistimos
// cabecera + defaults iniciales. Los impuestos y totales materializados salen // cabecera + defaults iniciales. Los impuestos y totales materializados salen
// de `items`, así que con `items = []` no se generan filas fiscales. // de `items`, así que con `items = []` no se generan filas fiscales.
@ -186,7 +217,177 @@ export class ProformaCreator implements IProformaCreator {
return Result.ok({ return Result.ok({
..._newProps, ..._newProps,
taxConfig: taxConfigResult.data,
items: resolvedItems, items: resolvedItems,
}); });
} }
private async resolveTaxConfig(params: {
companyId: UniqueID;
atDate: IProformaCreateProps["proformaDate"];
customer: Awaited<ReturnType<ICustomerPublicServices["getCustomerById"]>> extends Result<
infer T,
Error
>
? T
: never;
override?: ProformaTaxConfigInputProps;
}): Promise<Result<ProformaTaxConfig, Error>> {
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<ReturnType<ICustomerPublicServices["getCustomerById"]>> extends Result<
infer T,
Error
>
? T
: never
): string | null {
return null;
}
private async resolveDefaultRecCode(params: {
companyId: UniqueID;
atDate: IProformaCreateProps["proformaDate"];
defaultIvaCode: Maybe<string>;
usesEquivalenceSurcharge: boolean;
requestedCode?: Maybe<string>;
}): Promise<Result<Maybe<string>, 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<string>;
}): Promise<Result<Maybe<string>, 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<string, string> = {
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> | 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;
}
} }

View File

@ -1,7 +1,12 @@
import { DomainValidationError, type UniqueID, type UtcDate } from "@repo/rdx-ddd"; 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 { ProformaPatchInputProps } from "../models";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
@ -72,6 +77,7 @@ export class ProformaUpdater implements IProformaUpdater {
const resolvedPatch = await this.resolvePatchProps({ const resolvedPatch = await this.resolvePatchProps({
companyId, companyId,
currentInvoiceDate: proforma.invoiceDate, currentInvoiceDate: proforma.invoiceDate,
currentTaxConfig: proforma.taxConfig,
patch: patchProps, patch: patchProps,
transaction, transaction,
}); });
@ -107,10 +113,11 @@ export class ProformaUpdater implements IProformaUpdater {
private async resolvePatchProps(params: { private async resolvePatchProps(params: {
companyId: UniqueID; companyId: UniqueID;
currentInvoiceDate: UtcDate; currentInvoiceDate: UtcDate;
currentTaxConfig: Proforma["taxConfig"];
patch: ProformaPatchInputProps; patch: ProformaPatchInputProps;
transaction: unknown; transaction: unknown;
}): Promise<Result<ProformaPatchProps, Error>> { }): Promise<Result<ProformaPatchProps, Error>> {
const { patch, companyId, currentInvoiceDate, transaction } = params; const { patch, companyId, currentInvoiceDate, transaction, currentTaxConfig } = params;
if (patch.taxRegimeCode !== undefined) { if (patch.taxRegimeCode !== undefined) {
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({ 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) { if (patch.targetInvoiceSeriesCode !== undefined) {
const targetInvoiceSeriesValidationResult = const targetInvoiceSeriesValidationResult =
await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({ await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({
@ -148,10 +166,16 @@ export class ProformaUpdater implements IProformaUpdater {
} }
if (patch.items === undefined) { if (patch.items === undefined) {
return Result.ok({ const resolvedPatch: ProformaPatchProps = {
...patch, ...patch,
series: patch.targetInvoiceSeriesCode, series: patch.targetInvoiceSeriesCode,
} as ProformaPatchProps); };
if (taxConfigResult.data !== undefined) {
resolvedPatch.taxConfig = taxConfigResult.data;
}
return Result.ok(resolvedPatch);
} }
const effectiveInvoiceDate = patch.proformaDate ?? currentInvoiceDate; const effectiveInvoiceDate = patch.proformaDate ?? currentInvoiceDate;
@ -174,11 +198,175 @@ export class ProformaUpdater implements IProformaUpdater {
}); });
} }
return Result.ok({ const resolvedPatch: ProformaPatchProps = {
...patch, ...patch,
series: patch.targetInvoiceSeriesCode, series: patch.targetInvoiceSeriesCode,
items: resolvedItems, 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<Result<ProformaTaxConfig | undefined, Error>> {
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<string>;
usesEquivalenceSurcharge: boolean;
requestedCode?: Maybe<string>;
}): Promise<Result<Maybe<string>, 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<string>;
}): Promise<Result<Maybe<string>, 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<string, string> = {
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> | 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 { private isApprovedPatchAllowed(patch: ProformaPatchInputProps): boolean {

View File

@ -69,6 +69,17 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder
tax_regime: maybeToNullable(taxRegime, (taxRegime) => taxRegime), tax_regime: maybeToNullable(taxRegime, (taxRegime) => taxRegime),
payment_term: null, 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(), subtotal_amount: allTotals.subtotalAmount.toObjectString(),
items_discount_amount: allTotals.itemsDiscountAmount.toObjectString(), items_discount_amount: allTotals.itemsDiscountAmount.toObjectString(),

View File

@ -29,7 +29,7 @@ import {
import { InvalidProformaTransitionError, ProformaItemMismatch } from "../errors"; import { InvalidProformaTransitionError, ProformaItemMismatch } from "../errors";
import type { IProformaTaxTotals, ProformaCalculationContext } from "../services"; import type { IProformaTaxTotals, ProformaCalculationContext } from "../services";
import { canManuallyTransitionProformaStatus } from "../services"; import { canManuallyTransitionProformaStatus } from "../services";
import { ProformaItemTaxes, type ProformaRecipient } from "../value-objects"; import { ProformaItemTaxes, ProformaTaxConfig, type ProformaRecipient } from "../value-objects";
export interface IProformaCreateProps { export interface IProformaCreateProps {
companyId: UniqueID; companyId: UniqueID;
@ -57,6 +57,7 @@ export interface IProformaCreateProps {
paymentMethodId: Maybe<UniqueID>; paymentMethodId: Maybe<UniqueID>;
taxRegimeCode: Maybe<string>; taxRegimeCode: Maybe<string>;
taxConfig: ProformaTaxConfig;
items: IProformaItemCreateProps[]; items: IProformaItemCreateProps[];
globalDiscountPercentage: DiscountPercentage; globalDiscountPercentage: DiscountPercentage;
@ -107,6 +108,7 @@ export interface IProforma {
paymentMethodId: Maybe<UniqueID>; paymentMethodId: Maybe<UniqueID>;
taxRegimeCode: Maybe<string>; taxRegimeCode: Maybe<string>;
taxConfig: ProformaTaxConfig;
linkedInvoiceId: Maybe<UniqueID>; linkedInvoiceId: Maybe<UniqueID>;
@ -169,10 +171,13 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
// Mutabilidad // Mutabilidad
public update(patchProps: ProformaPatchProps): Result<Proforma, Error> { public update(patchProps: ProformaPatchProps): Result<Proforma, Error> {
const { items, ...otherProps } = patchProps; const { items, ...otherProps } = patchProps;
const definedProps = Object.fromEntries(
Object.entries(otherProps).filter(([, value]) => value !== undefined)
) as Partial<ProformaInternalProps>;
const candidateProps: ProformaInternalProps = { const candidateProps: ProformaInternalProps = {
...this.props, ...this.props,
...otherProps, ...definedProps,
}; };
// Validacciones // Validacciones
@ -281,6 +286,10 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return this.props.taxRegimeCode; return this.props.taxRegimeCode;
} }
public get taxConfig(): ProformaTaxConfig {
return this.props.taxConfig;
}
public get linkedInvoiceId(): Maybe<UniqueID> { public get linkedInvoiceId(): Maybe<UniqueID> {
return this.props.linkedInvoiceId; return this.props.linkedInvoiceId;
} }

View File

@ -1,2 +1,3 @@
export * from "./proforma-item-taxes.vo"; export * from "./proforma-item-taxes.vo";
export * from "./proforma-recipient.vo"; export * from "./proforma-recipient.vo";
export * from "./proforma-tax-config.vo";

View File

@ -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<string>;
usesEquivalenceSurcharge: boolean;
defaultRecCode: Maybe<string>;
usesRetention: boolean;
defaultRetentionCode: Maybe<string>;
}
export class ProformaTaxConfig {
private constructor(private readonly props: ProformaTaxConfigProps) {}
public static create(props: ProformaTaxConfigProps): Result<ProformaTaxConfig, Error> {
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<string> {
return this.props.defaultIvaCode;
}
public get usesEquivalenceSurcharge(): boolean {
return this.props.usesEquivalenceSurcharge;
}
public get defaultRecCode(): Maybe<string> {
return this.props.defaultRecCode;
}
public get usesRetention(): boolean {
return this.props.usesRetention;
}
public get defaultRetentionCode(): Maybe<string> {
return this.props.defaultRetentionCode;
}
public getProps(): ProformaTaxConfigProps {
return this.props;
}
}

View File

@ -53,6 +53,12 @@ export class ProformaModel extends Model<
declare payment_term_id: CreationOptional<string | null>; declare payment_term_id: CreationOptional<string | null>;
declare tax_regime_code: CreationOptional<string | null>; declare tax_regime_code: CreationOptional<string | null>;
declare tax_mode: CreationOptional<string>;
declare default_iva_code: CreationOptional<string | null>;
declare uses_equivalence_surcharge: CreationOptional<boolean>;
declare default_rec_code: CreationOptional<string | null>;
declare uses_retention: CreationOptional<boolean>;
declare default_retention_code: CreationOptional<string | null>;
declare subtotal_amount_value: number; declare subtotal_amount_value: number;
declare subtotal_amount_scale: number; declare subtotal_amount_scale: number;
@ -249,6 +255,36 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, 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: { subtotal_amount_value: {
type: new DataTypes.BIGINT(), type: new DataTypes.BIGINT(),
allowNull: false, allowNull: false,

View File

@ -1,4 +1,5 @@
import type { SetupParams } from "@erp/core/api"; import type { SetupParams } from "@erp/core/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
@ -51,6 +52,7 @@ export function buildProformaPublicServices(
const { database } = params; const { database } = params;
const catalogs = resolveProformaCatalogsDeps(params); const catalogs = resolveProformaCatalogsDeps(params);
const customerServices = params.getService<ICustomerPublicServices>("customers:general");
const documentSeriesServices = const documentSeriesServices =
params.getService<DocumentSeriesPublicServicesType>("document-series:general"); params.getService<DocumentSeriesPublicServicesType>("document-series:general");
@ -92,6 +94,7 @@ export function buildProformaPublicServices(
paymentResolver: catalogResolvers.paymentResolver, paymentResolver: catalogResolvers.paymentResolver,
proformaSeriesValidator, proformaSeriesValidator,
targetInvoiceSeriesValidator, targetInvoiceSeriesValidator,
customerServices,
}); });
return { return {

View File

@ -1,4 +1,5 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; 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 { DocumentSeriesPublicServicesType } from "@erp/document-series/api";
import type { ICompanyPublicServices } from "../../../../../../companies/src/api"; import type { ICompanyPublicServices } from "../../../../../../companies/src/api";
@ -65,6 +66,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const catalogs = resolveProformaCatalogsDeps(params); const catalogs = resolveProformaCatalogsDeps(params);
const companiesServices = params.getService<ICompanyPublicServices>("companies:general"); const companiesServices = params.getService<ICompanyPublicServices>("companies:general");
const customerServices = params.getService<ICustomerPublicServices>("customers:general");
const documentSeriesServices = const documentSeriesServices =
params.getService<DocumentSeriesPublicServicesType>("document-series:general"); params.getService<DocumentSeriesPublicServicesType>("document-series:general");
@ -109,6 +111,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
repository, repository,
proformaSeriesValidator, proformaSeriesValidator,
targetInvoiceSeriesValidator, targetInvoiceSeriesValidator,
customerServices,
}); });
const updater = buildProformaUpdater({ const updater = buildProformaUpdater({

View File

@ -18,6 +18,7 @@ import {
InvoiceSerie, InvoiceSerie,
InvoiceStatus, InvoiceStatus,
Proforma, Proforma,
ProformaTaxConfig,
type ProformaInternalProps, type ProformaInternalProps,
ProformaItems, ProformaItems,
} from "../../../../../../domain"; } from "../../../../../../domain";
@ -177,6 +178,25 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
errors 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 { return {
invoiceId, invoiceId,
companyId, companyId,
@ -193,6 +213,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
currencyCode, currencyCode,
paymentMethodId, paymentMethodId,
taxRegimeCode, taxRegimeCode,
taxConfig,
globalDiscountPercentage, globalDiscountPercentage,
linkedInvoiceId, linkedInvoiceId,
@ -265,6 +286,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
paymentMethodId: attributes.paymentMethodId!, paymentMethodId: attributes.paymentMethodId!,
taxRegimeCode: attributes.taxRegimeCode!, 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. 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_code: maybeToNullable(source.taxRegimeCode, (value) => value),
tax_regime_description: null, 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_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale, subtotal_amount_scale: allAmounts.subtotalAmount.scale,

View File

@ -18,6 +18,7 @@ import {
InvoiceSerie, InvoiceSerie,
InvoiceStatus, InvoiceStatus,
Proforma, Proforma,
ProformaTaxConfig,
type ProformaInternalProps, type ProformaInternalProps,
ProformaItems, ProformaItems,
} from "../../../../../../domain"; } from "../../../../../../domain";
@ -117,6 +118,30 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
"tax_regime_code", "tax_regime_code",
errors 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( const globalDiscountPercentage = extractOrPushError(
DiscountPercentage.create({ DiscountPercentage.create({
value: Number(raw.global_discount_percentage_value ?? 0), value: Number(raw.global_discount_percentage_value ?? 0),
@ -148,6 +173,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
currencyCode, currencyCode,
paymentMethodId, paymentMethodId,
taxRegimeCode, taxRegimeCode,
taxConfig,
globalDiscountPercentage, globalDiscountPercentage,
linkedInvoiceId, linkedInvoiceId,
}; };
@ -201,6 +227,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
globalDiscountPercentage: attributes.globalDiscountPercentage!, globalDiscountPercentage: attributes.globalDiscountPercentage!,
paymentMethodId: attributes.paymentMethodId!, paymentMethodId: attributes.paymentMethodId!,
taxRegimeCode: attributes.taxRegimeCode!, taxRegimeCode: attributes.taxRegimeCode!,
taxConfig: attributes.taxConfig!,
linkedInvoiceId: attributes.linkedInvoiceId!, linkedInvoiceId: attributes.linkedInvoiceId!,
}; };
@ -264,6 +291,15 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()), payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()),
payment_term_id: null, payment_term_id: null,
tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value), 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_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale, subtotal_amount_scale: allAmounts.subtotalAmount.scale,

View File

@ -7,7 +7,7 @@ import {
} from "@erp/core"; } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ItemPositionSchema, TaxCombinationCodeSchema } from "../../shared"; import { ItemPositionSchema, ProformaTaxConfigPatchSchema, TaxCombinationCodeSchema } from "../../shared";
export const CreateProformaItemRequestSchema = z.object({ export const CreateProformaItemRequestSchema = z.object({
position: ItemPositionSchema, position: ItemPositionSchema,
@ -49,6 +49,7 @@ export const CreateProformaRequestSchema = z.object({
payment_method_id: z.uuid().nullable().optional(), payment_method_id: z.uuid().nullable().optional(),
payment_term_id: z.uuid().nullable().optional(), payment_term_id: z.uuid().nullable().optional(),
tax_regime_code: z.string().nullable().optional(), tax_regime_code: z.string().nullable().optional(),
tax_config: ProformaTaxConfigPatchSchema.optional(),
items: z.array(CreateProformaItemRequestSchema).optional(), items: z.array(CreateProformaItemRequestSchema).optional(),
}); });

View File

@ -8,7 +8,7 @@ import {
} from "@erp/core"; } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ItemPositionSchema } from "../../shared"; import { ItemPositionSchema, ProformaTaxConfigPatchSchema } from "../../shared";
export const UpdateProformaItemRequestSchema = z.object({ export const UpdateProformaItemRequestSchema = z.object({
position: ItemPositionSchema, position: ItemPositionSchema,
@ -54,6 +54,7 @@ export const UpdateProformaByIdRequestSchema = z.object({
payment_method_id: z.uuid().nullable().optional(), payment_method_id: z.uuid().nullable().optional(),
payment_term_id: z.uuid().nullable().optional(), payment_term_id: z.uuid().nullable().optional(),
tax_regime_code: z.string().nullable().optional(), tax_regime_code: z.string().nullable().optional(),
tax_config: ProformaTaxConfigPatchSchema.optional(),
// retención como código??? retencion_15 // retención como código??? retencion_15

View File

@ -11,6 +11,7 @@ import { z } from "zod/v4";
import { import {
PaymentMethodRefSchema, PaymentMethodRefSchema,
PaymentTermRefSchema, PaymentTermRefSchema,
ProformaTaxConfigSchema,
TaxRegimeRefSchema, TaxRegimeRefSchema,
TaxesBreakdownSchema, TaxesBreakdownSchema,
} from "../../shared"; } from "../../shared";
@ -50,6 +51,7 @@ export const GetProformaByIdResponseSchema = z.object({
payment_term: PaymentTermRefSchema.nullable(), payment_term: PaymentTermRefSchema.nullable(),
tax_regime: TaxRegimeRefSchema.nullable(), tax_regime: TaxRegimeRefSchema.nullable(),
tax_config: ProformaTaxConfigSchema,
subtotal_amount: MoneySchema, subtotal_amount: MoneySchema,
items_discount_amount: MoneySchema, items_discount_amount: MoneySchema,

View File

@ -3,6 +3,7 @@ export * from "./item-position.dto";
export * from "./payment-method-ref.dto"; export * from "./payment-method-ref.dto";
export * from "./payment-term-ref.dto"; export * from "./payment-term-ref.dto";
export * from "./proforma"; export * from "./proforma";
export * from "./proforma-tax-config.dto";
export * from "./tax-combination-code.dto"; export * from "./tax-combination-code.dto";
export * from "./tax-regime-ref.dto"; export * from "./tax-regime-ref.dto";
export * from "./taxes-breakdown.dto"; export * from "./taxes-breakdown.dto";

View File

@ -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<typeof ProformaTaxConfigSchema>;
export type ProformaTaxConfigPatchDTO = z.infer<typeof ProformaTaxConfigPatchSchema>;

View File

@ -42,6 +42,14 @@ export const GetProformaByIdAdapter = {
customerId: dto.customer_id, customerId: dto.customer_id,
recipient: mapRecipient(dto), 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), taxes: dto.taxes.map(mapTaxSummary),
paymentMethodId: dto.payment_method?.id ?? null, paymentMethodId: dto.payment_method?.id ?? null,

View File

@ -3,6 +3,15 @@ import type { ProformaRecipient } from "./proforma-recipient.entity";
import type { ProformaStatus } from "./proforma-status.entity"; import type { ProformaStatus } from "./proforma-status.entity";
import type { ProformaTaxSummary } from "./proforma-tax-summary.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, * Interface que representa una proforma en el sistema,
* adaptada desde la respuesta de la API. * adaptada desde la respuesta de la API.
@ -33,6 +42,7 @@ export interface Proforma {
recipient: ProformaRecipient; recipient: ProformaRecipient;
taxRegimeCode: string | null; taxRegimeCode: string | null;
taxConfig: ProformaTaxConfig;
taxes: ProformaTaxSummary[]; taxes: ProformaTaxSummary[];
paymentMethodId: string | null; paymentMethodId: string | null;

View File

@ -79,6 +79,30 @@ export const getProformaRecCode = (
return definition?.recCode ?? null; 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 = ( export const getProformaRetentionCode = (
retentionPercentage: number | string | null | undefined retentionPercentage: number | string | null | undefined
): string | null => { ): string | null => {
@ -89,6 +113,20 @@ export const getProformaRetentionCode = (
return PROFORMA_RETENTION_DEFINITIONS[parsedRetentionPercentage].retentionCode; 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 getProformaTaxOptions = () => PROFORMA_TAX_OPTIONS;
export const getProformaRecOptions = () => PROFORMA_REC_OPTIONS; export const getProformaRecOptions = () => PROFORMA_REC_OPTIONS;

View File

@ -1,4 +1,9 @@
import type { Proforma } from "../../shared"; import type { Proforma } from "../../shared";
import {
getProformaRecPercentageFromCode,
getProformaRetentionPercentageFromCode,
getProformaTaxPercentageFromCode,
} from "../../shared";
import type { ProformaTaxMode, ProformaUpdateForm } from "../entities"; import type { ProformaTaxMode, ProformaUpdateForm } from "../entities";
import { buildProformaUpdateDefault } from "../utils"; import { buildProformaUpdateDefault } from "../utils";
@ -68,49 +73,19 @@ const resolveFiscalState = (
proforma: Proforma, proforma: Proforma,
proformaDefaults: ProformaUpdateForm proformaDefaults: ProformaUpdateForm
): FiscalState => { ): FiscalState => {
// Caso habitual: una sola combinación de impuestos const defaultTaxPercentage = getProformaTaxPercentageFromCode(proforma.taxConfig.defaultIvaCode);
if (proforma.taxes.length === 1) { const defaultRecPercentage = getProformaRecPercentageFromCode(proforma.taxConfig.defaultRecCode);
const taxSummary = proforma.taxes[0]; const defaultRetentionPercentage = getProformaRetentionPercentageFromCode(
proforma.taxConfig.defaultRetentionCode
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;
return { return {
taxMode: "perLine", taxMode: proforma.taxConfig.taxMode ?? proformaDefaults.taxMode,
defaultTaxPercentage: null, defaultTaxPercentage,
defaultRecPercentage: hasRecPercentage ? taxSummary.recPercentage : null, defaultRecPercentage,
defaultRetentionPercentage: null, defaultRetentionPercentage,
hasTaxPercentage: false, hasTaxPercentage: proforma.taxConfig.defaultIvaCode !== null,
hasRecPercentage, hasRecPercentage: proforma.taxConfig.usesEquivalenceSurcharge,
hasRetentionPercentage: false, hasRetentionPercentage: proforma.taxConfig.usesRetention,
}; };
}; };

View File

@ -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 // Si se han tocado los detalles, se envían todos
if (shouldReplaceItems(patch)) { if (shouldReplaceItems(patch)) {
const context = buildProformaItemUpdateDTOContext(formData); 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 => { const resolveSingleIvaCode = (formData: ProformaUpdateForm): string | null => {
if (!formData.hasTaxPercentage) { if (!formData.hasTaxPercentage) {
return null; return null;