From 97174e580d4de47cabaf216b1361ed1664798c78 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 16 Aug 2026 00:36:31 +0200 Subject: [PATCH] feat(payment-terms): enforce percentage validation and enhance deletion logic - Added validation to ensure payment term percentage is greater than 0 and does not exceed 100. - Introduced `PaymentTermUsageChecker` to check for references before deletion. - Updated `PaymentTerm` aggregate to track if due rules were updated. - Enhanced repository methods to handle due rules correctly during updates and deletions. - Added tests for payment term services and aggregate to ensure correct behavior. - Implemented unique constraint on due days in the database model. --- .../di/payment-term-deleter.di.ts | 6 +- .../payment-term-public-model.mapper.ts | 2 +- .../__tests__/payment-term-services.test.ts | 197 +++++++++ .../payment-terms/services/index.ts | 1 + .../services/payment-term-deleter.service.ts | 33 +- .../services/payment-term-usage-checker.ts | 68 +++ .../__tests__/payment-term.aggregate.test.ts | 229 ++++++++++ .../payment-terms/payment-term-percentage.ts | 14 + .../payment-terms/payment-term.aggregate.ts | 89 +++- .../payment-terms/di/payment-terms.di.ts | 4 +- .../payment-term-reference-usage-checker.ts | 65 +++ ...ize-payment-term-due-rule-domain.mapper.ts | 2 +- .../sequelize-payment-term-due-rule.model.ts | 5 + .../sequelize-payment-term.repository.test.ts | 397 ++++++++++++++++++ .../sequelize-payment-term.repository.ts | 61 ++- 15 files changed, 1146 insertions(+), 27 deletions(-) create mode 100644 modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts create mode 100644 modules/catalogs/src/api/application/payment-terms/services/payment-term-usage-checker.ts create mode 100644 modules/catalogs/src/api/domain/payment-terms/__tests__/payment-term.aggregate.test.ts create mode 100644 modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.ts create mode 100644 modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.test.ts diff --git a/modules/catalogs/src/api/application/payment-terms/di/payment-term-deleter.di.ts b/modules/catalogs/src/api/application/payment-terms/di/payment-term-deleter.di.ts index 9cb5e606..70c38460 100644 --- a/modules/catalogs/src/api/application/payment-terms/di/payment-term-deleter.di.ts +++ b/modules/catalogs/src/api/application/payment-terms/di/payment-term-deleter.di.ts @@ -1,10 +1,12 @@ import type { IPaymentTermRepository } from "../repositories"; +import type { IPaymentTermUsageChecker } from "../services"; import { type IPaymentTermDeleter, PaymentTermDeleter } from "../services"; export const buildPaymentTermDeleter = (params: { repository: IPaymentTermRepository; + usageChecker: IPaymentTermUsageChecker; }): IPaymentTermDeleter => { - const { repository } = params; + const { repository, usageChecker } = params; - return new PaymentTermDeleter(repository); + return new PaymentTermDeleter({ repository, usageChecker }); }; diff --git a/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts b/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts index 20848448..cc850182 100644 --- a/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts +++ b/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts @@ -1,4 +1,4 @@ -import type { PaymentTerm } from "../../../domain"; +import type { PaymentTerm } from "../../../../domain"; import type { PaymentTermPublicModel } from "../models"; export class PaymentTermPublicModelMapper { diff --git a/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts b/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts new file mode 100644 index 00000000..febccb34 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { EntityNotFoundError } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import { PaymentTerm } from "../../../../domain/payment-terms/payment-term.aggregate"; +import { PaymentTermDueDays } from "../../../../domain/payment-terms/payment-term-due-days"; +import { PaymentTermName } from "../../../../domain/payment-terms/payment-term-name"; +import { PaymentTermPercentage } from "../../../../domain/payment-terms/payment-term-percentage"; +import type { PaymentTermSummary } from "../../models/payment-term-summary.model"; +import type { IPaymentTermRepository } from "../../repositories/payment-term-repository.interface"; +import { PaymentTermDeleter } from "../payment-term-deleter.service"; + +class InMemoryPaymentTermRepository implements IPaymentTermRepository { + private readonly items = new Map(); + private readonly deleted = new Set(); + + async create(paymentTerm: PaymentTerm): Promise> { + this.items.set(paymentTerm.id.toString(), paymentTerm); + return Result.ok(); + } + + async update(paymentTerm: PaymentTerm): Promise> { + this.items.set(paymentTerm.id.toString(), paymentTerm); + return Result.ok(); + } + + async deleteByIdInCompany(companyId: UniqueID, id: UniqueID): Promise> { + const result = await this.existsByIdInCompany(companyId, id); + if (result.isFailure) { + return Result.fail(result.error); + } + + if (!result.data) { + return Result.fail(new EntityNotFoundError("PaymentTerm", "id", id.toString())); + } + + this.deleted.add(id.toString()); + return Result.ok(true); + } + + async existsByIdInCompany(companyId: UniqueID, id: UniqueID): Promise> { + const paymentTerm = this.items.get(id.toString()); + return Result.ok( + Boolean(paymentTerm && paymentTerm.companyId.toString() === companyId.toString()) + ); + } + + async getByIdInCompany(companyId: UniqueID, id: UniqueID): Promise> { + const paymentTerm = this.items.get(id.toString()); + + if (!paymentTerm || paymentTerm.companyId.toString() !== companyId.toString()) { + return Result.fail(new EntityNotFoundError("PaymentTerm", "id", id.toString())); + } + + if (this.deleted.has(id.toString())) { + return Result.fail(new EntityNotFoundError("PaymentTerm", "id", id.toString())); + } + + return Result.ok(paymentTerm); + } + + async findByIdInCompany( + companyId: UniqueID, + id: UniqueID + ): Promise, Error>> { + const result = await this.getByIdInCompany(companyId, id); + + if (result.isFailure) { + if (result.error instanceof EntityNotFoundError) { + return Result.ok(Maybe.none()); + } + + return Result.fail(result.error); + } + + return Result.ok(Maybe.some(result.data)); + } + + async findByCriteriaInCompany(): Promise, Error>> { + return Result.ok(new Collection([])); + } +} + +const createRuleProps = (params: { dueDays: number; percentageValue: number }) => { + const dueDaysResult = PaymentTermDueDays.create(params.dueDays); + assert.equal(dueDaysResult.isSuccess, true); + + const percentageResult = PaymentTermPercentage.create({ + value: params.percentageValue, + scale: 2, + }); + assert.equal(percentageResult.isSuccess, true); + + return { + dueDays: dueDaysResult.data, + percentage: percentageResult.data, + }; +}; + +const createPaymentTerm = (companyId = UniqueID.generateNewID()) => { + const nameResult = PaymentTermName.create("30/60"); + assert.equal(nameResult.isSuccess, true); + + const paymentTermResult = PaymentTerm.create( + { + companyId, + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: false, + dueRules: [ + createRuleProps({ dueDays: 30, percentageValue: 5000 }), + createRuleProps({ dueDays: 60, percentageValue: 5000 }), + ], + }, + UniqueID.generateNewID() + ); + + assert.equal(paymentTermResult.isSuccess, true); + return paymentTermResult.data; +}; + +describe("Payment term services", () => { + test("delete allows removing unreferenced custom payment terms", async () => { + const repository = new InMemoryPaymentTermRepository(); + const paymentTerm = createPaymentTerm(); + await repository.create(paymentTerm); + + const deleter = new PaymentTermDeleter({ + repository, + usageChecker: { + findUsage: async () => Result.ok([]), + }, + }); + + const result = await deleter.delete({ + paymentTerm, + }); + + assert.equal(result.isSuccess, true); + }); + + test("delete blocks referenced payment terms", async () => { + const repository = new InMemoryPaymentTermRepository(); + const paymentTerm = createPaymentTerm(); + await repository.create(paymentTerm); + + const deleter = new PaymentTermDeleter({ + repository, + usageChecker: { + findUsage: async () => Result.ok([{ source: "customers" }]), + }, + }); + + const result = await deleter.delete({ + paymentTerm, + }); + + assert.equal(result.isFailure, true); + }); + + test("delete blocks system payment terms", async () => { + const companyId = UniqueID.generateNewID(); + const nameResult = PaymentTermName.create("Contado sistema"); + assert.equal(nameResult.isSuccess, true); + + const paymentTermResult = PaymentTerm.create( + { + companyId, + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: true, + dueRules: [createRuleProps({ dueDays: 0, percentageValue: 10000 })], + }, + UniqueID.generateNewID() + ); + + assert.equal(paymentTermResult.isSuccess, true); + + const deleter = new PaymentTermDeleter({ + repository: new InMemoryPaymentTermRepository(), + usageChecker: { + findUsage: async () => Result.ok([]), + }, + }); + + const result = await deleter.delete({ + paymentTerm: paymentTermResult.data, + }); + + assert.equal(result.isFailure, true); + }); +}); diff --git a/modules/catalogs/src/api/application/payment-terms/services/index.ts b/modules/catalogs/src/api/application/payment-terms/services/index.ts index 0bd4014c..3aaa5f14 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/index.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/index.ts @@ -5,3 +5,4 @@ export * from "./payment-term-deleter.service"; export * from "./payment-term-finder.service"; export * from "./payment-term-status-changer.service"; export * from "./payment-term-updater.service"; +export * from "./payment-term-usage-checker"; diff --git a/modules/catalogs/src/api/application/payment-terms/services/payment-term-deleter.service.ts b/modules/catalogs/src/api/application/payment-terms/services/payment-term-deleter.service.ts index 74e4d3ad..7cc085c2 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/payment-term-deleter.service.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/payment-term-deleter.service.ts @@ -4,6 +4,8 @@ import type { PaymentTerm } from "../../../domain"; import { PaymentTermCannotBeDeletedError } from "../../../domain"; import type { IPaymentTermRepository } from "../repositories/payment-term-repository.interface"; +import type { IPaymentTermUsageChecker } from "./payment-term-usage-checker"; + export interface IPaymentTermDeleter { delete(params: { paymentTerm: PaymentTerm; @@ -12,7 +14,12 @@ export interface IPaymentTermDeleter { } export class PaymentTermDeleter implements IPaymentTermDeleter { - public constructor(private readonly repository: IPaymentTermRepository) {} + public constructor( + private readonly deps: { + repository: IPaymentTermRepository; + usageChecker: IPaymentTermUsageChecker; + } + ) {} public async delete(params: { paymentTerm: PaymentTerm; @@ -20,13 +27,31 @@ export class PaymentTermDeleter implements IPaymentTermDeleter { }): Promise> { const { paymentTerm, transaction } = params; - if (paymentTerm.isSystem) { + const invariantResult = paymentTerm.ensureCanBeDeleted(); + if (invariantResult.isFailure) { + return Result.fail(invariantResult.error); + } + + const usageResult = await this.deps.usageChecker.findUsage({ + companyId: paymentTerm.companyId, + paymentTermId: paymentTerm.id, + transaction, + }); + + if (usageResult.isFailure) { + return Result.fail(usageResult.error); + } + + if (usageResult.data.length > 0) { + const sources = usageResult.data.map((detail) => detail.source).join(", "); return Result.fail( - new PaymentTermCannotBeDeletedError("System payment terms cannot be deleted.") + new PaymentTermCannotBeDeletedError( + `Payment term cannot be deleted because it is still referenced by: ${sources}.` + ) ); } - const deleteResult = await this.repository.deleteByIdInCompany( + const deleteResult = await this.deps.repository.deleteByIdInCompany( paymentTerm.companyId, paymentTerm.id, transaction diff --git a/modules/catalogs/src/api/application/payment-terms/services/payment-term-usage-checker.ts b/modules/catalogs/src/api/application/payment-terms/services/payment-term-usage-checker.ts new file mode 100644 index 00000000..9cf4b29e --- /dev/null +++ b/modules/catalogs/src/api/application/payment-terms/services/payment-term-usage-checker.ts @@ -0,0 +1,68 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +export type PaymentTermUsageSource = "customers" | "proformas"; + +export interface PaymentTermUsageDetail { + source: PaymentTermUsageSource; +} + +export interface IPaymentTermUsageChecker { + findUsage(params: { + companyId: UniqueID; + paymentTermId: UniqueID; + transaction?: unknown; + }): Promise>; +} + +export class PaymentTermUsageChecker implements IPaymentTermUsageChecker { + public constructor( + private readonly deps: { + customersUsageFinder: { + existsByPaymentTermId(params: { + companyId: UniqueID; + paymentTermId: UniqueID; + transaction?: unknown; + }): Promise>; + }; + proformasUsageFinder: { + existsByPaymentTermId(params: { + companyId: UniqueID; + paymentTermId: UniqueID; + transaction?: unknown; + }): Promise>; + }; + } + ) {} + + public async findUsage(params: { + companyId: UniqueID; + paymentTermId: UniqueID; + transaction?: unknown; + }): Promise> { + const [customersUsage, proformasUsage] = await Promise.all([ + this.deps.customersUsageFinder.existsByPaymentTermId(params), + this.deps.proformasUsageFinder.existsByPaymentTermId(params), + ]); + + if (customersUsage.isFailure) { + return Result.fail(customersUsage.error); + } + + if (proformasUsage.isFailure) { + return Result.fail(proformasUsage.error); + } + + const usage: PaymentTermUsageDetail[] = []; + + if (customersUsage.data) { + usage.push({ source: "customers" }); + } + + if (proformasUsage.data) { + usage.push({ source: "proformas" }); + } + + return Result.ok(usage); + } +} diff --git a/modules/catalogs/src/api/domain/payment-terms/__tests__/payment-term.aggregate.test.ts b/modules/catalogs/src/api/domain/payment-terms/__tests__/payment-term.aggregate.test.ts new file mode 100644 index 00000000..a19d7014 --- /dev/null +++ b/modules/catalogs/src/api/domain/payment-terms/__tests__/payment-term.aggregate.test.ts @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { PaymentTerm } from "../payment-term.aggregate"; +import { PaymentTermDueDays } from "../payment-term-due-days"; +import { PaymentTermPercentage } from "../payment-term-percentage"; +import { PaymentTermName } from "../payment-term-name"; + +const createRuleProps = (params: { dueDays: number; percentageValue: number; scale?: number }) => { + const dueDaysResult = PaymentTermDueDays.create(params.dueDays); + assert.equal(dueDaysResult.isSuccess, true); + + const percentageResult = PaymentTermPercentage.create({ + value: params.percentageValue, + scale: params.scale, + }); + assert.equal(percentageResult.isSuccess, true); + + return { + dueDays: dueDaysResult.data, + percentage: percentageResult.data, + }; +}; + +const createPaymentTerm = (rules = [createRuleProps({ dueDays: 0, percentageValue: 10000 })]) => { + const nameResult = PaymentTermName.create("Transferencia 30 dias"); + assert.equal(nameResult.isSuccess, true); + + const paymentTermResult = PaymentTerm.create( + { + companyId: UniqueID.generateNewID(), + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: false, + dueRules: rules, + }, + UniqueID.generateNewID() + ); + + assert.equal(paymentTermResult.isSuccess, true); + return paymentTermResult.data; +}; + +describe("PaymentTerm aggregate", () => { + test("rehydrate keeps dueRulesWereUpdated disabled", () => { + const paymentTerm = createPaymentTerm([ + createRuleProps({ dueDays: 30, percentageValue: 5000 }), + createRuleProps({ dueDays: 60, percentageValue: 5000 }), + ]); + + const rehydrated = PaymentTerm.rehydrate( + { + companyId: paymentTerm.companyId, + name: paymentTerm.name, + description: paymentTerm.description, + isActive: paymentTerm.isActive, + isSystem: paymentTerm.isSystem, + }, + paymentTerm.dueRules, + paymentTerm.id + ); + + assert.equal(rehydrated.dueRulesWereUpdated, false); + }); + + test("creates immediate payment term with 0 days and 100%", () => { + const paymentTerm = createPaymentTerm([createRuleProps({ dueDays: 0, percentageValue: 10000 })]); + + assert.equal(paymentTerm.dueRules.getAll().length, 1); + assert.equal(paymentTerm.dueRules.getAll()[0]?.dueDays.toPrimitive(), 0); + assert.equal(paymentTerm.dueRules.getAll()[0]?.percentage.value, 10000); + }); + + test("creates payment term with 30 days and 100%", () => { + const paymentTerm = createPaymentTerm([createRuleProps({ dueDays: 30, percentageValue: 10000 })]); + + assert.equal(paymentTerm.dueRules.getAll()[0]?.dueDays.toPrimitive(), 30); + }); + + test("creates payment term with 30/60/90 rules", () => { + const paymentTerm = createPaymentTerm([ + createRuleProps({ dueDays: 30, percentageValue: 3333 }), + createRuleProps({ dueDays: 60, percentageValue: 3333 }), + createRuleProps({ dueDays: 90, percentageValue: 3334 }), + ]); + + assert.deepEqual( + paymentTerm.dueRules.getAll().map((rule) => rule.dueDays.toPrimitive()), + [30, 60, 90] + ); + }); + + test("rejects empty due rules", () => { + const nameResult = PaymentTermName.create("Sin reglas"); + assert.equal(nameResult.isSuccess, true); + + const result = PaymentTerm.create({ + companyId: UniqueID.generateNewID(), + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: false, + dueRules: [], + }); + + assert.equal(result.isFailure, true); + }); + + test("rejects negative due days", () => { + const result = PaymentTermDueDays.create(-1); + assert.equal(result.isFailure, true); + }); + + test("rejects non-integer due days", () => { + const result = PaymentTermDueDays.create(1.5); + assert.equal(result.isFailure, true); + }); + + test("rejects duplicated due days", () => { + const nameResult = PaymentTermName.create("Duplicado"); + assert.equal(nameResult.isSuccess, true); + + const result = PaymentTerm.create({ + companyId: UniqueID.generateNewID(), + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: false, + dueRules: [ + createRuleProps({ dueDays: 30, percentageValue: 5000 }), + createRuleProps({ dueDays: 30, percentageValue: 5000 }), + ], + }); + + assert.equal(result.isFailure, true); + }); + + test("rejects 0.00 percent", () => { + const result = PaymentTermPercentage.create({ value: 0, scale: 2 }); + assert.equal(result.isFailure, true); + }); + + test("accepts 0.01 percent", () => { + const result = PaymentTermPercentage.create({ value: 1, scale: 2 }); + assert.equal(result.isSuccess, true); + }); + + test("accepts 100.00 percent", () => { + const result = PaymentTermPercentage.create({ value: 10000, scale: 2 }); + assert.equal(result.isSuccess, true); + }); + + test("rejects percentage greater than 100.00", () => { + const result = PaymentTermPercentage.create({ value: 10001, scale: 2 }); + assert.equal(result.isFailure, true); + }); + + test("rejects percentage scale different from 2", () => { + const result = PaymentTermPercentage.create({ value: 1000, scale: 3 }); + assert.equal(result.isFailure, true); + }); + + test("rejects sum 99.99", () => { + const nameResult = PaymentTermName.create("Suma 99.99"); + assert.equal(nameResult.isSuccess, true); + + const result = PaymentTerm.create({ + companyId: UniqueID.generateNewID(), + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: false, + dueRules: [ + createRuleProps({ dueDays: 30, percentageValue: 3333 }), + createRuleProps({ dueDays: 60, percentageValue: 3333 }), + createRuleProps({ dueDays: 90, percentageValue: 3333 }), + ], + }); + + assert.equal(result.isFailure, true); + }); + + test("rejects sum 100.01", () => { + const nameResult = PaymentTermName.create("Suma 100.01"); + assert.equal(nameResult.isSuccess, true); + + const result = PaymentTerm.create({ + companyId: UniqueID.generateNewID(), + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: false, + dueRules: [ + createRuleProps({ dueDays: 30, percentageValue: 3333 }), + createRuleProps({ dueDays: 60, percentageValue: 3333 }), + createRuleProps({ dueDays: 90, percentageValue: 3335 }), + ], + }); + + assert.equal(result.isFailure, true); + }); + + test("accepts sum 100.00", () => { + const paymentTerm = createPaymentTerm([ + createRuleProps({ dueDays: 30, percentageValue: 3333 }), + createRuleProps({ dueDays: 60, percentageValue: 3333 }), + createRuleProps({ dueDays: 90, percentageValue: 3334 }), + ]); + + assert.equal(paymentTerm.dueRules.totalPercentage(), 10000); + }); + + test("exposes due rules ordered by due days ascending", () => { + const paymentTerm = createPaymentTerm([ + createRuleProps({ dueDays: 90, percentageValue: 3334 }), + createRuleProps({ dueDays: 30, percentageValue: 3333 }), + createRuleProps({ dueDays: 60, percentageValue: 3333 }), + ]); + + assert.deepEqual( + paymentTerm.dueRules.getAll().map((rule) => rule.dueDays.toPrimitive()), + [30, 60, 90] + ); + }); +}); diff --git a/modules/catalogs/src/api/domain/payment-terms/payment-term-percentage.ts b/modules/catalogs/src/api/domain/payment-terms/payment-term-percentage.ts index 0661cac4..fe56fb43 100644 --- a/modules/catalogs/src/api/domain/payment-terms/payment-term-percentage.ts +++ b/modules/catalogs/src/api/domain/payment-terms/payment-term-percentage.ts @@ -1,6 +1,8 @@ import { Percentage, type PercentageProps, ValidationErrorCollection } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; +import { InvalidPaymentTermPercentageError } from "./errors"; + type PaymentTermPercentageProps = PercentageProps; export class PaymentTermPercentage extends Percentage { @@ -15,6 +17,18 @@ export class PaymentTermPercentage extends Percentage { ); } + if (value <= 0) { + return Result.fail( + new InvalidPaymentTermPercentageError("Payment term percentage must be greater than 0.00.") + ); + } + + if (value > 10000) { + return Result.fail( + new InvalidPaymentTermPercentageError("Payment term percentage cannot be greater than 100.00.") + ); + } + return Result.ok( new PaymentTermPercentage({ value, diff --git a/modules/catalogs/src/api/domain/payment-terms/payment-term.aggregate.ts b/modules/catalogs/src/api/domain/payment-terms/payment-term.aggregate.ts index e56cd439..21346e7f 100644 --- a/modules/catalogs/src/api/domain/payment-terms/payment-term.aggregate.ts +++ b/modules/catalogs/src/api/domain/payment-terms/payment-term.aggregate.ts @@ -2,6 +2,7 @@ import { AggregateRoot, type TextValue, type UniqueID } from "@repo/rdx-ddd"; import { type Maybe, Result } from "@repo/rdx-utils"; import { + PaymentTermCannotBeDeletedError, PaymentTermCannotBeDisabledError, PaymentTermCannotBeUpdatedError, PaymentTermDueRuleMismatch, @@ -32,6 +33,7 @@ export type PaymentTermInternalProps = Omit export class PaymentTerm extends AggregateRoot { private _dueRules: PaymentTermDueRules; + private _dueRulesWereUpdated = false; protected constructor( props: PaymentTermInternalProps, @@ -154,6 +156,10 @@ export class PaymentTerm extends AggregateRoot { return this._dueRules; } + public get dueRulesWereUpdated(): boolean { + return this._dueRulesWereUpdated; + } + public update(patchProps: PaymentTermPatchProps): Result { if (this.isSystem) { return Result.fail( @@ -167,23 +173,42 @@ export class PaymentTerm extends AggregateRoot { } let hasChanges = false; - const { dueRules, ...otherProps } = patchProps; - const candidateProps: PaymentTermInternalProps = { - ...this.props, - ...otherProps, - }; + if ( + patchProps.name !== undefined && + patchProps.name.toPrimitive() !== this.props.name.toPrimitive() + ) { + this.props.name = patchProps.name; + hasChanges = true; + } - // Aplicar cambios - Object.assign(this.props, candidateProps); - hasChanges = true; + if ( + patchProps.description !== undefined && + !PaymentTerm.sameDescription(this.props.description, patchProps.description) + ) { + this.props.description = patchProps.description; + hasChanges = true; + } + + if (patchProps.isActive !== undefined && this.props.isActive !== patchProps.isActive) { + this.props.isActive = patchProps.isActive; + hasChanges = true; + } + + this._dueRulesWereUpdated = false; - // Reemplazo de items (si se proporciona) if (patchProps.dueRules !== undefined) { + if (PaymentTerm.sameDueRules(this._dueRules, patchProps.dueRules)) { + return Result.ok(hasChanges); + } + const initializeResult = this.initializeRules(patchProps.dueRules); if (initializeResult.isFailure) { return Result.fail(initializeResult.error); } + + this._dueRulesWereUpdated = true; + hasChanges = true; } return Result.ok(hasChanges); @@ -216,4 +241,50 @@ export class PaymentTerm extends AggregateRoot { this.props.isActive = true; return Result.ok(true); } + + public ensureCanBeDeleted(): Result { + if (this.isSystem) { + return Result.fail( + new PaymentTermCannotBeDeletedError("System payment terms cannot be deleted.") + ); + } + + return Result.ok(); + } + + private static sameDescription(current: Maybe, next: Maybe): boolean { + return current.match( + (currentValue: TextValue) => + next.match( + (nextValue: TextValue) => currentValue.toPrimitive() === nextValue.toPrimitive(), + () => false + ), + () => next.isNone() + ); + } + + private static sameDueRules( + current: PaymentTermDueRules, + next: PaymentTermDueRulePatchProps[] + ): boolean { + const currentRules = current.getAll(); + const orderedNext = [...next].sort( + (first, second) => first.dueDays.toPrimitive() - second.dueDays.toPrimitive() + ); + + if (currentRules.length !== orderedNext.length) { + return false; + } + + return currentRules.every((rule, index) => { + const nextRule = orderedNext[index]; + + return ( + nextRule !== undefined && + rule.dueDays.toPrimitive() === nextRule.dueDays.toPrimitive() && + rule.percentage.value === nextRule.percentage.value && + rule.percentage.scale === nextRule.percentage.scale + ); + }); + } } diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts b/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts index b8018256..bb8d86cc 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts @@ -24,6 +24,7 @@ import type { IPaymentTermRepository } from "../../../application/payment-terms/ import { buildPaymentTermPersistenceMappers } from "./payment-term-persistence-mappers.di"; import { buildPaymentTermRepository } from "./payment-term-repositories.di"; +import { buildPaymentTermReferenceUsageChecker } from "../payment-term-reference-usage-checker"; export type PaymentTermsInternalDeps = { repository: IPaymentTermRepository; @@ -45,13 +46,14 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm const persistenceMappers = buildPaymentTermPersistenceMappers(); const repository = buildPaymentTermRepository({ database, mappers: persistenceMappers }); + const usageChecker = buildPaymentTermReferenceUsageChecker(database as Sequelize); const inputMappers = buildPaymentTermInputMappers(); const finder = buildPaymentTermFinder({ repository }); const creator = buildPaymentTermCreator({ repository }); const updater = buildPaymentTermUpdater({ repository }); - const deleter = buildPaymentTermDeleter({ repository }); + const deleter = buildPaymentTermDeleter({ repository, usageChecker }); const statusChanger = buildPaymentTermStatusChanger({ repository }); const snapshotBuilders = buildPaymentTermSnapshotBuilders(); diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.ts b/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.ts new file mode 100644 index 00000000..a2a3f8af --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.ts @@ -0,0 +1,65 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Sequelize, Transaction } from "sequelize"; + +import { + PaymentTermUsageChecker, + type PaymentTermUsageSource, +} from "../../application/payment-terms/services"; + +type CountableModel = { + count: (params: { where: Record; transaction?: Transaction }) => Promise; +}; + +type SequelizeModelsRegistry = Record; + +const buildCountUsageSource = + (params: { database: Sequelize; modelName: string; source: PaymentTermUsageSource }) => + async (input: { companyId: UniqueID; paymentTermId: UniqueID; transaction?: unknown }) => { + const { database, modelName, source } = params; + const { companyId, paymentTermId, transaction } = input; + + try { + const models = database.models as SequelizeModelsRegistry; + const model = models[modelName]; + + if (!model) { + return Result.ok(false); + } + + const count = await model.count({ + where: { + company_id: companyId.toString(), + payment_term_id: paymentTermId.toString(), + }, + transaction: transaction as Transaction | undefined, + }); + + return Result.ok(count > 0); + } catch (error: unknown) { + return Result.fail( + error instanceof Error + ? error + : new Error(`Failed checking ${source} payment term references.`) + ); + } + }; + +export const buildPaymentTermReferenceUsageChecker = (database: Sequelize) => { + return new PaymentTermUsageChecker({ + customersUsageFinder: { + existsByPaymentTermId: buildCountUsageSource({ + database, + modelName: "CustomerModel", + source: "customers", + }), + }, + proformasUsageFinder: { + existsByPaymentTermId: buildCountUsageSource({ + database, + modelName: "ProformaModel", + source: "proformas", + }), + }, + }); +}; diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-due-rule-domain.mapper.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-due-rule-domain.mapper.ts index d93316f6..708bc917 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-due-rule-domain.mapper.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-due-rule-domain.mapper.ts @@ -85,7 +85,7 @@ export class SequelizePaymentTermDueRuleDomainMapper extends SequelizeDomainMapp source: PaymentTermDueRule, params?: MapperParamsType ): Result { - const { errors, index, parent } = params as { + const { parent } = params as { index: number; parent: PaymentTerm; errors: ValidationErrorDetail[]; diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/models/sequelize-payment-term-due-rule.model.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/models/sequelize-payment-term-due-rule.model.ts index 6ea51632..778a29c6 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/models/sequelize-payment-term-due-rule.model.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/models/sequelize-payment-term-due-rule.model.ts @@ -99,6 +99,11 @@ export default (database: Sequelize) => { name: "idx_payment_term_due_rules_payment_term", fields: ["payment_term_id"], }, + { + name: "uq_payment_term_due_rules_payment_term_due_days", + fields: ["payment_term_id", "due_days"], + unique: true, + }, ], defaultScope: {}, diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.test.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.test.ts new file mode 100644 index 00000000..d36a0168 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.test.ts @@ -0,0 +1,397 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { PaymentTerm } from "../../../../../domain/payment-terms/payment-term.aggregate"; +import { PaymentTermDueDays } from "../../../../../domain/payment-terms/payment-term-due-days"; +import { PaymentTermPercentage } from "../../../../../domain/payment-terms/payment-term-percentage"; +import { PaymentTermDueRuleModel, PaymentTermModel } from "../models"; + +import { SequelizePaymentTermRepository } from "./sequelize-payment-term.repository"; + +const createRuleProps = (params: { dueDays: number; percentageValue: number }) => { + const dueDaysResult = PaymentTermDueDays.create(params.dueDays); + assert.equal(dueDaysResult.isSuccess, true); + + const percentageResult = PaymentTermPercentage.create({ + value: params.percentageValue, + scale: 2, + }); + assert.equal(percentageResult.isSuccess, true); + + return { + dueDays: dueDaysResult.data, + percentage: percentageResult.data, + }; +}; + +const createPaymentTerm = (companyId = UniqueID.generateNewID()) => { + const nameResult = Name.create("30/60"); + assert.equal(nameResult.isSuccess, true); + + const paymentTermResult = PaymentTerm.create( + { + companyId, + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: false, + dueRules: [ + createRuleProps({ dueDays: 30, percentageValue: 5000 }), + createRuleProps({ dueDays: 60, percentageValue: 5000 }), + ], + }, + UniqueID.generateNewID() + ); + + assert.equal(paymentTermResult.isSuccess, true); + return paymentTermResult.data; +}; + +const createRepository = (params?: { + mapToPersistence?: () => Result; + mapToDomain?: () => Result; +}) => + new SequelizePaymentTermRepository( + { + mapToPersistence: + params?.mapToPersistence ?? + (() => + Result.ok({ + id: "payment-term-1", + company_id: "company-1", + name: "30/60", + description: null, + is_active: true, + is_system: false, + due_rules: [ + { + due_id: "rule-1", + payment_term_id: "payment-term-1", + due_days: 30, + percentage_value: 5000, + percentage_scale: 2, + }, + ], + })), + mapToDomain: params?.mapToDomain ?? (() => Result.ok(createPaymentTerm())), + } as any, + {} as any, + { models: {} } as any + ); + +describe("SequelizePaymentTermRepository", () => { + test("create persists parent and due rules atomically through nested create", async () => { + const repository = createRepository(); + const paymentTerm = createPaymentTerm(); + const transaction = { id: "tx-1" }; + const calls: Array<{ payload: unknown; options: unknown }> = []; + const originalCreate = PaymentTermModel.create; + + (PaymentTermModel as any).create = async (payload: unknown, options: unknown) => { + calls.push({ payload, options }); + return {} as unknown; + }; + + try { + const result = await repository.create(paymentTerm, transaction as any); + + assert.equal(result.isSuccess, true); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0]?.options, { + include: [{ all: true }], + transaction, + }); + } finally { + (PaymentTermModel as any).create = originalCreate; + } + }); + + test("findByIdInCompany rehydrates payment term with due rules loaded", async () => { + const paymentTerm = createPaymentTerm(); + const repository = createRepository({ + mapToDomain: () => Result.ok(paymentTerm), + }); + const originalFindOne = PaymentTermModel.findOne; + const findCalls: unknown[] = []; + + (PaymentTermModel as any).findOne = async (options: unknown) => { + findCalls.push(options); + return { + id: paymentTerm.id.toString(), + company_id: paymentTerm.companyId.toString(), + due_rules: [], + }; + }; + + try { + const result = await repository.findByIdInCompany(paymentTerm.companyId, paymentTerm.id); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.isSome(), true); + assert.deepEqual(findCalls[0], { + where: { + id: paymentTerm.id.toString(), + company_id: paymentTerm.companyId.toString(), + }, + transaction: undefined, + include: [{ association: "due_rules" }], + }); + } finally { + (PaymentTermModel as any).findOne = originalFindOne; + } + }); + + test("findByIdInCompany preserves tenancy isolation in query", async () => { + const paymentTerm = createPaymentTerm(); + const otherCompanyId = UniqueID.generateNewID(); + const repository = createRepository(); + const originalFindOne = PaymentTermModel.findOne; + let whereArg: unknown; + + (PaymentTermModel as any).findOne = async (options: any) => { + whereArg = options.where; + return null; + }; + + try { + const result = await repository.findByIdInCompany(otherCompanyId, paymentTerm.id); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.isNone(), true); + assert.deepEqual(whereArg, { + id: paymentTerm.id.toString(), + company_id: otherCompanyId.toString(), + }); + } finally { + (PaymentTermModel as any).findOne = originalFindOne; + } + }); + + test("update without due rules changes keeps current collection", async () => { + const paymentTerm = createPaymentTerm(); + const nameResult = Name.create("60 dias"); + assert.equal(nameResult.isSuccess, true); + const updateResult = paymentTerm.update({ name: nameResult.data }); + assert.equal(updateResult.isSuccess, true); + + const repository = createRepository(); + const originalUpdate = PaymentTermModel.update; + const originalDestroy = PaymentTermDueRuleModel.destroy; + const originalBulkCreate = PaymentTermDueRuleModel.bulkCreate; + let destroyCalled = false; + let bulkCreateCalled = false; + + (PaymentTermModel as any).update = async () => [1]; + (PaymentTermDueRuleModel as any).destroy = async () => { + destroyCalled = true; + return 0; + }; + (PaymentTermDueRuleModel as any).bulkCreate = async () => { + bulkCreateCalled = true; + return []; + }; + + try { + const result = await repository.update(paymentTerm); + + assert.equal(result.isSuccess, true); + assert.equal(paymentTerm.dueRulesWereUpdated, false); + assert.equal(destroyCalled, false); + assert.equal(bulkCreateCalled, false); + } finally { + (PaymentTermModel as any).update = originalUpdate; + (PaymentTermDueRuleModel as any).destroy = originalDestroy; + (PaymentTermDueRuleModel as any).bulkCreate = originalBulkCreate; + } + }); + + test("update with new due rules replaces the whole collection", async () => { + const paymentTerm = createPaymentTerm(); + const updateResult = paymentTerm.update({ + dueRules: [createRuleProps({ dueDays: 90, percentageValue: 10000 })], + }); + assert.equal(updateResult.isSuccess, true); + + const repository = createRepository({ + mapToPersistence: () => + Result.ok({ + id: paymentTerm.id.toString(), + company_id: paymentTerm.companyId.toString(), + name: paymentTerm.name.toPrimitive(), + description: null, + is_active: paymentTerm.isActive, + is_system: paymentTerm.isSystem, + due_rules: [ + { + due_id: "rule-90", + payment_term_id: paymentTerm.id.toString(), + due_days: 90, + percentage_value: 10000, + percentage_scale: 2, + }, + ], + }), + }); + const transaction = { id: "tx-2" }; + const originalUpdate = PaymentTermModel.update; + const originalDestroy = PaymentTermDueRuleModel.destroy; + const originalBulkCreate = PaymentTermDueRuleModel.bulkCreate; + const destroyCalls: unknown[] = []; + const bulkCreateCalls: unknown[] = []; + + (PaymentTermModel as any).update = async () => [1]; + (PaymentTermDueRuleModel as any).destroy = async (options: unknown) => { + destroyCalls.push(options); + return 1; + }; + (PaymentTermDueRuleModel as any).bulkCreate = async (payload: unknown, options: unknown) => { + bulkCreateCalls.push({ payload, options }); + return []; + }; + + try { + const result = await repository.update(paymentTerm, transaction as any); + + assert.equal(result.isSuccess, true); + assert.equal(paymentTerm.dueRulesWereUpdated, true); + assert.deepEqual(destroyCalls[0], { + where: { payment_term_id: paymentTerm.id.toString() }, + transaction, + }); + assert.deepEqual(bulkCreateCalls[0], { + payload: [ + { + due_id: "rule-90", + payment_term_id: paymentTerm.id.toString(), + due_days: 90, + percentage_value: 10000, + percentage_scale: 2, + }, + ], + options: { transaction }, + }); + } finally { + (PaymentTermModel as any).update = originalUpdate; + (PaymentTermDueRuleModel as any).destroy = originalDestroy; + (PaymentTermDueRuleModel as any).bulkCreate = originalBulkCreate; + } + }); + + test("update with only due rules changes tolerates a no-op parent update", async () => { + const paymentTerm = createPaymentTerm(); + const updateResult = paymentTerm.update({ + dueRules: [createRuleProps({ dueDays: 90, percentageValue: 10000 })], + }); + assert.equal(updateResult.isSuccess, true); + + const repository = createRepository({ + mapToPersistence: () => + Result.ok({ + id: paymentTerm.id.toString(), + company_id: paymentTerm.companyId.toString(), + name: paymentTerm.name.toPrimitive(), + description: null, + is_active: paymentTerm.isActive, + is_system: paymentTerm.isSystem, + due_rules: [ + { + due_id: "rule-90", + payment_term_id: paymentTerm.id.toString(), + due_days: 90, + percentage_value: 10000, + percentage_scale: 2, + }, + ], + }), + }); + const originalUpdate = PaymentTermModel.update; + const originalFindOne = PaymentTermModel.findOne; + const originalDestroy = PaymentTermDueRuleModel.destroy; + const originalBulkCreate = PaymentTermDueRuleModel.bulkCreate; + let findOneCalled = false; + let destroyCalled = false; + let bulkCreateCalled = false; + + (PaymentTermModel as any).update = async () => [0]; + (PaymentTermModel as any).findOne = async () => { + findOneCalled = true; + return { + id: paymentTerm.id.toString(), + company_id: paymentTerm.companyId.toString(), + }; + }; + (PaymentTermDueRuleModel as any).destroy = async () => { + destroyCalled = true; + return 1; + }; + (PaymentTermDueRuleModel as any).bulkCreate = async () => { + bulkCreateCalled = true; + return []; + }; + + try { + const result = await repository.update(paymentTerm); + + assert.equal(result.isSuccess, true); + assert.equal(findOneCalled, true); + assert.equal(destroyCalled, true); + assert.equal(bulkCreateCalled, true); + } finally { + (PaymentTermModel as any).update = originalUpdate; + (PaymentTermModel as any).findOne = originalFindOne; + (PaymentTermDueRuleModel as any).destroy = originalDestroy; + (PaymentTermDueRuleModel as any).bulkCreate = originalBulkCreate; + } + }); + + test("update returns failure if inserting new due rules fails", async () => { + const paymentTerm = createPaymentTerm(); + const updateResult = paymentTerm.update({ + dueRules: [createRuleProps({ dueDays: 0, percentageValue: 10000 })], + }); + assert.equal(updateResult.isSuccess, true); + + const repository = createRepository({ + mapToPersistence: () => + Result.ok({ + id: paymentTerm.id.toString(), + company_id: paymentTerm.companyId.toString(), + name: paymentTerm.name.toPrimitive(), + description: null, + is_active: paymentTerm.isActive, + is_system: paymentTerm.isSystem, + due_rules: [ + { + due_id: "rule-0", + payment_term_id: paymentTerm.id.toString(), + due_days: 0, + percentage_value: 10000, + percentage_scale: 2, + }, + ], + }), + }); + const originalUpdate = PaymentTermModel.update; + const originalDestroy = PaymentTermDueRuleModel.destroy; + const originalBulkCreate = PaymentTermDueRuleModel.bulkCreate; + + (PaymentTermModel as any).update = async () => [1]; + (PaymentTermDueRuleModel as any).destroy = async () => 1; + (PaymentTermDueRuleModel as any).bulkCreate = async () => { + throw new Error("insert failed"); + }; + + try { + const result = await repository.update(paymentTerm); + + assert.equal(result.isFailure, true); + } finally { + (PaymentTermModel as any).update = originalUpdate; + (PaymentTermDueRuleModel as any).destroy = originalDestroy; + (PaymentTermDueRuleModel as any).bulkCreate = originalBulkCreate; + } + }); +}); diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts index 7d3a3eda..953e8984 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts @@ -16,7 +16,7 @@ import type { SequelizePaymentTermDomainMapper, SequelizePaymentTermSummaryMapper, } from "../mappers"; -import { PaymentTermModel } from "../models"; +import { PaymentTermDueRuleModel, PaymentTermModel } from "../models"; export class SequelizePaymentTermRepository extends SequelizeRepository @@ -51,18 +51,60 @@ export class SequelizePaymentTermRepository return Result.fail(dtoResult.error); } - const { id, ...payload } = dtoResult.data; - const [affected] = await PaymentTermModel.update(payload, { - where: { id }, - transaction, - individualHooks: true, - }); + const { id, due_rules, ...payload } = dtoResult.data; - if (affected === 0) { + if (due_rules === undefined) { return Result.fail( - new InfrastructureRepositoryError("Concurrency conflict or payment term not found") + new InfrastructureRepositoryError("Payment term due rules are required for persistence.") ); } + if (Object.keys(payload).length > 0) { + const [affected] = await PaymentTermModel.update(payload, { + where: { + id, + company_id: payload.company_id, + }, + transaction, + individualHooks: true, + }); + + if (affected === 0) { + if (!paymentTerm.dueRulesWereUpdated) { + return Result.fail( + new InfrastructureRepositoryError("Concurrency conflict or payment term not found") + ); + } + + const currentRow = await PaymentTermModel.findOne({ + where: { + id, + company_id: payload.company_id, + }, + transaction, + }); + + if (!currentRow) { + return Result.fail( + new InfrastructureRepositoryError("Concurrency conflict or payment term not found") + ); + } + } + } + + if (paymentTerm.dueRulesWereUpdated) { + await PaymentTermDueRuleModel.destroy({ + where: { + payment_term_id: id, + }, + transaction, + }); + + if (due_rules.length > 0) { + await PaymentTermDueRuleModel.bulkCreate(due_rules, { + transaction, + }); + } + } return Result.ok(); } catch (err: unknown) { @@ -166,6 +208,7 @@ export class SequelizePaymentTermRepository company_id: companyId.toString(), }, transaction, + include: [{ association: "due_rules" }], }); if (!row) {