diff --git a/server/src/contexts/sales/infrastructure/Quote.repository.ts b/server/src/contexts/sales/infrastructure/Quote.repository.ts index e006b6d..c7cfeae 100644 --- a/server/src/contexts/sales/infrastructure/Quote.repository.ts +++ b/server/src/contexts/sales/infrastructure/Quote.repository.ts @@ -1,5 +1,5 @@ import { ISequelizeAdapter, SequelizeRepository } from "@/contexts/common/infrastructure/sequelize"; -import { UniqueID } from "@shared/contexts"; +import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts"; import { ModelDefined, Transaction } from "sequelize"; import { IQuoteRepository } from "../domain"; diff --git a/server/src/contexts/sales/infrastructure/sequelize/quote.model.ts b/server/src/contexts/sales/infrastructure/sequelize/quote.model.ts index 2415eb5..2cc9358 100644 --- a/server/src/contexts/sales/infrastructure/sequelize/quote.model.ts +++ b/server/src/contexts/sales/infrastructure/sequelize/quote.model.ts @@ -114,7 +114,7 @@ export default (sequelize: Sequelize) => { }, discount: { - type: new DataTypes.BIGINT(), + type: new DataTypes.SMALLINT(), allowNull: true, }, diff --git a/server/src/contexts/sales/infrastructure/sequelize/quoteItem.model.ts b/server/src/contexts/sales/infrastructure/sequelize/quoteItem.model.ts index 322377a..bd58032 100644 --- a/server/src/contexts/sales/infrastructure/sequelize/quoteItem.model.ts +++ b/server/src/contexts/sales/infrastructure/sequelize/quoteItem.model.ts @@ -79,7 +79,7 @@ export default (sequelize: Sequelize) => { allowNull: true, }, discount: { - type: DataTypes.BIGINT(), + type: new DataTypes.SMALLINT(), allowNull: true, }, total_price: { diff --git a/shared/lib/contexts/common/domain/entities/MoneyValue.ts b/shared/lib/contexts/common/domain/entities/MoneyValue.ts index 04cf3c9..c63d739 100644 --- a/shared/lib/contexts/common/domain/entities/MoneyValue.ts +++ b/shared/lib/contexts/common/domain/entities/MoneyValue.ts @@ -1,9 +1,9 @@ /* eslint-disable no-use-before-define */ -import DineroFactory, { Dinero } from "dinero.js"; +import DineroFactory, { Currency, Dinero } from "dinero.js"; import Joi from "joi"; import { isNull } from "lodash"; -import { NullOr } from "../../../../utilities"; +import { NullOr, UndefinedOr } from "../../../../utilities"; import { RuleValidator } from "../RuleValidator"; import { CurrencyData } from "./CurrencyData"; import { Result } from "./Result"; @@ -49,7 +49,7 @@ const defaultMoneyValueProps = { }; interface IMoneyValue { - toPrimitive(): number; + toPrimitive(): UndefinedOr; toPrimitives(): MoneyValueObject; isEmpty(): boolean; toString(): string; @@ -148,10 +148,11 @@ export class MoneyValue extends ValueObject implements IMoneyValue { } const _amount: NullOr = MoneyValue.sanitize(validationResult.object); + const _currency = CurrencyData.createFromCode(currencyCode).object.code; const prop = DineroFactory({ - amount: !isNull(_amount) ? _amount : options.defaultValue, - currency: CurrencyData.createFromCode(currencyCode).object.code, + amount: !isNull(_amount) ? Number(_amount) : options.defaultValue, + currency: _currency as Currency, precision, }).setLocale(options.locale); @@ -208,8 +209,8 @@ export class MoneyValue extends ValueObject implements IMoneyValue { return this._isNull ? {} : this.props?.toJSON(); } - public toPrimitive(): number { - return this.toUnit(); + public toPrimitive(): UndefinedOr { + return this._isNull ? undefined : Number(this.props?.getAmount()); } public toPrimitives(): MoneyValueObject { diff --git a/shared/lib/contexts/common/domain/entities/Percentage.test.ts b/shared/lib/contexts/common/domain/entities/Percentage.test.ts new file mode 100644 index 0000000..34728cf --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Percentage.test.ts @@ -0,0 +1,42 @@ +import { Percentage } from "./Percentage"; + +describe("Percentage", () => { + test("should create an instance with default values", () => { + const percentage = Percentage.create(); + expect(percentage.isSuccess).toBe(true); + expect(percentage.object.amount).toBe(0); + expect(percentage.object.scale).toBe(2); + }); + + test("should create an instance with specific values", () => { + const percentage = Percentage.create({ amount: 50, scale: 1 }); + expect(percentage.isSuccess).toBe(true); + expect(percentage.object.amount).toBe(50); + expect(percentage.object.scale).toBe(1); + }); + + test("should fail to create an instance with invalid values", () => { + const percentage = Percentage.create({ amount: 15000, scale: 2 }); + expect(percentage.isFailure).toBe(true); + }); + + test("should convert scale and check range", () => { + const percentage = Percentage.create({ amount: 9999, scale: 2 }).object; + + const converted = percentage.convertScale(0); + expect(converted.amount).toBe(100); + expect(converted.scale).toBe(0); + + expect(() => { + percentage.convertScale(3); + }).toThrow("Scale out of range"); + }); + + test("should return primitive value", () => { + const percentage = Percentage.create({ amount: 9234, scale: 2 }).object; + expect(percentage.toPrimitive()).toBe(9234); + + const convertedHalfUp = percentage.convertScale(1); + expect(convertedHalfUp.toPrimitive()).toBe(9230); + }); +}); diff --git a/shared/lib/contexts/common/domain/entities/Percentage.ts b/shared/lib/contexts/common/domain/entities/Percentage.ts index 1dd6672..9ab9b5b 100644 --- a/shared/lib/contexts/common/domain/entities/Percentage.ts +++ b/shared/lib/contexts/common/domain/entities/Percentage.ts @@ -5,51 +5,87 @@ import { DomainError, handleDomainError } from "../errors"; import { RuleValidator } from "../RuleValidator"; import { INullableValueObjectOptions, NullableValueObject } from "./NullableValueObject"; import { Result } from "./Result"; +import { ResultCollection } from "./ResultCollection"; + +const DEFAULT_SCALE = 2; export interface IPercentageOptions extends INullableValueObjectOptions {} export interface IPercentageProps { amount: NullOr; - precision?: number; + scale?: number; } interface IPercentage { amount: NullOr; - precision: number; + scale: number; } export interface PercentageObject { amount: number; - precision: number; + scale: number; } const defaultPercentageProps = { amount: 0, - precision: 0, + scale: DEFAULT_SCALE, }; export class Percentage extends NullableValueObject { - public static readonly DEFAULT_PRECISION = 2; + public static readonly DEFAULT_SCALE = DEFAULT_SCALE; public static readonly MIN_VALUE = 0; public static readonly MAX_VALUE = 100; + public static readonly MIN_SCALE = 0; + public static readonly MAX_SCALE = 2; + private readonly _isNull: boolean; private readonly _options: IPercentageOptions; - protected static validate(value: NullOr, options: IPercentageOptions) { + protected static validate( + value: NullOr, + scale: NullOr, + options: IPercentageOptions + ) { const ruleNull = RuleValidator.RULE_ALLOW_NULL_OR_UNDEFINED.default( defaultPercentageProps.amount ); - const rule = Joi.number() - .min(Percentage.MIN_VALUE) - .max(Percentage.MAX_VALUE) - + const ruleScale = Joi.number() + .min(Percentage.MIN_SCALE) + .max(Percentage.MAX_SCALE) .label(options.label ? options.label : "percentage"); - const rules = Joi.alternatives(ruleNull, rule); + const validationResults = new ResultCollection([ + RuleValidator.validate>( + Joi.alternatives(ruleNull, RuleValidator.RULE_IS_TYPE_NUMBER), + value + ), + RuleValidator.validate>( + Joi.alternatives(ruleNull, RuleValidator.RULE_IS_TYPE_NUMBER, ruleScale), + scale + ), + ]); - return RuleValidator.validate>(rules, value); + if (validationResults.hasSomeFaultyResult()) { + return validationResults.getFirstFaultyResult(); + } + + // Convert the value to a number if it's a string + let numericValue = typeof value === "string" ? parseInt(value, 10) : Number(value); + + // Check if scale is null, and set to default if so + let numericScale = isNull(scale) ? Percentage.DEFAULT_SCALE : Number(scale); + + // Calculate the adjusted value + const adjustedValue = numericValue / Math.pow(10, numericScale); + + // Check if the adjusted value is within the specified range + if (adjustedValue < Percentage.MIN_VALUE || adjustedValue > Percentage.MAX_VALUE) { + return Result.fail(new Error(`Value with scale is out of range: ${adjustedValue}`)); + } + + return Result.ok(); } public static create( @@ -60,15 +96,14 @@ export class Percentage extends NullableValueObject { throw new Error(`InvalidParams: props params is missing`); } - const { amount = defaultPercentageProps.amount, precision = defaultPercentageProps.precision } = - props; + const { amount = defaultPercentageProps.amount, scale = defaultPercentageProps.scale } = props; const _options = { label: "percentage", ...options, }; - const validationResult = Percentage.validate(amount, _options); + const validationResult = Percentage.validate(amount, scale, _options); if (validationResult.isFailure) { return Result.fail( @@ -76,17 +111,17 @@ export class Percentage extends NullableValueObject { ); } - let _amount: NullOr = Percentage.sanitize(validationResult.object); + let _amount: NullOr = Percentage._sanitize(amount); const _props = { amount: isNull(_amount) ? 0 : _amount, - precision, + scale, }; return Result.ok(new this(_props, isNull(_amount), options)); } - private static sanitize(value: NullOr): NullOr { + private static _sanitize(value: NullOr): NullOr { let _value: NullOr = null; if (typeof value === "string") { @@ -98,6 +133,21 @@ export class Percentage extends NullableValueObject { return _value; } + private static _toNumber(value: NullOr, scale: number): number { + if (isNull(value)) { + return 0; + } + + const factor = Math.pow(10, scale); + const amount = Number(value) / factor; + return Number(amount.toFixed(scale)); + } + + private static _isWithinRange(value: NullOr, scale: number): boolean { + const _value = Percentage._toNumber(value, scale); + return _value >= Percentage.MIN_VALUE && _value <= Percentage.MAX_VALUE; + } + constructor(percentage: IPercentage, isNull: boolean, options: IPercentageOptions) { super(percentage); this._isNull = Object.freeze(isNull); @@ -108,16 +158,16 @@ export class Percentage extends NullableValueObject { return this.isNull() ? null : Number(this.props?.amount); } - get precision(): number { - return this.isNull() ? 0 : Number(this.props?.precision); + get scale(): number { + return this.isNull() ? 0 : Number(this.props?.scale); } public getAmount(): NullOr { return this.isNull() ? null : Number(this.props?.amount); } - public getPrecision(): number { - return this.isNull() ? 0 : Number(this.props?.precision); + public getScale(): number { + return this.isNull() ? 0 : Number(this.props?.scale); } public isEmpty = (): boolean => { @@ -129,21 +179,19 @@ export class Percentage extends NullableValueObject { }; public toNumber(): number { - if (this.isNull()) { - return 0; - } - - const factor = Math.pow(10, this.precision); - const amount = Number(this.amount) / factor; - return Number(amount.toFixed(this.precision)); + return Percentage._toNumber(this.scale, this.scale); } public toString(): string { return this.isNull() ? "" : String(this.toNumber()); } - public toPrimitive(): number { - return this.toNumber(); + public toPrimitive(): NullOr { + if (this.scale !== Percentage.DEFAULT_SCALE) { + return this.convertScale(Percentage.DEFAULT_SCALE).toPrimitive(); + } else { + return this.amount; + } } public toPrimitives() { @@ -152,12 +200,38 @@ export class Percentage extends NullableValueObject { public toObject(): PercentageObject { return { - amount: this.amount ? this.amount : 0, - precision: this.precision, + amount: this.isNull() ? 0 : Number(this.amount), + scale: this.scale, }; } - public hasSamePrecision(quantity: Percentage) { - return this.precision === quantity.precision; + public convertScale(newScale: number): Percentage { + if (newScale < Percentage.MIN_SCALE || newScale > Percentage.MAX_SCALE) { + throw new Error(`Scale out of range: ${newScale}`); + } + + if (this.isNull()) { + return new Percentage({ amount: null, scale: newScale }, true, this._options); + } + + const oldFactor = Math.pow(10, this.scale); + const value = Number(this.amount) / oldFactor; + + const newFactor = Math.pow(10, newScale); + const newValue = Math.round(value * newFactor); + + if (!Percentage._isWithinRange(newValue, newScale)) { + throw new Error(`Value out of range after conversion: ${newValue} ${newScale}`); + } + + return new Percentage({ amount: newValue, scale: newScale }, false, this._options); + } + + public hasSameScale(quantity: Percentage) { + return this.scale === quantity.scale; + } + + public isWithinRange(): boolean { + return Percentage._isWithinRange(this.amount, this.scale); } } diff --git a/shared/lib/contexts/common/domain/entities/ValueObject.ts b/shared/lib/contexts/common/domain/entities/ValueObject.ts index a897429..e219a00 100644 --- a/shared/lib/contexts/common/domain/entities/ValueObject.ts +++ b/shared/lib/contexts/common/domain/entities/ValueObject.ts @@ -1,6 +1,6 @@ import { shallowEqual } from "shallow-equal-object"; -export type Primitive = string | boolean | number; +export type Primitive = string | boolean | number | null | undefined; export interface IValueObjectOptions { label?: string;