This commit is contained in:
David Arranz 2026-06-13 14:58:09 +02:00
parent dc6382dd41
commit 5bc9920538
174 changed files with 3618 additions and 1004 deletions

File diff suppressed because one or more lines are too long

View File

@ -208,7 +208,7 @@ function validateModuleServices(moduleName: string, services: Record<string, unk
const fullName = `${moduleName}:${serviceKey}`;
if (serviceApi === undefined) {
throw new Error(`Service "${fullName}" is undefined`);
throw new Error(`Service "${fullName}" is undefined (validateModuleServices)`);
}
}
}

View File

@ -4,6 +4,7 @@ const services: Record<string, unknown> = {};
* Registra un objeto de servicio (API) bajo un nombre.
*/
export function registerService(name: string, api: unknown) {
console.debug(`Registering service: ${name}`);
if (services[name]) {
throw new Error(`❌ Servicio "${name}" ya fue registrado.`);
}

View File

@ -1,5 +1,4 @@
export * from "./payment-methods";
export * from "./payment-terms";
export * from "./services";
export * from "./tax-definitions";
export * from "./tax-regimes";

View File

@ -1,6 +1,7 @@
export * from "./di";
export * from "./mappers";
export * from "./models";
export * from "./public";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";

View File

@ -0,0 +1 @@
export * from './payment-method-not-active.error';

View File

@ -0,0 +1,9 @@
import { DomainError, UniqueID } from "@repo/rdx-ddd";
export class PaymentMethodNotActiveError extends DomainError {
public readonly code = "PAYMENT_METHOD_NOT_ACTIVE" as const;
public constructor(public readonly id: UniqueID) {
super(`Payment method with id ${id.toString()} is not active`);
}
}

View File

@ -0,0 +1,4 @@
export * from "./mappers";
export * from "./models/";
export * from "./services";
export * from "./services/";

View File

@ -0,0 +1 @@
export * from './payment-method-public-model.mapper';

View File

@ -0,0 +1,18 @@
import type {
PaymentMethodPublicModel
} from "../models";
import { PaymentMethod } from '../../../../domain';
export class PaymentMethodPublicModelMapper {
public toPublicModel(paymentMethod: PaymentMethod): PaymentMethodPublicModel {
return {
id: paymentMethod.id,
companyId: paymentMethod.companyId,
name: paymentMethod.name.toPrimitive(),
description: paymentMethod.description.map((value) => value.toPrimitive()),
isActive: paymentMethod.isActive,
};
}
}

View File

@ -0,0 +1 @@
export * from './payment-method-public.model';

View File

@ -0,0 +1,14 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
export interface PaymentMethodPublicModel {
id: UniqueID;
companyId: UniqueID;
name: string;
description: Maybe<string>;
isActive: boolean;
}

View File

@ -0,0 +1,2 @@
export * from './payment-method-public-finder.interface';
export * from './payment-method-public-finder';

View File

@ -0,0 +1,24 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { PaymentMethodPublicModel } from "../models/payment-method-public.model";
export interface FindPaymentMethodByIdInCompanyParams {
companyId: UniqueID;
id: UniqueID;
transaction?: unknown;
}
export interface IPaymentMethodPublicFinder {
existsByIdInCompany(
params: FindPaymentMethodByIdInCompanyParams,
): Promise<Result<boolean, Error>>;
getByIdInCompany(
params: FindPaymentMethodByIdInCompanyParams,
): Promise<Result<PaymentMethodPublicModel, Error>>;
findByIdInCompany(
params: FindPaymentMethodByIdInCompanyParams,
): Promise<Result<Maybe<PaymentMethodPublicModel>, Error>>;
}

View File

@ -0,0 +1,71 @@
import { Maybe, Result } from "@repo/rdx-utils";
import type { IPaymentMethodRepository } from "../../repositories";
import type { PaymentMethodPublicModelMapper } from "../mappers/payment-method-public-model.mapper";
import type { PaymentMethodPublicModel } from "../models/payment-method-public.model";
import type {
FindPaymentMethodByIdInCompanyParams,
IPaymentMethodPublicFinder,
} from "./payment-method-public-finder.interface";
export class PaymentMethodPublicFinder implements IPaymentMethodPublicFinder {
public constructor(
private readonly deps: {
repository: IPaymentMethodRepository;
mapper: PaymentMethodPublicModelMapper;
},
) {}
public async existsByIdInCompany(
params: FindPaymentMethodByIdInCompanyParams,
): Promise<Result<boolean, Error>> {
const result = await this.deps.repository.existsByIdInCompany(
params.companyId,
params.id,
params.transaction,
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.data);
}
public async getByIdInCompany(
params: FindPaymentMethodByIdInCompanyParams,
): Promise<Result<PaymentMethodPublicModel, Error>> {
const result = await this.deps.repository.getByIdInCompany(
params.companyId,
params.id,
params.transaction,
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(this.deps.mapper.toPublicModel(result.data));
}
public async findByIdInCompany(
params: FindPaymentMethodByIdInCompanyParams,
): Promise<Result<Maybe<PaymentMethodPublicModel>, Error>> {
const result = await this.deps.repository.findByIdInCompany(
params.companyId,
params.id,
params.transaction,
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(
result.data.match(
(paymentMethod) => Maybe.some(this.deps.mapper.toPublicModel(paymentMethod)),
() => Maybe.none<PaymentMethodPublicModel>(),
),
);
}
}

View File

@ -1,6 +1,6 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { PaymentMethod } from "../../../domain";
import type { PaymentMethodSummary } from "../models";
@ -18,6 +18,11 @@ export interface IPaymentMethodRepository {
id: UniqueID,
transaction?: unknown
): Promise<Result<PaymentMethod, Error>>;
findByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<PaymentMethod>, Error>>;
findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,

View File

@ -9,13 +9,13 @@ import type { IPaymentMethodRepository } from "../repositories";
export interface IPaymentMethodFinder {
findPaymentMethodById(
companyId: UniqueID,
invoiceId: UniqueID,
paymentMethodId: UniqueID,
transaction?: unknown
): Promise<Result<PaymentMethod, Error>>;
paymentmethodExists(
companyId: UniqueID,
invoiceId: UniqueID,
paymentMethodId: UniqueID,
transaction?: unknown
): Promise<Result<boolean, Error>>;
@ -31,18 +31,18 @@ export class PaymentMethodFinder implements IPaymentMethodFinder {
async findPaymentMethodById(
companyId: UniqueID,
paymentmethodId: UniqueID,
paymentMethodId: UniqueID,
transaction?: unknown
): Promise<Result<PaymentMethod, Error>> {
return this.repository.getByIdInCompany(companyId, paymentmethodId, transaction);
return this.repository.getByIdInCompany(companyId, paymentMethodId, transaction);
}
async paymentmethodExists(
companyId: UniqueID,
paymentmethodId: UniqueID,
paymentMethodId: UniqueID,
transaction?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, paymentmethodId, transaction);
return this.repository.existsByIdInCompany(companyId, paymentMethodId, transaction);
}
async findPaymentMethodsByCriteria(

View File

@ -1,20 +0,0 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { IPaymentMethodPublicServices } from "../payment-methods";
import type { IPaymentTermPublicServices } from "../payment-terms";
import type { ITaxRegimePublicServices } from "../tax-regimes";
export type { IPaymentMethodPublicServices } from "../payment-methods";
export type { IPaymentTermPublicServices } from "../payment-terms";
export type { ITaxRegimePublicServices } from "../tax-regimes";
export interface ICatalogServicesContext {
transaction: unknown;
companyId: UniqueID;
}
export interface ICatalogPublicServices {
paymentMethods: IPaymentMethodPublicServices;
paymentTerms: IPaymentTermPublicServices;
taxRegimes: ITaxRegimePublicServices;
}

View File

@ -1 +0,0 @@
export * from './catalog-public-services.interface';

View File

@ -1,5 +1,6 @@
export * from "./mappers";
export * from "./models";
export * from "./public";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";

View File

@ -0,0 +1 @@
export * from './tax-definitions-not-found.error';

View File

@ -0,0 +1,10 @@
import { DomainError } from "@repo/rdx-ddd";
import type { Collection } from "@repo/rdx-utils";
export class TaxDefinitionsNotFoundError extends DomainError {
public readonly code = "TAX_DEFINITIONS_NOT_FOUND" as const;
public constructor(public readonly missingCodes: Collection<string>) {
super(`Tax definitions not found for codes: ${missingCodes.getAll().join(", ")}`);
}
}

View File

@ -0,0 +1,5 @@
export * from "./errors/";
export * from "./mappers";
export * from "./models/";
export * from "./services";
export * from "./services/";

View File

@ -0,0 +1 @@
export * from './tax-definition-public-model.mapper';

View File

@ -0,0 +1,80 @@
import { Collection, Maybe } from "@repo/rdx-utils";
import type { TaxDefinition } from "../../../../domain/tax-definitions";
import type {
TaxDefinitionPublicCalculationBehavior,
TaxDefinitionPublicFamily,
TaxDefinitionPublicModel,
TaxDefinitionPublicScope,
} from "../models/tax-definition-public.model";
export class TaxDefinitionPublicModelMapper {
public toPublicModel(taxDefinition: TaxDefinition): TaxDefinitionPublicModel {
return {
id: taxDefinition.id,
companyId: Maybe.some(taxDefinition.companyId),
code: taxDefinition.code.toPrimitive(),
name: taxDefinition.name.toPrimitive(),
description: taxDefinition.description.map((value) => value.toPrimitive()),
rate: taxDefinition.rate,
taxFamily: this.mapTaxFamily(taxDefinition.taxFamily.toPrimitive()),
calculationBehavior: this.mapCalculationBehavior(
taxDefinition.calculationBehavior.toPrimitive()
),
taxScope: this.mapTaxScope(taxDefinition.taxScope.toPrimitive()),
jurisdictionCountryCode: taxDefinition.jurisdictionCountryCode,
jurisdictionRegionCode: taxDefinition.jurisdictionRegionCode,
invoiceNote: taxDefinition.invoiceNote.map((value) => value.toPrimitive()),
allowedSurchargeCodes: new Collection(
taxDefinition.allowedSurchargeCodes.match(
(codes) => codes.map((code) => code.toPrimitive()),
() => []
)
),
isSystem: taxDefinition.isSystem,
isActive: taxDefinition.isActive,
validFrom: taxDefinition.validFrom,
validTo: taxDefinition.validTo,
};
}
private mapTaxFamily(value: string): TaxDefinitionPublicFamily {
switch (value) {
case "iva":
case "igic":
case "ipsi":
return value;
case "equivalence_surcharge":
return "surcharge";
case "withholding":
return "retention";
default:
throw new Error(`Unsupported tax family for public model: ${value}`);
}
}
private mapCalculationBehavior(value: string): TaxDefinitionPublicCalculationBehavior {
switch (value) {
case "additive":
case "subtractive":
return value;
default:
throw new Error(`Unsupported calculation behavior for public model: ${value}`);
}
}
private mapTaxScope(value: string): TaxDefinitionPublicScope {
switch (value) {
case "domestic":
return "sales";
case "intra_eu":
case "import":
return "purchases";
case "export":
case "international":
return "both";
default:
throw new Error(`Unsupported tax scope for public model: ${value}`);
}
}
}

View File

@ -0,0 +1 @@
export * from './tax-definition-public.model';

View File

@ -0,0 +1,72 @@
import type {
CountryCode,
CountryRegionCode, Percentage, UniqueID,
UtcDate
} from "@repo/rdx-ddd";
import type { Collection, Maybe } from "@repo/rdx-utils";
/**
* Familia fiscal pública expuesta por `catalogs`.
*
* No se expone el Value Object interno de dominio para evitar acoplar
* consumidores externos a las invariantes privadas de `tax-definitions`.
*/
export type TaxDefinitionPublicFamily = "iva" | "igic" | "ipsi" | "surcharge" | "retention";
/**
* Comportamiento de cálculo público de un impuesto.
*
* `additive` suma al total.
* `subtractive` resta del total, por ejemplo retenciones.
*/
export type TaxDefinitionPublicCalculationBehavior = "additive" | "subtractive";
/**
* Ámbito público de aplicación fiscal.
*/
export type TaxDefinitionPublicScope = "sales" | "purchases" | "both";
/**
* Modelo público de lectura expuesto por el módulo `catalogs`.
*
* Es un contrato backend entre módulos, no un DTO HTTP y no una entidad
* de dominio. Puede usar Value Objects comunes del ERP, pero no debe
* exponer Value Objects internos de `tax-definitions`.
*/
export interface TaxDefinitionPublicModel {
id: UniqueID;
/**
* `none` si la definición es global/sistema.
* `some(companyId)` si la definición está sobrescrita para una empresa.
*/
companyId: Maybe<UniqueID>;
code: string;
name: string;
description: Maybe<string>;
rate: Percentage;
taxFamily: TaxDefinitionPublicFamily;
calculationBehavior: TaxDefinitionPublicCalculationBehavior;
taxScope: TaxDefinitionPublicScope;
jurisdictionCountryCode: CountryCode;
jurisdictionRegionCode: Maybe<CountryRegionCode>;
invoiceNote: Maybe<string>;
/**
* Códigos de recargo compatibles con esta definición.
*
* Colección vacía => no permite recargos.
*/
allowedSurchargeCodes: Collection<string>;
isSystem: boolean;
isActive: boolean;
validFrom: Maybe<UtcDate>;
validTo: Maybe<UtcDate>;
}

View File

@ -0,0 +1,2 @@
export * from './tax-definition-public-finder.interface';
export * from './tax-definition-public-finder';

View File

@ -0,0 +1,30 @@
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { TaxDefinitionPublicModel } from "../models/tax-definition-public.model";
export interface FindActiveTaxDefinitionByCodeParams {
companyId: UniqueID;
code: string;
atDate: UtcDate;
}
export interface FindActiveTaxDefinitionsByCodesParams {
companyId: UniqueID;
codes: Collection<string>;
atDate: UtcDate;
}
export interface ITaxDefinitionPublicFinder {
findActiveByCode(
params: FindActiveTaxDefinitionByCodeParams
): Promise<Result<Maybe<TaxDefinitionPublicModel>, Error>>;
findActiveByCodes(
params: FindActiveTaxDefinitionsByCodesParams
): Promise<Result<Collection<TaxDefinitionPublicModel>, Error>>;
ensureActiveByCodes(
params: FindActiveTaxDefinitionsByCodesParams
): Promise<Result<Collection<TaxDefinitionPublicModel>, Error>>;
}

View File

@ -0,0 +1,64 @@
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { ITaxDefinitionRepository } from "../../repositories";
import { TaxDefinitionsNotFoundError } from "../errors/tax-definitions-not-found.error";
import type { TaxDefinitionPublicModelMapper } from "../mappers/tax-definition-public-model.mapper";
import type {
FindActiveTaxDefinitionByCodeParams,
FindActiveTaxDefinitionsByCodesParams,
ITaxDefinitionPublicFinder,
} from "./tax-definition-public-finder.interface";
import { TaxDefinitionPublicModel } from '../models';
export class TaxDefinitionPublicFinder implements ITaxDefinitionPublicFinder {
public constructor(
private readonly deps: {
repository: ITaxDefinitionRepository;
mapper: TaxDefinitionPublicModelMapper;
}
) {}
public async findActiveByCode(params: FindActiveTaxDefinitionByCodeParams): Promise<Result<Maybe<TaxDefinitionPublicModel>, Error>> {
const result = await this.deps.repository.findActiveByCodeInCompany(params);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.data.map((taxDefinition) => this.deps.mapper.toPublicModel(taxDefinition)));
}
public async findActiveByCodes(params: FindActiveTaxDefinitionsByCodesParams): Promise<Result<Collection<TaxDefinitionPublicModel>, Error>> {
const result = await this.deps.repository.findActiveByCodesInCompany(params);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(new Collection(result.data.map((taxDefinition) => this.deps.mapper.toPublicModel(taxDefinition))));
}
public async ensureActiveByCodes(params: FindActiveTaxDefinitionsByCodesParams):Promise<Result<Collection<TaxDefinitionPublicModel>, Error>> {
const result = await this.findActiveByCodes(params);
if (result.isFailure) {
return Result.fail(result.error);
}
const requestedCodes = Array.from(
new Set(params.codes.getAll().map((code) => this.normalizeCode(code)))
);
const foundCodes = new Set(result.data.getAll().map((taxDefinition) => taxDefinition.code));
const missingCodes = requestedCodes.filter((code) => !foundCodes.has(code));
if (missingCodes.length > 0) {
return Result.fail(new TaxDefinitionsNotFoundError(new Collection(missingCodes)));
}
return result;
}
private normalizeCode(code: string): string {
return code.trim().toLowerCase();
}
}

View File

@ -1,6 +1,6 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { TaxDefinition, TaxDefinitionCode } from "../../../domain";
import type { TaxDefinitionSummary } from "../../tax-definitions/models";
@ -32,6 +32,20 @@ export interface ITaxDefinitionRepository {
transaction?: unknown
): Promise<Result<TaxDefinition, Error>>;
findActiveByCodeInCompany(params: {
companyId: UniqueID;
code: string;
atDate: UtcDate;
transaction?: unknown;
}): Promise<Result<Maybe<TaxDefinition>, Error>>;
findActiveByCodesInCompany(params: {
companyId: UniqueID;
codes: Collection<string>;
atDate: UtcDate;
transaction?: unknown;
}): Promise<Result<Collection<TaxDefinition>, Error>>;
findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,

View File

@ -1,5 +1,6 @@
export * from "./tax-definition-creator.service";
export * from "./tax-definition-deleter.service";
export * from "./tax-definition-finder.service";
export * from "./tax-definition-public-services";
export * from "./tax-definition-status-changer.service";
export * from "./tax-definition-updater.service";

View File

@ -0,0 +1,11 @@
import type { ITaxDefinitionPublicFinder } from "../public";
export interface ITaxDefinitionPublicServices {
finder: ITaxDefinitionPublicFinder;
}
export const buildTaxDefinitionsPublicServices = (
finder: ITaxDefinitionPublicFinder
): ITaxDefinitionPublicServices => ({
finder,
});

View File

@ -1,4 +1,3 @@
import type { TaxDefinitionDetailDTO } from "@erp/catalogs/common";
import type { ISnapshotBuilder } from "@erp/core/api";
import type { TaxDefinitionDetail } from "../../models";

View File

@ -1,5 +1,6 @@
export * from "./mappers";
export * from "./models";
export * from "./public";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";

View File

@ -0,0 +1 @@
export * from './tax-regime-not-found.error';

View File

@ -0,0 +1,10 @@
import { DomainError } from "@repo/rdx-ddd";
import type { Collection } from "@repo/rdx-utils";
export class TaxRegimesNotFoundError extends DomainError {
public readonly code = "TAX_REGIME_NOT_FOUND" as const;
public constructor(public readonly missingCodes: Collection<string>) {
super(`Tax regime not found for codes: ${missingCodes.getAll().join(", ")}`);
}
}

View File

@ -0,0 +1,14 @@
export { TaxRegimesNotFoundError } from "./errors/";
export { TaxRegimePublicModelMapper } from "./mappers";
export type {
TaxRegimePublicCalculationBehavior,
TaxRegimePublicFamily,
TaxRegimePublicModel,
TaxRegimePublicScope,
} from "./models/";
export type {
FindActiveTaxRegimeByCodeParams,
FindActiveTaxRegimesByCodesParams,
ITaxRegimePublicFinder,
} from "./services";
export { TaxRegimePublicFinder } from "./services/";

View File

@ -0,0 +1 @@
export * from './tax-regime-public-model.mapper';

View File

@ -0,0 +1,20 @@
import type { TaxRegime } from "../../../../domain/tax-regimes";
import type {
TaxRegimePublicModel
} from "../models/tax-regime-public.model";
export class TaxRegimePublicModelMapper {
public toPublicModel(TaxRegime: TaxRegime): TaxRegimePublicModel {
return {
id: TaxRegime.id,
companyId: TaxRegime.companyId,
code: TaxRegime.code.toPrimitive(),
description: TaxRegime.description.toPrimitive(),
isSystem: TaxRegime.isSystem,
isActive: TaxRegime.isActive,
};
}
}

View File

@ -0,0 +1 @@
export * from './tax-regime-public.model';

View File

@ -0,0 +1,20 @@
import type { UniqueID } from "@repo/rdx-ddd";
/**
* Modelo público de lectura expuesto por el módulo `catalogs`.
*
* Es un contrato backend entre módulos, no un DTO HTTP y no una entidad
* de dominio. Puede usar Value Objects comunes del ERP, pero no debe
* exponer Value Objects internos de `tax-regimes`.
*/
export interface TaxRegimePublicModel {
id: UniqueID;
companyId: UniqueID;
code: string;
description: string;
isSystem: boolean;
isActive: boolean;
}

View File

@ -0,0 +1,2 @@
export * from './tax-regime-public-finder.interface';
export * from './tax-regime-public-finder';

View File

@ -0,0 +1,24 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { TaxRegimePublicModel } from "../models";
export interface FindTaxRegimeByCodeInCompanyParams {
companyId: UniqueID;
code: string;
transaction?: unknown;
}
export interface ITaxRegimePublicFinder {
existsByCodeInCompany(
params: FindTaxRegimeByCodeInCompanyParams,
): Promise<Result<boolean, Error>>;
getByCodeInCompany(
params: FindTaxRegimeByCodeInCompanyParams,
): Promise<Result<TaxRegimePublicModel, Error>>;
findByCodeInCompany(
params: FindTaxRegimeByCodeInCompanyParams,
): Promise<Result<Maybe<TaxRegimePublicModel>, Error>>;
}

View File

@ -0,0 +1,96 @@
import { Maybe, Result } from "@repo/rdx-utils";
import { TaxRegimeCode } from "../../../../domain/tax-regimes";
import type { ITaxRegimeRepository } from "../../repositories";
import type { TaxRegimePublicModelMapper } from "../mappers/tax-regime-public-model.mapper";
import type { TaxRegimePublicModel } from "../models";
import type {
FindTaxRegimeByCodeInCompanyParams,
ITaxRegimePublicFinder,
} from "./tax-regime-public-finder.interface";
export class TaxRegimePublicFinder implements ITaxRegimePublicFinder {
public constructor(
private readonly deps: {
repository: ITaxRegimeRepository;
mapper: TaxRegimePublicModelMapper;
},
) {}
public async existsByCodeInCompany(
params: FindTaxRegimeByCodeInCompanyParams,
): Promise<Result<boolean, Error>> {
const code = TaxRegimeCode.create(params.code);
if (code.isFailure) {
return Result.fail(code.error);
}
const result = await this.deps.repository.existsByCodeInCompany(
params.companyId,
code.data,
params.transaction,
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.data);
}
public async getByCodeInCompany(
params: FindTaxRegimeByCodeInCompanyParams,
): Promise<Result<TaxRegimePublicModel, Error>> {
const code = TaxRegimeCode.create(params.code);
if (code.isFailure) {
return Result.fail(code.error);
}
const result = await this.deps.repository.getByCodeInCompany(
params.companyId,
code.data,
params.transaction,
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(this.deps.mapper.toPublicModel(result.data));
}
/**
* Busca un régimen fiscal por código dentro de una empresa.
*
* No falla si el régimen fiscal no existe. En ese caso devuelve `Maybe.none()`.
* Usar `getByCodeInCompany` cuando la ausencia deba tratarse como error.
*/
public async findByCodeInCompany(
params: FindTaxRegimeByCodeInCompanyParams,
): Promise<Result<Maybe<TaxRegimePublicModel>, Error>> {
const code = TaxRegimeCode.create(params.code);
if (code.isFailure) {
return Result.fail(code.error);
}
const result = await this.deps.repository.findByCodeInCompany(
params.companyId,
code.data,
params.transaction,
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(
result.data.match(
(taxRegime) => Maybe.some(this.deps.mapper.toPublicModel(taxRegime)),
() => Maybe.none<TaxRegimePublicModel>(),
),
);
}
}

View File

@ -1,6 +1,6 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { TaxRegime, TaxRegimeCode } from "../../../domain";
import type { TaxRegimeSummary } from "../models";
@ -20,6 +20,12 @@ export interface ITaxRegimeRepository {
transaction?: unknown
): Promise<Result<boolean, Error>>;
existsByCodeInCompany(
companyId: UniqueID,
code: TaxRegimeCode,
transaction?: unknown
): Promise<Result<boolean, Error>>;
getByIdInCompany(
companyId: UniqueID,
id: UniqueID,
@ -32,6 +38,12 @@ export interface ITaxRegimeRepository {
transaction?: unknown
): Promise<Result<TaxRegime, Error>>;
findByCodeInCompany(
companyId: UniqueID,
code: TaxRegimeCode,
transaction?: unknown
): Promise<Result<Maybe<TaxRegime>, Error>>;
findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,

View File

@ -61,24 +61,6 @@ export const isInvalidTaxDefinitionCalculationBehaviorError = (
): e is InvalidTaxDefinitionCalculationBehaviorError =>
e instanceof InvalidTaxDefinitionCalculationBehaviorError;
export class InvalidTaxDefinitionJurisdictionCountryCodeError extends DomainError {
public readonly code = "TAX_DEFINITION_INVALID_JURISDICTION_COUNTRY_CODE" as const;
}
export const isInvalidTaxDefinitionJurisdictionCountryCodeError = (
e: unknown
): e is InvalidTaxDefinitionJurisdictionCountryCodeError =>
e instanceof InvalidTaxDefinitionJurisdictionCountryCodeError;
export class InvalidTaxDefinitionJurisdictionRegionCodeError extends DomainError {
public readonly code = "TAX_DEFINITION_INVALID_JURISDICTION_REGION_CODE" as const;
}
export const isInvalidTaxDefinitionJurisdictionRegionCodeError = (
e: unknown
): e is InvalidTaxDefinitionJurisdictionRegionCodeError =>
e instanceof InvalidTaxDefinitionJurisdictionRegionCodeError;
export class InvalidTaxDefinitionScopeError extends DomainError {
public readonly code = "TAX_DEFINITION_INVALID_SCOPE" as const;
}

View File

@ -1,7 +1,5 @@
export * from "./calculation-behavior";
export * from "./errors";
export * from "./jurisdiction-country-code";
export * from "./jurisdiction-region-code";
export * from "./tax-definition.aggregate";
export * from "./tax-definition-code";
export * from "./tax-definition-name";

View File

@ -1,41 +0,0 @@
import { Result } from "@repo/rdx-utils";
import { InvalidTaxDefinitionJurisdictionCountryCodeError } from "./errors";
export class TaxJurisdictionCountryCode {
private constructor(private readonly value: string) {}
public static create(code: string): Result<TaxJurisdictionCountryCode, Error> {
const trimmed = code?.trim() ?? "";
if (trimmed.length !== 2) {
return Result.fail(
new InvalidTaxDefinitionJurisdictionCountryCodeError("Country code must be 2 letters")
);
}
const normalized = trimmed.toUpperCase();
const regex = /^[A-Z]{2}$/;
if (!regex.test(normalized)) {
return Result.fail(
new InvalidTaxDefinitionJurisdictionCountryCodeError(
"Country code must be 2 uppercase letters"
)
);
}
// NOTE: We allow two-letter codes including 'EU' to support union-level definitions like reverse charge.
return Result.ok(new TaxJurisdictionCountryCode(normalized));
}
public static fromPersistence(code: string): TaxJurisdictionCountryCode {
return new TaxJurisdictionCountryCode(code);
}
public toPrimitive(): string {
return this.value;
}
public toString(): string {
return this.value;
}
}

View File

@ -1,42 +0,0 @@
import { Result } from "@repo/rdx-utils";
import { InvalidTaxDefinitionJurisdictionRegionCodeError } from "./errors";
export class TaxJurisdictionRegionCode {
private constructor(private readonly value: string) {}
public static create(code: string): Result<TaxJurisdictionRegionCode, Error> {
const trimmed = code?.trim() ?? "";
if (trimmed.length === 0) {
return Result.fail(
new InvalidTaxDefinitionJurisdictionRegionCodeError("Region code cannot be empty")
);
}
const normalized = trimmed.toUpperCase();
// basic pattern: CC-... (we won't validate full ISO-3166-2 list)
const regex = /^[A-Z]{2}-[A-Z0-9-]+$/;
if (!regex.test(normalized)) {
return Result.fail(
new InvalidTaxDefinitionJurisdictionRegionCodeError(
"Region code must follow ISO-3166-2 pattern COUNTRY-REGION"
)
);
}
return Result.ok(new TaxJurisdictionRegionCode(normalized));
}
public static fromPersistence(code: string): TaxJurisdictionRegionCode {
return new TaxJurisdictionRegionCode(code);
}
public toPrimitive(): string {
return this.value;
}
public toString(): string {
return this.value;
}
}

View File

@ -1,14 +1,18 @@
import { AggregateRoot, type TextValue, type UniqueID, type UtcDate } from "@repo/rdx-ddd";
import {
AggregateRoot,
type CountryCode,
type CountryRegionCode,
type TextValue,
type UniqueID,
type UtcDate,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { TaxCalculationBehavior } from "./calculation-behavior";
import {
InvalidTaxDefinitionAllowedSurchargeCodesError,
InvalidTaxDefinitionJurisdictionRegionCodeError,
InvalidTaxDefinitionValidityPeriodError,
} from "./errors";
import type { TaxJurisdictionCountryCode } from "./jurisdiction-country-code";
import type { TaxJurisdictionRegionCode } from "./jurisdiction-region-code";
import type { TaxDefinitionCode } from "./tax-definition-code";
import type { TaxDefinitionName } from "./tax-definition-name";
import type { TaxFamily } from "./tax-family";
@ -23,8 +27,8 @@ export interface ITaxDefinitionCreateProps {
rate: TaxRate;
taxFamily: TaxFamily;
calculationBehavior: TaxCalculationBehavior;
jurisdictionCountryCode: TaxJurisdictionCountryCode;
jurisdictionRegionCode: Maybe<TaxJurisdictionRegionCode>;
jurisdictionCountryCode: CountryCode;
jurisdictionRegionCode: Maybe<CountryRegionCode>;
taxScope: TaxScope;
invoiceNote: Maybe<TextValue>; // Texto fiscal que aparece en el documento
allowedSurchargeCodes: Maybe<TaxDefinitionCode[]>;
@ -146,19 +150,6 @@ export class TaxDefinition extends AggregateRoot<TaxDefinitionInternalProps> {
}
}
// region coherence with country
if (props.jurisdictionRegionCode && props.jurisdictionRegionCode.isSome()) {
const region = props.jurisdictionRegionCode.unwrap().toPrimitive();
const country = props.jurisdictionCountryCode.toPrimitive();
if (!region.startsWith(`${country}-`)) {
return Result.fail(
new InvalidTaxDefinitionJurisdictionRegionCodeError(
"Region code must start with country code"
)
);
}
}
return Result.ok();
}
@ -198,11 +189,11 @@ export class TaxDefinition extends AggregateRoot<TaxDefinitionInternalProps> {
return this.props.calculationBehavior;
}
public get jurisdictionCountryCode(): TaxJurisdictionCountryCode {
public get jurisdictionCountryCode(): CountryCode {
return this.props.jurisdictionCountryCode;
}
public get jurisdictionRegionCode(): Maybe<TaxJurisdictionRegionCode> {
public get jurisdictionRegionCode(): Maybe<CountryRegionCode> {
return this.props.jurisdictionRegionCode;
}

View File

@ -5,6 +5,8 @@ import {
paymentMethodsRouter,
paymentTermModels,
paymentTermsRouter,
taxDefinitionModels,
taxDefinitionsRouter,
taxRegimeModels,
taxRegimesRouter,
} from "./infrastructure";
@ -13,10 +15,14 @@ import {
buildCatalogsPublicServices,
} from "./infrastructure/di/catalogs.di";
export * from "./application/services/catalog-public-services.interface"; // <- exportamos la interfaz de los servicios públicos para que otros módulos puedan usarla en sus dependencias
export * from "./application/payment-methods/public";
export * from "./application/tax-definitions/public";
export * from "./application/tax-regimes/public";
//export * from "./infrastructure/payment-methods/persistence/sequelize"; <- ???
export type CatalogsPublicServicesType = ReturnType<typeof buildCatalogsPublicServices>;
export const catalogsAPIModule: IModuleServer = {
name: "catalogs",
version: "1.0.0",
@ -44,13 +50,16 @@ export const catalogsAPIModule: IModuleServer = {
return {
// Modelos Sequelize del módulo
models: [...paymentMethodModels, ...paymentTermModels, ...taxRegimeModels],
models: [
...paymentMethodModels,
...paymentTermModels,
...taxDefinitionModels,
...taxRegimeModels,
],
// Servicios expuestos a otros módulos
services: {
paymentMethods: publicServices.paymentMethods,
paymentTerms: publicServices.paymentTerms,
taxRegimes: publicServices.taxRegimes,
...publicServices,
},
// Implementación privada del módulo
@ -72,6 +81,7 @@ export const catalogsAPIModule: IModuleServer = {
paymentMethodsRouter(params);
paymentTermsRouter(params);
taxRegimesRouter(params);
taxDefinitionsRouter(params);
logger.info("🚀 Catalogs module started", {
label: this.name,

View File

@ -10,6 +10,11 @@ import {
buildPaymentTermsDependencies,
buildPaymentTermsPublicServices,
} from "../payment-terms/di";
import {
type TaxDefinitionsInternalDeps,
buildTaxDefinitionsDependencies,
buildTaxDefinitionsPublicServices,
} from "../tax-definitions/di";
import {
type TaxRegimesInternalDeps,
buildTaxRegimesDependencies,
@ -19,6 +24,7 @@ import {
export type CatalogsInternalDeps = {
paymentMethods: PaymentMethodsInternalDeps;
paymentTerms: PaymentTermsInternalDeps;
taxDefinitions: TaxDefinitionsInternalDeps;
taxRegimes: TaxRegimesInternalDeps;
};
@ -26,6 +32,7 @@ export const buildCatalogsDependencies = (params: ModuleParams): CatalogsInterna
return {
paymentMethods: buildPaymentMethodsDependencies(params),
paymentTerms: buildPaymentTermsDependencies(params),
taxDefinitions: buildTaxDefinitionsDependencies(params),
taxRegimes: buildTaxRegimesDependencies(params),
};
};
@ -34,6 +41,7 @@ export const buildCatalogsPublicServices = (params: SetupParams, deps: CatalogsI
return {
paymentMethods: buildPaymentMethodsPublicServices(params, deps.paymentMethods),
paymentTerms: buildPaymentTermsPublicServices(params, deps.paymentTerms),
taxDefinitions: buildTaxDefinitionsPublicServices(params, deps.taxDefinitions),
taxRegimes: buildTaxRegimesPublicServices(params, deps.taxRegimes),
};
};

View File

@ -6,7 +6,7 @@ import {
} from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import { type Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import type { IPaymentMethodRepository } from "../../../../../application";
@ -145,6 +145,35 @@ export class SequelizePaymentMethodRepository
}
}
async findByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: Transaction
): Promise<Result<Maybe<PaymentMethod>, Error>> {
try {
const row = await PaymentMethodModel.findOne({
where: {
id: id.toString(),
company_id: companyId.toString(),
},
transaction,
});
if (!row) {
return Result.ok(Maybe.none<PaymentMethod>());
}
const mappedResult = this.domainMapper.mapToDomain(row);
if (mappedResult.isFailure) {
return Result.fail(mappedResult.error);
}
return Result.ok(Maybe.some(mappedResult.data));
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
* Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
*

View File

@ -1,2 +1,3 @@
export * from "./tax-definition-persistence-mappers.di";
export * from "./tax-definition-repositories.di";
export * from "./tax-definitions.di";

View File

@ -0,0 +1,125 @@
import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api";
import type { Sequelize } from "sequelize";
import {
CreateTaxDefinitionInputMapper,
CreateTaxDefinitionUseCase,
DeleteTaxDefinitionByIdUseCase,
DisableTaxDefinitionByIdUseCase,
EnableTaxDefinitionByIdUseCase,
GetTaxDefinitionByIdUseCase,
type ITaxDefinitionPublicFinder,
type ITaxDefinitionRepository,
ListTaxDefinitionsUseCase,
TaxDefinitionCreator,
TaxDefinitionDeleter,
TaxDefinitionFinder,
TaxDefinitionFullSnapshotBuilder,
TaxDefinitionPublicFinder,
TaxDefinitionPublicModelMapper,
TaxDefinitionStatusChanger,
TaxDefinitionSummarySnapshotBuilder,
TaxDefinitionUpdater,
UpdateTaxDefinitionByIdInputMapper,
UpdateTaxDefinitionByIdUseCase,
} from "../../../application";
import { buildTaxDefinitionPersistenceMappers } from "./tax-definition-persistence-mappers.di";
import { buildTaxDefinitionRepository } from "./tax-definition-repositories.di";
export type TaxDefinitionsInternalDeps = {
repository: ITaxDefinitionRepository;
useCases: {
listTaxDefinitions: () => ListTaxDefinitionsUseCase;
getTaxDefinitionById: () => GetTaxDefinitionByIdUseCase;
createTaxDefinition: () => CreateTaxDefinitionUseCase;
updateTaxDefinitionById: () => UpdateTaxDefinitionByIdUseCase;
deleteTaxDefinitionById: () => DeleteTaxDefinitionByIdUseCase;
disableTaxDefinitionById: () => DisableTaxDefinitionByIdUseCase;
enableTaxDefinitionById: () => EnableTaxDefinitionByIdUseCase;
};
};
export const buildTaxDefinitionsDependencies = (
params: ModuleParams
): TaxDefinitionsInternalDeps => {
const { database } = params;
const transactionManager = buildTransactionManager(database as Sequelize);
const persistenceMappers = buildTaxDefinitionPersistenceMappers();
const repository = buildTaxDefinitionRepository({ database, mappers: persistenceMappers });
const finder = new TaxDefinitionFinder(repository);
const creator = new TaxDefinitionCreator(repository);
const updater = new TaxDefinitionUpdater(repository);
const deleter = new TaxDefinitionDeleter(repository);
const statusChanger = new TaxDefinitionStatusChanger(repository);
const createInputMapper = new CreateTaxDefinitionInputMapper();
const updateInputMapper = new UpdateTaxDefinitionByIdInputMapper();
const fullSnapshotBuilder = new TaxDefinitionFullSnapshotBuilder();
const summarySnapshotBuilder = new TaxDefinitionSummarySnapshotBuilder();
return {
repository,
useCases: {
listTaxDefinitions: () =>
new ListTaxDefinitionsUseCase({
repository,
summarySnapshotBuilder,
}),
getTaxDefinitionById: () =>
new GetTaxDefinitionByIdUseCase({
finder,
fullSnapshotBuilder,
}),
createTaxDefinition: () =>
new CreateTaxDefinitionUseCase({
dtoMapper: createInputMapper,
creator,
fullSnapshotBuilder,
transactionManager,
}),
updateTaxDefinitionById: () =>
new UpdateTaxDefinitionByIdUseCase({
dtoMapper: updateInputMapper,
updater,
fullSnapshotBuilder,
}),
deleteTaxDefinitionById: () =>
new DeleteTaxDefinitionByIdUseCase({
deleter,
}),
disableTaxDefinitionById: () =>
new DisableTaxDefinitionByIdUseCase({
statusChanger,
}),
enableTaxDefinitionById: () =>
new EnableTaxDefinitionByIdUseCase({
statusChanger,
}),
},
};
};
export const buildTaxDefinitionsPublicServices = (
_params: SetupParams,
deps: TaxDefinitionsInternalDeps
): { finder: ITaxDefinitionPublicFinder } => {
const mapper = new TaxDefinitionPublicModelMapper();
return {
finder: new TaxDefinitionPublicFinder({
repository: deps.repository,
mapper,
}),
};
};

View File

@ -0,0 +1,39 @@
import type { CreateTaxDefinitionRequestDTO } from "@erp/catalogs/common";
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { CreateTaxDefinitionUseCase } from "../../../../application/tax-definitions";
import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper";
export class CreateTaxDefinitionController extends ExpressController {
constructor(private readonly useCase: CreateTaxDefinitionUseCase) {
super();
this.errorMapper = taxDefinitionsApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const dto = this.req.body satisfies CreateTaxDefinitionRequestDTO;
const result = await this.useCase.execute({ dto, companyId });
return result.match(
(data: unknown) => this.created(data),
(err: Error) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,38 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { DeleteTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions";
import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper";
export class DeleteTaxDefinitionByIdController extends ExpressController {
constructor(private readonly useCase: DeleteTaxDefinitionByIdUseCase) {
super();
this.errorMapper = taxDefinitionsApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { tax_definition_id } = this.req.params;
const result = await this.useCase.execute(companyId, tax_definition_id as any);
return result.match(
(data: unknown) => this.ok(data),
(err: Error) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,38 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { DisableTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions";
import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper";
export class DisableTaxDefinitionByIdController extends ExpressController {
constructor(private readonly useCase: DisableTaxDefinitionByIdUseCase) {
super();
this.errorMapper = taxDefinitionsApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { tax_definition_id } = this.req.params;
const result = await this.useCase.execute(companyId, tax_definition_id as any);
return result.match(
(data: unknown) => this.ok(data),
(err: Error) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,38 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { EnableTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions";
import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper";
export class EnableTaxDefinitionByIdController extends ExpressController {
constructor(private readonly useCase: EnableTaxDefinitionByIdUseCase) {
super();
this.errorMapper = taxDefinitionsApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { tax_definition_id } = this.req.params;
const result = await this.useCase.execute(companyId, tax_definition_id as any);
return result.match(
(data: unknown) => this.ok(data),
(err: Error) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,38 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { GetTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions";
import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper";
export class GetTaxDefinitionByIdController extends ExpressController {
constructor(private readonly useCase: GetTaxDefinitionByIdUseCase) {
super();
this.errorMapper = taxDefinitionsApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { tax_definition_id } = this.req.params;
const result = await this.useCase.execute(companyId, tax_definition_id as any);
return result.match(
(data) => this.ok(data),
(err: Error) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,7 @@
export * from "./create-tax-definition.controller";
export * from "./delete-tax-definition-by-id.controller";
export * from "./disable-tax-definition-by-id.controller";
export * from "./enable-tax-definition-by-id.controller";
export * from "./get-tax-definition-by-id.controller";
export * from "./list-tax-definitions.controller";
export * from "./update-tax-definition-by-id.controller";

View File

@ -0,0 +1,54 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import { Criteria } from "@repo/rdx-criteria/server";
import type { ListTaxDefinitionsUseCase } from "../../../../application/tax-definitions";
import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper";
export class ListTaxDefinitionsController extends ExpressController {
constructor(private readonly useCase: ListTaxDefinitionsUseCase) {
super();
this.errorMapper = taxDefinitionsApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
private getCriteriaWithDefaultOrder() {
if (this.criteria.hasOrder()) {
return this.criteria;
}
const { q: quicksearch, filters, pageSize, pageNumber } = this.criteria.toPrimitives();
return Criteria.fromPrimitives(filters, "code", "ASC", pageSize, pageNumber, quicksearch);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const criteria = this.getCriteriaWithDefaultOrder();
const result = await this.useCase.execute(companyId, criteria);
return result.match(
(data: any) =>
this.ok(data, {
"X-Total-Count": String(data.total_items),
"Pagination-Count": String(data.total_pages),
"Pagination-Page": String(data.page),
"Pagination-Limit": String(data.per_page),
}),
(err: Error) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,44 @@
import type { UpdateTaxDefinitionByIdRequestDTO } from "@erp/catalogs/common";
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { UpdateTaxDefinitionByIdUseCase } from "../../../../application/tax-definitions";
import { taxDefinitionsApiErrorMapper } from "../tax-definitions-api-error-mapper";
export class UpdateTaxDefinitionByIdController extends ExpressController {
constructor(private readonly useCase: UpdateTaxDefinitionByIdUseCase) {
super();
this.errorMapper = taxDefinitionsApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { tax_definition_id } = this.req.params;
if (!tax_definition_id) {
return this.invalidInputError("Tax definition ID missing");
}
const dto = this.req.body as UpdateTaxDefinitionByIdRequestDTO;
const result = await this.useCase.execute(companyId, { id: tax_definition_id as any, dto });
return result.match(
(data: unknown) => this.ok(data),
(err: Error) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,2 @@
export * from "./controllers";
export * from "./tax-definitions.routes";

View File

@ -0,0 +1,186 @@
import {
ApiErrorMapper,
ConflictApiError,
EntityNotFoundError,
type ErrorToApiRule,
NotFoundApiError,
ValidationApiError,
} from "@erp/core/api";
import {
type InvalidTaxDefinitionAllowedSurchargeCodesError,
type InvalidTaxDefinitionCalculationBehaviorError,
type InvalidTaxDefinitionCodeError,
type InvalidTaxDefinitionDescriptionError,
type InvalidTaxDefinitionFamilyError,
type InvalidTaxDefinitionIdError,
type InvalidTaxDefinitionInvoiceNoteError,
type InvalidTaxDefinitionNameError,
type InvalidTaxDefinitionRateError,
type InvalidTaxDefinitionScopeError,
type InvalidTaxDefinitionValidityPeriodError,
type TaxDefinitionCannotBeDisabledError,
type TaxDefinitionCannotBeEnabledError,
isInvalidTaxDefinitionAllowedSurchargeCodesError,
isInvalidTaxDefinitionCalculationBehaviorError,
isInvalidTaxDefinitionCodeError,
isInvalidTaxDefinitionDescriptionError,
isInvalidTaxDefinitionFamilyError,
isInvalidTaxDefinitionIdError,
isInvalidTaxDefinitionInvoiceNoteError,
isInvalidTaxDefinitionNameError,
isInvalidTaxDefinitionRateError,
isInvalidTaxDefinitionScopeError,
isInvalidTaxDefinitionValidityPeriodError,
isTaxDefinitionCannotBeDisabledError,
isTaxDefinitionCannotBeEnabledError,
} from "../../../domain/tax-definitions";
const invalidTaxDefinitionIdRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionIdError,
build: (error) =>
new ConflictApiError(
(error as InvalidTaxDefinitionIdError).message ||
"Tax definition with the provided id already exists."
),
};
const invalidTaxDefinitionCodeRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionCodeError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionCodeError).message || "Tax definition code is invalid."
),
};
const invalidTaxDefinitionNameRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionNameError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionNameError).message || "Tax definition name is invalid."
),
};
const invalidTaxDefinitionDescriptionRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionDescriptionError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionDescriptionError).message ||
"Tax definition description is invalid."
),
};
const invalidTaxDefinitionRateRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionRateError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionRateError).message || "Tax definition rate is invalid."
),
};
const invalidTaxDefinitionFamilyRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionFamilyError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionFamilyError).message || "Tax definition family is invalid."
),
};
const invalidTaxDefinitionCalculationBehaviorRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionCalculationBehaviorError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionCalculationBehaviorError).message ||
"Tax definition calculation behavior is invalid."
),
};
const invalidTaxDefinitionScopeRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionScopeError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionScopeError).message || "Tax definition scope is invalid."
),
};
const invalidTaxDefinitionInvoiceNoteRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionInvoiceNoteError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionInvoiceNoteError).message ||
"Tax definition invoice note is invalid."
),
};
const invalidTaxDefinitionAllowedSurchargeCodesRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionAllowedSurchargeCodesError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionAllowedSurchargeCodesError).message ||
"Tax definition allowed surcharge codes are invalid."
),
};
const invalidTaxDefinitionValidityPeriodRule: ErrorToApiRule = {
priority: 120,
matches: isInvalidTaxDefinitionValidityPeriodError,
build: (error) =>
new ValidationApiError(
(error as InvalidTaxDefinitionValidityPeriodError).message ||
"Tax definition validity period is invalid."
),
};
const taxDefinitionNotFoundRule: ErrorToApiRule = {
priority: 120,
matches: (error) =>
error instanceof EntityNotFoundError &&
((error as EntityNotFoundError).message.includes("TaxDefinition") ||
(error as EntityNotFoundError).message.includes("Tax definition")),
build: (error) =>
new NotFoundApiError((error as EntityNotFoundError).message || "Tax definition not found."),
};
const taxDefinitionCannotBeDisabledRule: ErrorToApiRule = {
priority: 120,
matches: isTaxDefinitionCannotBeDisabledError,
build: (error) =>
new ValidationApiError(
(error as TaxDefinitionCannotBeDisabledError).message || "Tax definition cannot be disabled."
),
};
const taxDefinitionCannotBeEnabledRule: ErrorToApiRule = {
priority: 120,
matches: isTaxDefinitionCannotBeEnabledError,
build: (error) =>
new ValidationApiError(
(error as TaxDefinitionCannotBeEnabledError).message || "Tax definition cannot be enabled."
),
};
export const taxDefinitionsApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(invalidTaxDefinitionIdRule)
.register(invalidTaxDefinitionCodeRule)
.register(invalidTaxDefinitionNameRule)
.register(invalidTaxDefinitionDescriptionRule)
.register(invalidTaxDefinitionRateRule)
.register(invalidTaxDefinitionFamilyRule)
.register(invalidTaxDefinitionCalculationBehaviorRule)
.register(invalidTaxDefinitionScopeRule)
.register(invalidTaxDefinitionInvoiceNoteRule)
.register(invalidTaxDefinitionAllowedSurchargeCodesRule)
.register(invalidTaxDefinitionValidityPeriodRule)
.register(taxDefinitionNotFoundRule)
.register(taxDefinitionCannotBeDisabledRule)
.register(taxDefinitionCannotBeEnabledRule);

View File

@ -0,0 +1,120 @@
import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api";
import { type RequestWithAuth, type StartParams, validateRequest } from "@erp/core/api";
import type { NextFunction, Request, Response } from "express";
import { Router } from "express";
import {
CreateTaxDefinitionRequestSchema,
DeleteTaxDefinitionByIdRequestSchema,
GetTaxDefinitionByIdRequestSchema,
ListTaxDefinitionsRequestSchema,
UpdateTaxDefinitionByIdParamsRequestSchema,
UpdateTaxDefinitionByIdRequestSchema,
} from "../../../../common";
import type { CatalogsInternalDeps } from "../../di";
import {
CreateTaxDefinitionController,
DeleteTaxDefinitionByIdController,
DisableTaxDefinitionByIdController,
EnableTaxDefinitionByIdController,
GetTaxDefinitionByIdController,
ListTaxDefinitionsController,
UpdateTaxDefinitionByIdController,
} from "./controllers";
export const taxDefinitionsRouter = (params: StartParams) => {
const { app, config, getInternal } = params;
const deps = getInternal<CatalogsInternalDeps>("catalogs").taxDefinitions;
const router = Router({ mergeParams: true });
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
router.use((req: Request, res: Response, next: NextFunction) =>
mockUser(req as RequestWithAuth, res, next)
);
}
router.use([
(req: Request, res: Response, next: NextFunction) =>
requireAuthenticated()(req as RequestWithAuth, res, next),
(req: Request, res: Response, next: NextFunction) =>
requireCompanyContext()(req as RequestWithAuth, res, next),
]);
router.get(
"/",
validateRequest(ListTaxDefinitionsRequestSchema, "query"),
(req, res, next) => {
const controller = new ListTaxDefinitionsController(deps.useCases.listTaxDefinitions());
return controller.execute(req, res, next);
}
);
router.post(
"/",
validateRequest(CreateTaxDefinitionRequestSchema, "body"),
(req, res, next) => {
const controller = new CreateTaxDefinitionController(deps.useCases.createTaxDefinition());
return controller.execute(req, res, next);
}
);
router.get(
"/:tax_definition_id",
validateRequest(GetTaxDefinitionByIdRequestSchema, "params"),
(req, res, next) => {
const controller = new GetTaxDefinitionByIdController(
deps.useCases.getTaxDefinitionById()
);
return controller.execute(req, res, next);
}
);
router.delete(
"/:tax_definition_id",
validateRequest(DeleteTaxDefinitionByIdRequestSchema, "params"),
(req, res, next) => {
const controller = new DeleteTaxDefinitionByIdController(
deps.useCases.deleteTaxDefinitionById()
);
return controller.execute(req, res, next);
}
);
router.put(
"/:tax_definition_id",
validateRequest(UpdateTaxDefinitionByIdParamsRequestSchema, "params"),
validateRequest(UpdateTaxDefinitionByIdRequestSchema, "body"),
(req, res, next) => {
const controller = new UpdateTaxDefinitionByIdController(
deps.useCases.updateTaxDefinitionById()
);
return controller.execute(req, res, next);
}
);
router.patch(
"/:tax_definition_id/disable",
validateRequest(GetTaxDefinitionByIdRequestSchema, "params"),
(req, res, next) => {
const controller = new DisableTaxDefinitionByIdController(
deps.useCases.disableTaxDefinitionById()
);
return controller.execute(req, res, next);
}
);
router.patch(
"/:tax_definition_id/enable",
validateRequest(GetTaxDefinitionByIdRequestSchema, "params"),
(req, res, next) => {
const controller = new EnableTaxDefinitionByIdController(
deps.useCases.enableTaxDefinitionById()
);
return controller.execute(req, res, next);
}
);
app.use(`${config.server.apiBasePath}/catalogs/tax-definitions`, router);
};

View File

@ -1,2 +1,3 @@
export * from "./express";
export * from "./di";
export * from "./persistence";

View File

@ -1,5 +1,7 @@
import { SequelizeQueryMapper } from "@erp/core/api";
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
CountryCode,
CountryRegionCode,
TextValue,
UniqueID,
ValidationErrorCollection,
@ -15,18 +17,20 @@ import {
TaxDefinitionCode as TaxDefinitionCodeVO,
TaxDefinitionName as TaxDefinitionNameVO,
TaxFamily,
TaxJurisdictionCountryCode,
TaxJurisdictionRegionCode,
TaxRate as TaxRateVO,
TaxScope,
} from "../../../../../domain";
import type { TaxDefinitionModel } from "../models";
import type { TaxDefinitionCreationAttributes, TaxDefinitionModel } from "../models";
export class SequelizeTaxDefinitionDomainMapper extends SequelizeQueryMapper<
export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
TaxDefinitionModel,
TaxDefinitionCreationAttributes,
TaxDefinition
> {
public mapToDomain(raw: TaxDefinitionModel): Result<TaxDefinition, Error> {
public mapToDomain(
raw: TaxDefinitionModel,
params?: MapperParamsType
): Result<TaxDefinition, Error> {
const errors: ValidationErrorDetail[] = [];
const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors);
@ -51,13 +55,13 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeQueryMapper<
);
const jurisdictionCountryCode = extractOrPushError(
TaxJurisdictionCountryCode.create(raw.jurisdiction_country_code),
CountryCode.create(raw.jurisdiction_country_code),
"jurisdiction_country_code",
errors
);
const jurisdictionRegionCode = maybeFromNullableResult(raw.jurisdiction_region_code, (v) =>
TaxJurisdictionRegionCode.create(v)
CountryRegionCode.create(v)
);
const taxScope = extractOrPushError(TaxScope.create(raw.tax_scope), "tax_scope", errors);
@ -69,9 +73,10 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeQueryMapper<
const arr: any[] = [];
for (const el of v) {
const r = TaxDefinitionCodeVO.create(String(el));
if (r.isFailure) return Result.fail(new Error("Invalid allowed_surcharge_codes element"));
arr.push(r.getValue());
const _result = TaxDefinitionCodeVO.create(String(el));
if (_result.isFailure)
return Result.fail(new Error("Invalid allowed_surcharge_codes element"));
arr.push(_result.data);
}
return Result.ok(arr);
@ -112,46 +117,51 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeQueryMapper<
return Result.ok(domainOrError);
}
public mapToPersistence(domain: TaxDefinition): Result<Record<string, unknown>, Error> {
const dto: Record<string, unknown> = {
id: domain.id.toPrimitive(),
company_id: domain.companyId.toPrimitive(),
code: domain.code.toPrimitive(),
name: domain.name.toPrimitive(),
description: domain.description.match(
public mapToPersistence(
source: TaxDefinition,
params?: MapperParamsType
): Result<TaxDefinitionCreationAttributes, Error> {
const dto: TaxDefinitionCreationAttributes = {
id: source.id.toPrimitive(),
company_id: source.companyId.toPrimitive(),
code: source.code.toPrimitive(),
name: source.name.toPrimitive(),
description: source.description.match(
(v) => v.toPrimitive(),
() => null
),
rate_value: domain.rate.toPrimitive(),
rate_scale: (domain.rate as any).scale ?? 2,
tax_family: domain.taxFamily.toPrimitive(),
calculation_behavior: domain.calculationBehavior.toPrimitive(),
jurisdiction_country_code: domain.jurisdictionCountryCode.toPrimitive(),
jurisdiction_region_code: domain.jurisdictionRegionCode.match(
rate_value: source.rate.toPrimitive(),
rate_scale: (source.rate as any).scale ?? 2,
tax_family: source.taxFamily.toPrimitive(),
calculation_behavior: source.calculationBehavior.toPrimitive(),
jurisdiction_country_code: source.jurisdictionCountryCode.toPrimitive(),
jurisdiction_region_code: source.jurisdictionRegionCode.match(
(v) => v.toPrimitive(),
() => null
),
tax_scope: domain.taxScope.toPrimitive(),
invoice_note: domain.invoiceNote.match(
tax_scope: source.taxScope.toPrimitive(),
invoice_note: source.invoiceNote.match(
(v) => v.toPrimitive(),
() => null
),
allowed_surcharge_codes: domain.allowedSurchargeCodes.match(
allowed_surcharge_codes: source.allowedSurchargeCodes.match(
(arr) => arr.map((c) => c.toPrimitive()),
() => null
),
is_system: domain.isSystem,
is_active: domain.isActive,
valid_from: domain.validFrom.match(
is_system: source.isSystem,
is_active: source.isActive,
valid_from: source.validFrom.match(
(d) => d.toPrimitive(),
() => null
),
valid_to: domain.validTo.match(
valid_to: source.validTo.match(
(d) => d.toPrimitive(),
() => null
),
};
return Result.ok(dto);
return Result.ok<TaxDefinitionCreationAttributes>(
dto satisfies TaxDefinitionCreationAttributes
);
}
}

View File

@ -0,0 +1 @@
export * from "./sequelize-tax-definition.model";

View File

@ -5,9 +5,9 @@ import {
translateSequelizeError,
} from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import { Op, type Sequelize, type Transaction, type WhereOptions } from "sequelize";
import type { ITaxDefinitionRepository, TaxDefinitionSummary } from "../../../../../application";
import type { TaxDefinition, TaxDefinitionCode } from "../../../../../domain";
@ -123,6 +123,62 @@ export class SequelizeTaxDefinitionRepository
}
}
async findActiveByCodeInCompany(params: {
companyId: UniqueID;
code: string;
atDate: UtcDate;
transaction?: Transaction;
}): Promise<Result<Maybe<TaxDefinition>, Error>> {
try {
const row = await TaxDefinitionModel.findOne({
where: {
...this.buildActiveWhereClause(params.companyId, params.atDate),
code: this.normalizeCode(params.code),
},
transaction: params.transaction,
});
if (!row) {
return Result.ok(Maybe.none<TaxDefinition>());
}
return this.domainMapper.mapToDomain(row).map((taxDefinition) => Maybe.some(taxDefinition));
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
async findActiveByCodesInCompany(params: {
companyId: UniqueID;
codes: Collection<string>;
atDate: UtcDate;
transaction?: Transaction;
}): Promise<Result<Collection<TaxDefinition>, Error>> {
try {
const normalizedCodes = Array.from(
new Set(params.codes.getAll().map((code) => this.normalizeCode(code)))
);
if (normalizedCodes.length === 0) {
return Result.ok(new Collection([]));
}
const rows = await TaxDefinitionModel.findAll({
where: {
...this.buildActiveWhereClause(params.companyId, params.atDate),
code: {
[Op.in]: normalizedCodes,
},
},
transaction: params.transaction,
});
return this.domainMapper.mapToDomainCollection(rows, rows.length);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
async findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
@ -171,4 +227,25 @@ export class SequelizeTaxDefinitionRepository
return Result.fail(translateSequelizeError(err));
}
}
private buildActiveWhereClause(companyId: UniqueID, atDate: UtcDate): WhereOptions {
const atDateISO = atDate.toPrimitive();
return {
company_id: companyId.toString(),
is_active: true,
[Op.and]: [
{
[Op.or]: [{ valid_from: null }, { valid_from: { [Op.lte]: atDateISO } }],
},
{
[Op.or]: [{ valid_to: null }, { valid_to: { [Op.gte]: atDateISO } }],
},
],
};
}
private normalizeCode(code: string): string {
return code.trim().toLowerCase();
}
}

View File

@ -2,8 +2,7 @@ import type { ModuleParams, SetupParams } from "@erp/core/api";
import { buildTransactionManager } from "@erp/core/api";
import type { Sequelize } from "sequelize";
import type { ITaxRegimeRepository } from "../../../application/";
import { TaxRegimeFinder } from "../../../application/";
import { type ITaxRegimeRepository, TaxRegimePublicFinder } from "../../../application/";
import {
CreateTaxRegimeUseCase,
DeleteTaxRegimeByIdUseCase,
@ -11,6 +10,7 @@ import {
EnableTaxRegimeByIdUseCase,
GetTaxRegimeByIdUseCase,
ListTaxRegimesUseCase,
TaxRegimePublicModelMapper,
UpdateTaxRegimeByIdUseCase,
} from "../../../application/tax-regimes";
import {
@ -112,8 +112,13 @@ export const buildTaxRegimesDependencies = (params: ModuleParams): TaxRegimesInt
export const buildTaxRegimesPublicServices = (
_params: SetupParams,
deps: TaxRegimesInternalDeps
): { finder: TaxRegimeFinder } => {
): { finder: TaxRegimePublicFinder } => {
const mapper = new TaxRegimePublicModelMapper();
return {
finder: new TaxRegimeFinder(deps.repository),
finder: new TaxRegimePublicFinder({
repository: deps.repository,
mapper,
}),
};
};

View File

@ -37,7 +37,7 @@ export default (database: Sequelize) => {
allowNull: false,
},
code: {
type: DataTypes.STRING,
type: DataTypes.STRING(2),
allowNull: false,
unique: true,
},

View File

@ -6,8 +6,8 @@ import {
} from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import { Op, type Sequelize, type Transaction } from "sequelize";
import type { ITaxRegimeRepository, TaxRegimeSummary } from "../../../../../application";
import type { TaxRegime, TaxRegimeCode } from "../../../../../domain";
@ -105,6 +105,22 @@ export class SequelizeTaxRegimeRepository
}
}
async existsByCodeInCompany(
companyId: UniqueID,
code: TaxRegimeCode,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
try {
const count = await TaxRegimeModel.count({
where: { code: code.toString(), company_id: companyId.toString() },
transaction,
});
return Result.ok(Boolean(count > 0));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
/**
* Recupera un método de pago por su ID y companyId.
*
@ -250,4 +266,55 @@ export class SequelizeTaxRegimeRepository
return Result.fail(translateSequelizeError(err));
}
}
async findByCodeInCompany(
companyId: UniqueID,
code: TaxRegimeCode,
transaction?: Transaction
): Promise<Result<Maybe<TaxRegime>, Error>> {
try {
const row = await TaxRegimeModel.findOne({
where: { code: code.toString(), company_id: companyId.toString() },
transaction,
});
if (!row) {
return Result.ok(Maybe.none<TaxRegime>());
}
return this.domainMapper.mapToDomain(row).map((taxDefinition) => Maybe.some(taxDefinition));
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
async findByCodesInCompany(params: {
companyId: UniqueID;
codes: Collection<TaxRegimeCode>;
transaction?: Transaction;
}): Promise<Result<Collection<TaxRegime>, Error>> {
try {
const normalizedCodes = Array.from(
new Set(params.codes.getAll().map((code) => code.toString()))
);
if (normalizedCodes.length === 0) {
return Result.ok(new Collection());
}
const rows = await TaxRegimeModel.findAll({
where: {
company_id: params.companyId.toString(),
code: {
[Op.in]: normalizedCodes,
},
},
transaction: params.transaction,
});
return this.domainMapper.mapToDomainCollection(rows, rows.length);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
}

View File

@ -1,3 +1,4 @@
export * from "./payment-methods";
export * from "./payment-terms";
export * from "./tax-definitions";
export * from "./tax-regimes";

View File

@ -0,0 +1,3 @@
export * from "./request";
export * from "./response";
export * from "./shared";

View File

@ -0,0 +1,43 @@
import { IsoDateSchema, PercentageSchema } from "@erp/core";
import { z } from "zod/v4";
const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/);
const TaxDefinitionFamilySchema = z.enum([
"iva",
"igic",
"ipsi",
"equivalence_surcharge",
"withholding",
"vat",
"gst",
"sales_tax",
"reverse_charge",
"exempt",
"not_subject",
"custom",
]);
const TaxCalculationBehaviorSchema = z.enum(["additive", "subtractive", "neutral"]);
const TaxScopeSchema = z.enum(["domestic", "intra_eu", "export", "import", "international"]);
const JurisdictionCountryCodeSchema = z.string().regex(/^[A-Z]{2}$/);
const JurisdictionRegionCodeSchema = z.string().regex(/^[A-Z]{2}-[A-Z0-9-]+$/);
export const CreateTaxDefinitionRequestSchema = z.object({
id: z.uuid(),
code: TaxDefinitionCodeSchema,
name: z.string(),
description: z.string().nullable().optional(),
rate: PercentageSchema,
tax_family: TaxDefinitionFamilySchema,
calculation_behavior: TaxCalculationBehaviorSchema,
jurisdiction_country_code: JurisdictionCountryCodeSchema,
jurisdiction_region_code: JurisdictionRegionCodeSchema.nullable().optional(),
tax_scope: TaxScopeSchema,
invoice_note: z.string().nullable().optional(),
allowed_surcharge_codes: z.array(TaxDefinitionCodeSchema).nullable().optional(),
is_system: z.boolean().optional(),
is_active: z.boolean(),
valid_from: IsoDateSchema.nullable().optional(),
valid_to: IsoDateSchema.nullable().optional(),
});
export type CreateTaxDefinitionRequestDTO = z.infer<typeof CreateTaxDefinitionRequestSchema>;

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const DeleteTaxDefinitionByIdRequestSchema = z.object({
tax_definition_id: z.uuid(),
});
export type DeleteTaxDefinitionByIdRequestDTO = z.infer<
typeof DeleteTaxDefinitionByIdRequestSchema
>;

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const DisableTaxDefinitionByIdRequestSchema = z.object({
tax_definition_id: z.uuid(),
});
export type DisableTaxDefinitionByIdRequestDTO = z.infer<
typeof DisableTaxDefinitionByIdRequestSchema
>;

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const EnableTaxDefinitionByIdRequestSchema = z.object({
tax_definition_id: z.uuid(),
});
export type EnableTaxDefinitionByIdRequestDTO = z.infer<
typeof EnableTaxDefinitionByIdRequestSchema
>;

View File

@ -0,0 +1,7 @@
import { z } from "zod/v4";
export const GetTaxDefinitionByIdRequestSchema = z.object({
tax_definition_id: z.uuid(),
});
export type GetTaxDefinitionByIdRequestDTO = z.infer<typeof GetTaxDefinitionByIdRequestSchema>;

View File

@ -0,0 +1,7 @@
export * from "./create-tax-definition.request.dto";
export * from "./delete-tax-definition-by-id.request.dto";
export * from "./disable-tax-definition-by-id.request.dto";
export * from "./enable-tax-definition-by-id.request.dto";
export * from "./get-tax-definition-by-id.request.dto";
export * from "./list-tax-definitions.request.dto";
export * from "./update-tax-definition-by-id.request.dto";

View File

@ -0,0 +1,5 @@
import { CriteriaSchema } from "@erp/core";
import type { z } from "zod/v4";
export const ListTaxDefinitionsRequestSchema = CriteriaSchema;
export type ListTaxDefinitionsRequestDTO = z.infer<typeof ListTaxDefinitionsRequestSchema>;

View File

@ -0,0 +1,26 @@
import { IsoDateSchema, PercentageSchema } from "@erp/core";
import { z } from "zod/v4";
const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/);
export const UpdateTaxDefinitionByIdParamsRequestSchema = z.object({
tax_definition_id: z.uuid(),
});
export const UpdateTaxDefinitionByIdRequestSchema = z.object({
name: z.string().optional(),
description: z.string().nullable().optional(),
rate: PercentageSchema.optional(),
invoice_note: z.string().nullable().optional(),
allowed_surcharge_codes: z.array(TaxDefinitionCodeSchema).nullable().optional(),
is_active: z.boolean().optional(),
valid_from: IsoDateSchema.nullable().optional(),
valid_to: IsoDateSchema.nullable().optional(),
});
export type UpdateTaxDefinitionByIdParamsRequestDTO = z.infer<
typeof UpdateTaxDefinitionByIdParamsRequestSchema
>;
export type UpdateTaxDefinitionByIdRequestDTO = z.infer<
typeof UpdateTaxDefinitionByIdRequestSchema
>;

View File

@ -0,0 +1,6 @@
import type { z } from "zod/v4";
import { TaxDefinitionDetailSchema } from "../shared";
export const CreateTaxDefinitionResponseSchema = TaxDefinitionDetailSchema;
export type CreateTaxDefinitionResponseDTO = z.infer<typeof CreateTaxDefinitionResponseSchema>;

View File

@ -0,0 +1,8 @@
import type { z } from "zod/v4";
import { TaxDefinitionDetailSchema } from "../shared";
export const DisableTaxDefinitionByIdResponseSchema = TaxDefinitionDetailSchema;
export type DisableTaxDefinitionByIdResponseDTO = z.infer<
typeof DisableTaxDefinitionByIdResponseSchema
>;

View File

@ -0,0 +1,8 @@
import type { z } from "zod/v4";
import { TaxDefinitionDetailSchema } from "../shared";
export const EnableTaxDefinitionByIdResponseSchema = TaxDefinitionDetailSchema;
export type EnableTaxDefinitionByIdResponseDTO = z.infer<
typeof EnableTaxDefinitionByIdResponseSchema
>;

View File

@ -0,0 +1,8 @@
import type { z } from "zod/v4";
import { TaxDefinitionDetailSchema } from "../shared";
export const GetTaxDefinitionByIdResponseSchema = TaxDefinitionDetailSchema;
export type GetTaxDefinitionByIdResponseDTO = z.infer<
typeof GetTaxDefinitionByIdResponseSchema
>;

View File

@ -0,0 +1,6 @@
export * from "./create-tax-definition.response.dto";
export * from "./disable-tax-definition-by-id.response.dto";
export * from "./enable-tax-definition-by-id.response.dto";
export * from "./get-tax-definition-by-id.response.dto";
export * from "./list-tax-definitions.response.dto";
export * from "./update-tax-definition-by-id.response.dto";

View File

@ -0,0 +1,9 @@
import { createPaginatedListSchema } from "@erp/core";
import type { z } from "zod/v4";
import { TaxDefinitionSummarySchema } from "../shared";
export const ListTaxDefinitionsResponseSchema = createPaginatedListSchema(
TaxDefinitionSummarySchema
);
export type ListTaxDefinitionsResponseDTO = z.infer<typeof ListTaxDefinitionsResponseSchema>;

View File

@ -0,0 +1,8 @@
import type { z } from "zod/v4";
import { TaxDefinitionDetailSchema } from "../shared";
export const UpdateTaxDefinitionByIdResponseSchema = TaxDefinitionDetailSchema;
export type UpdateTaxDefinitionByIdResponseDTO = z.infer<
typeof UpdateTaxDefinitionByIdResponseSchema
>;

View File

@ -0,0 +1,2 @@
export * from "./tax-definition-detail.dto";
export * from "./tax-definition-summary.dto";

View File

@ -0,0 +1,44 @@
import { IsoDateSchema, PercentageSchema } from "@erp/core";
import { z } from "zod/v4";
const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/);
const TaxDefinitionFamilySchema = z.enum([
"iva",
"igic",
"ipsi",
"equivalence_surcharge",
"withholding",
"vat",
"gst",
"sales_tax",
"reverse_charge",
"exempt",
"not_subject",
"custom",
]);
const TaxCalculationBehaviorSchema = z.enum(["additive", "subtractive", "neutral"]);
const TaxScopeSchema = z.enum(["domestic", "intra_eu", "export", "import", "international"]);
const JurisdictionCountryCodeSchema = z.string().regex(/^[A-Z]{2}$/);
const JurisdictionRegionCodeSchema = z.string().regex(/^[A-Z]{2}-[A-Z0-9-]+$/);
export const TaxDefinitionDetailSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
code: TaxDefinitionCodeSchema,
name: z.string(),
description: z.string().nullable(),
rate: PercentageSchema,
tax_family: TaxDefinitionFamilySchema,
calculation_behavior: TaxCalculationBehaviorSchema,
jurisdiction_country_code: JurisdictionCountryCodeSchema,
jurisdiction_region_code: JurisdictionRegionCodeSchema.nullable(),
tax_scope: TaxScopeSchema,
invoice_note: z.string().nullable(),
allowed_surcharge_codes: z.array(TaxDefinitionCodeSchema).nullable(),
is_system: z.boolean(),
is_active: z.boolean(),
valid_from: IsoDateSchema.nullable(),
valid_to: IsoDateSchema.nullable(),
});
export type TaxDefinitionDetailDTO = z.infer<typeof TaxDefinitionDetailSchema>;

View File

@ -0,0 +1,14 @@
import { z } from "zod/v4";
const TaxDefinitionCodeSchema = z.string().regex(/^[a-z0-9][a-z0-9_]*$/);
export const TaxDefinitionSummarySchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
code: TaxDefinitionCodeSchema,
name: z.string(),
is_active: z.boolean(),
is_system: z.boolean(),
});
export type TaxDefinitionSummaryDTO = z.infer<typeof TaxDefinitionSummarySchema>;

View File

@ -1,4 +1,4 @@
export type ISnapshotBuilderParams = Readonly<Record<string, unknown>>;
export type ISnapshotBuilderParams = {}; //Readonly<Record<string, unknown>>;
export interface ISnapshotBuilder<TSource, TSnapshot = unknown> {
toOutput(source: TSource, params?: ISnapshotBuilderParams): TSnapshot;

View File

@ -1,19 +1,25 @@
import { Percentage, type PercentageProps } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
type TaxPercentageProps = Pick<PercentageProps, "value">;
export class TaxPercentage extends Percentage {
static DEFAULT_SCALE = 2;
public static readonly DEFAULT_SCALE = 2;
static create({ value }: TaxPercentageProps): Result<Percentage> {
return Percentage.create({
public static create({ value }: TaxPercentageProps): Result<TaxPercentage> {
const result = Percentage.create({
value,
scale: TaxPercentage.DEFAULT_SCALE,
});
if (result.isFailure) {
return result;
}
return Result.ok(new TaxPercentage(result.data.getProps()));
}
static zero() {
public static zero(): TaxPercentage {
return TaxPercentage.create({ value: 0 }).data;
}
}

View File

@ -1,178 +1,183 @@
import type { TaxCatalogProvider } from "@erp/core";
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { TaxPercentage } from "./tax-percentage.vo";
import { TaxPercentage } from "./tax-percentage.vo.js";
const TAX_GROUPS = ["IVA", "IPSI", "IGIC", "retention", "rec"] as const;
type TaxGroup = (typeof TAX_GROUPS)[number];
export const TAX_GROUPS = ["iva", "ipsi", "igic", "retention", "surcharge"] as const;
export type TaxGroup = (typeof TAX_GROUPS)[number];
export type TaxCalculationBehavior = "additive" | "subtractive";
export interface TaxProps {
code: string; // iva_21
name: string; // 21% IVA
value: number; // 2100
code: string;
name: string;
rate: TaxPercentage;
group: TaxGroup;
calculationBehavior: TaxCalculationBehavior;
}
export interface CreateTaxProps {
code: string;
name: string;
rate: TaxPercentage;
group: TaxGroup;
calculationBehavior: TaxCalculationBehavior;
}
export class Tax extends ValueObject<TaxProps> {
static readonly DEFAULT_SCALE = TaxPercentage.DEFAULT_SCALE;
static readonly MIN_VALUE = TaxPercentage.MIN_VALUE;
static readonly MAX_VALUE = TaxPercentage.MAX_VALUE;
static readonly MIN_SCALE = TaxPercentage.MIN_SCALE;
static readonly MAX_SCALE = TaxPercentage.MAX_SCALE;
public static readonly DEFAULT_SCALE = TaxPercentage.DEFAULT_SCALE;
public static readonly MIN_VALUE = TaxPercentage.MIN_VALUE;
public static readonly MAX_VALUE = TaxPercentage.MAX_VALUE;
public static readonly MIN_SCALE = TaxPercentage.MIN_SCALE;
public static readonly MAX_SCALE = TaxPercentage.MAX_SCALE;
private static CODE_REGEX = /^[a-z0-9_:-]+$/;
private static readonly CODE_REGEX = /^[a-z0-9_:-]+$/;
private _percentage!: TaxPercentage;
public static create(props: CreateTaxProps): Result<Tax> {
const validationResult = Tax.validate(props);
protected static validate(values: TaxProps) {
if (!validationResult.success) {
return Result.fail(
new Error(validationResult.error.issues.map((issue) => issue.message).join(", "))
);
}
return Result.ok(
new Tax({
code: props.code.trim(),
name: props.name.trim(),
rate: props.rate,
group: props.group,
calculationBehavior: props.calculationBehavior,
})
);
}
protected static validate(values: CreateTaxProps) {
const schema = z.object({
value: z
.number()
.int()
.min(Tax.MIN_VALUE, "La tasa de impuesto no puede ser negativa.")
.max(Tax.MAX_VALUE * 10 ** Tax.MAX_SCALE, "La tasa de impuesto es demasiado alta."),
name: z
.string()
.trim()
.min(1, "El nombre del impuesto es obligatorio.")
.max(100, "El nombre del impuesto no puede exceder 100 caracteres."),
code: z
.string()
.trim()
.min(1, "El código del impuesto es obligatorio.")
.max(40, "El código del impuesto no puede exceder 40 caracteres.")
.regex(Tax.CODE_REGEX, "El código contiene caracteres no permitidos."),
group: z.enum(TAX_GROUPS, "El impuesto debe ser un IVA, retención o rec. equivalencia"),
group: z.enum(TAX_GROUPS),
calculationBehavior: z.enum(["additive", "subtractive"]),
});
return schema.safeParse(values);
}
static create(props: TaxProps): Result<Tax> {
const { value, name, code, group } = props;
const validationResult = Tax.validate({ value, name, code, group });
if (!validationResult.success) {
return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", ")));
}
return Result.ok(new Tax({ value, name, code, group }));
}
/**
* Crea un Tax usando solo el 'code', resolviendo el resto de datos desde el catálogo.
* @param code Código del impuesto (p.ej. "iva_21")
* @param provider Proveedor del catálogo de impuestos
*/
static createFromCode(code: string, provider: TaxCatalogProvider): Result<Tax, Error> {
const normalized = (code ?? "").trim().toLowerCase();
const schema = z
.string()
.min(1, "El código del impuesto es obligatorio.")
.max(40, "El código del impuesto no puede exceder 40 caracteres.")
.regex(Tax.CODE_REGEX, "El código contiene caracteres no permitidos.");
const validationResult = schema.safeParse(normalized);
if (!validationResult.success) {
return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", ")));
}
const maybeItem = provider.findByCode(normalized);
if (maybeItem.isNone()) {
return Result.fail(
new Error(`Código de impuesto no encontrado en el catálogo: "${normalized}"`)
);
}
const item = maybeItem.unwrap();
// Delegamos en create para reusar validación y límites
return Tax.create({
value: Number(item.value),
name: item.name,
code: item.code, // guardamos el code tal cual viene del catálogo
group: item.group as TaxGroup,
});
}
get value(): number {
return this.props.value;
}
get scale(): number {
return Tax.DEFAULT_SCALE;
}
get name(): string {
return this.props.name;
}
get code(): string {
public get code(): string {
return this.props.code;
}
get group(): string {
public get name(): string {
return this.props.name;
}
public get rate(): TaxPercentage {
return this.props.rate;
}
public get group(): TaxGroup {
return this.props.group;
}
get percentage(): TaxPercentage {
return TaxPercentage.create({ value: this.value }).data;
public get calculationBehavior(): TaxCalculationBehavior {
return this.props.calculationBehavior;
}
isVATLike(): boolean {
return this.group === "IVA" || this.group === "IGIC" || this.group === "IPSI";
public get value(): number {
return this.props.rate.value;
}
isRetention(): boolean {
return this.group === "retention";
public get scale(): number {
return this.props.rate.scale;
}
isRec(): boolean {
return this.group === "rec";
public get percentage(): TaxPercentage {
return this.props.rate;
}
getProps(): TaxProps {
public isAdditive(): boolean {
return this.props.calculationBehavior === "additive";
}
public isSubtractive(): boolean {
return this.props.calculationBehavior === "subtractive";
}
public isIva(): boolean {
return this.props.group === "iva";
}
public isIgic(): boolean {
return this.props.group === "igic";
}
public isIpsi(): boolean {
return this.props.group === "ipsi";
}
public isSurcharge(): boolean {
return this.props.group === "surcharge";
}
public isRetention(): boolean {
return this.props.group === "retention";
}
public getProps(): TaxProps {
return this.props;
}
toPrimitive() {
public toPrimitive(): TaxProps {
return this.getProps();
}
toNumber(): number {
public toNumber(): number {
return this.value / 10 ** this.scale;
}
toString(): string {
return `${this.toNumber().toFixed(this.scale)}%`;
public toString(): string {
return this.rate.toString();
}
isZero(): boolean {
public isZero(): boolean {
return this.value === 0;
}
isPositive(): boolean {
public isPositive(): boolean {
return this.value > 0;
}
equalsTo(other: Tax): boolean {
return this.equals(other);
public equalsTo(other: Tax): boolean {
return (
this.code === other.code &&
this.name === other.name &&
this.value === other.value &&
this.scale === other.scale &&
this.group === other.group &&
this.calculationBehavior === other.calculationBehavior
);
}
greaterThan(other: Tax): boolean {
public greaterThan(other: Tax): boolean {
return this.toNumber() > other.toNumber();
}
lessThan(other: Tax): boolean {
public lessThan(other: Tax): boolean {
return this.toNumber() < other.toNumber();
}
toJSON() {
return {
value: this.value,
scale: this.scale,
name: this.name,
code: this.code,
percentage: this.toNumber(),
formatted: this.toString(),
};
}
}

View File

@ -1,21 +1,16 @@
import {
FactuGESPaymentCatalogProvider,
type JsonPaymentCatalogProvider,
type JsonTaxCatalogProvider,
SpainTaxCatalogProvider,
} from "../../../common";
import { type JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "../../../common";
export interface ICatalogs {
taxCatalog: JsonTaxCatalogProvider;
paymentCatalog: JsonPaymentCatalogProvider;
//paymentCatalog: JsonPaymentCatalogProvider;
}
export const buildCatalogs = (): ICatalogs => {
const taxCatalog = SpainTaxCatalogProvider();
const paymentCatalog = FactuGESPaymentCatalogProvider();
//const paymentCatalog = FactuGESPaymentCatalogProvider();
return {
taxCatalog,
paymentCatalog,
//paymentCatalog,
};
};

View File

@ -17,9 +17,8 @@ import {
isDomainValidationError,
isValidationErrorCollection,
} from "@repo/rdx-ddd";
import type { ZodError } from "zod";
import { ZodError } from "zod";
import { isSchemaError } from "../../../common/schemas";
import { type DocumentGenerationError, isDocumentGenerationError } from "../../application";
import {
type DuplicateEntityError,
@ -46,6 +45,8 @@ import {
ValidationApiError,
} from "./errors";
export const isSchemaError = (e: unknown): e is ZodError => e instanceof ZodError;
// ────────────────────────────────────────────────────────────────────────────────
// Contexto opcional para enriquecer Problem+JSON (útil en middleware Express)
// ────────────────────────────────────────────────────────────────────────────────

View File

@ -12,7 +12,7 @@ export abstract class SequelizeDomainMapper<TModel extends Model, TModelAttribut
public abstract mapToPersistence(
domain: TEntity,
params?: MapperParamsType
): Result<TModelAttributes, Error>;
): Result<TModelAttributes, Error> | Promise<Result<TModelAttributes, Error>>;
public mapToDomainCollection(
raws: (TModel | TModelAttributes)[],

Some files were not shown because too many files have changed in this diff Show More