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:
parent
13cd45ea37
commit
97174e580d
@ -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 });
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { PaymentTerm } from "../../../domain";
|
||||
import type { PaymentTerm } from "../../../../domain";
|
||||
import type { PaymentTermPublicModel } from "../models";
|
||||
|
||||
export class PaymentTermPublicModelMapper {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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[];
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user