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.
This commit is contained in:
David Arranz 2026-08-16 00:36:31 +02:00
parent 13cd45ea37
commit 97174e580d
15 changed files with 1146 additions and 27 deletions

View File

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

View File

@ -1,4 +1,4 @@
import type { PaymentTerm } from "../../../domain";
import type { PaymentTerm } from "../../../../domain";
import type { PaymentTermPublicModel } from "../models";
export class PaymentTermPublicModelMapper {

View File

@ -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<string, PaymentTerm>();
private readonly deleted = new Set<string>();
async create(paymentTerm: PaymentTerm): Promise<Result<void, Error>> {
this.items.set(paymentTerm.id.toString(), paymentTerm);
return Result.ok();
}
async update(paymentTerm: PaymentTerm): Promise<Result<void, Error>> {
this.items.set(paymentTerm.id.toString(), paymentTerm);
return Result.ok();
}
async deleteByIdInCompany(companyId: UniqueID, id: UniqueID): Promise<Result<boolean, Error>> {
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<Result<boolean, Error>> {
const paymentTerm = this.items.get(id.toString());
return Result.ok(
Boolean(paymentTerm && paymentTerm.companyId.toString() === companyId.toString())
);
}
async getByIdInCompany(companyId: UniqueID, id: UniqueID): Promise<Result<PaymentTerm, Error>> {
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<Result<Maybe<PaymentTerm>, 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<Result<Collection<PaymentTermSummary>, Error>> {
return Result.ok(new Collection<PaymentTermSummary>([]));
}
}
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);
});
});

View File

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

View File

@ -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<Result<PaymentTerm, Error>> {
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

View File

@ -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<Result<PaymentTermUsageDetail[], Error>>;
}
export class PaymentTermUsageChecker implements IPaymentTermUsageChecker {
public constructor(
private readonly deps: {
customersUsageFinder: {
existsByPaymentTermId(params: {
companyId: UniqueID;
paymentTermId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
};
proformasUsageFinder: {
existsByPaymentTermId(params: {
companyId: UniqueID;
paymentTermId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
};
}
) {}
public async findUsage(params: {
companyId: UniqueID;
paymentTermId: UniqueID;
transaction?: unknown;
}): Promise<Result<PaymentTermUsageDetail[], Error>> {
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);
}
}

View File

@ -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]
);
});
});

View File

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

View File

@ -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<IPaymentTermCreateProps, "dueRules">
export class PaymentTerm extends AggregateRoot<PaymentTermInternalProps> {
private _dueRules: PaymentTermDueRules;
private _dueRulesWereUpdated = false;
protected constructor(
props: PaymentTermInternalProps,
@ -154,6 +156,10 @@ export class PaymentTerm extends AggregateRoot<PaymentTermInternalProps> {
return this._dueRules;
}
public get dueRulesWereUpdated(): boolean {
return this._dueRulesWereUpdated;
}
public update(patchProps: PaymentTermPatchProps): Result<boolean, Error> {
if (this.isSystem) {
return Result.fail(
@ -167,23 +173,42 @@ export class PaymentTerm extends AggregateRoot<PaymentTermInternalProps> {
}
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<PaymentTermInternalProps> {
this.props.isActive = true;
return Result.ok(true);
}
public ensureCanBeDeleted(): Result<void, Error> {
if (this.isSystem) {
return Result.fail(
new PaymentTermCannotBeDeletedError("System payment terms cannot be deleted.")
);
}
return Result.ok();
}
private static sameDescription(current: Maybe<TextValue>, next: Maybe<TextValue>): 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
);
});
}
}

View File

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

View File

@ -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<string, string>; transaction?: Transaction }) => Promise<number>;
};
type SequelizeModelsRegistry = Record<string, CountableModel | undefined>;
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",
}),
},
});
};

View File

@ -85,7 +85,7 @@ export class SequelizePaymentTermDueRuleDomainMapper extends SequelizeDomainMapp
source: PaymentTermDueRule,
params?: MapperParamsType
): Result<PaymentTermDueRuleCreationAttributes, Error> {
const { errors, index, parent } = params as {
const { parent } = params as {
index: number;
parent: PaymentTerm;
errors: ValidationErrorDetail[];

View File

@ -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: {},

View File

@ -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<any, Error>;
mapToDomain?: () => Result<PaymentTerm, Error>;
}) =>
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;
}
});
});

View File

@ -16,7 +16,7 @@ import type {
SequelizePaymentTermDomainMapper,
SequelizePaymentTermSummaryMapper,
} from "../mappers";
import { PaymentTermModel } from "../models";
import { PaymentTermDueRuleModel, PaymentTermModel } from "../models";
export class SequelizePaymentTermRepository
extends SequelizeRepository<PaymentTerm>
@ -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) {