import DineroFactory, { Currency, Dinero } from "dinero.js"; import Joi from "joi"; import { isNull } from "lodash"; import { NullOr } from "../../../../utilities"; import { adjustPrecision } from "../../../../utilities/helpers"; import { DomainError, handleDomainError } from "../errors"; import { RuleValidator } from "../RuleValidator"; import { CurrencyData } from "./CurrencyData"; import { Result } from "./Result"; import { IValueObjectOptions, ValueObject } from "./ValueObject"; export interface IMoneyValueOptions extends IValueObjectOptions { locale: string; } export const defaultMoneyValueOptions: IMoneyValueOptions = { locale: "es-ES", }; export interface MoneyValueObject { amount: number | null; scale: number; currency_code: string; } export type RoundingMode = | "HALF_ODD" | "HALF_EVEN" | "HALF_UP" | "HALF_DOWN" | "HALF_TOWARDS_ZERO" | "HALF_AWAY_FROM_ZERO" | "DOWN"; export interface IMoneyValueProps { amount: NullOr; currencyCode?: string; scale?: number; } const defaultMoneyValueProps = { amount: null, currencyCode: CurrencyData.DEFAULT_CURRENCY_CODE, scale: 2, }; interface IMoneyValue { toPrimitive(): NullOr; toPrimitives(): MoneyValueObject; isEmpty(): boolean; toString(): string; toJSON(): MoneyValueObject | {}; isNull(): boolean; getAmount(): number; getCurrency(): CurrencyData; getLocale(): string; getScale(): number; convertScale(newScale: number, roundingMode?: RoundingMode): MoneyValue; add(addend: MoneyValue): MoneyValue; subtract(subtrahend: MoneyValue): MoneyValue; multiply(multiplier: number, roundingMode?: RoundingMode): MoneyValue; divide(divisor: number, roundingMode?: RoundingMode): MoneyValue; percentage(percentage: number, roundingMode?: RoundingMode): MoneyValue; allocate(ratios: ReadonlyArray): MoneyValue[]; equalsTo(comparator: MoneyValue): boolean; lessThan(comparator: MoneyValue): boolean; lessThanOrEqual(comparator: MoneyValue): boolean; greaterThan(comparator: MoneyValue): boolean; greaterThanOrEqual(comparator: MoneyValue): boolean; isZero(): boolean; isPositive(): boolean; isNegative(): boolean; hasSameCurrency(comparator: MoneyValue): boolean; hasSameAmount(comparator: MoneyValue): boolean; toFormat(format?: string, roundingMode?: RoundingMode): string; toUnit(): number; toRoundedUnit(digits: number, roundingMode?: RoundingMode): number; toObject(): MoneyValueObject; toNumber(): number; } export class MoneyValue extends ValueObject implements IMoneyValue { public static readonly DEFAULT_SCALE = defaultMoneyValueProps.scale; public static readonly DEFAULT_CURRENCY_CODE = defaultMoneyValueProps.currencyCode; private readonly _isNull: boolean; private readonly _options: IMoneyValueOptions; protected static validate(amount: NullOr, options: IMoneyValueOptions) { const ruleNull = Joi.any() .optional() // <- undefined .valid(null); // <- null const ruleNumber = Joi.number().label(options.label ? options.label : "amount"); const rules = Joi.alternatives(ruleNull, ruleNumber); return RuleValidator.validate>(rules, amount); } protected static getMonetaryValueInfo(amount: string): [string, number] { // Divide la cadena de entrada en dos partes: valor y precisión const [valuePart, scalePart] = amount.split("."); // Calcula la precisión utilizada const scale = scalePart ? scalePart.length : 0; // Elimina cualquier carácter no numérico de la parte del valor y concaténalo const sanitizedValue = (valuePart + scalePart).replace(/[^0-9]/g, ""); return [sanitizedValue, scale]; } public static create( props: IMoneyValueProps = defaultMoneyValueProps, options = defaultMoneyValueOptions ) { if (props === null) { throw new Error(`InvalidParams: props params is missing`); } const { amount = defaultMoneyValueProps.amount, currencyCode = defaultMoneyValueProps.currencyCode, scale = defaultMoneyValueProps.scale, } = props || {}; const validationResult = MoneyValue.validate(amount, options); if (validationResult.isFailure) { return Result.fail( handleDomainError(DomainError.INVALID_INPUT_DATA, validationResult.error.message, options) ); } const _amount: NullOr = MoneyValue.sanitize(validationResult.object); const _currency = CurrencyData.createFromCode(currencyCode).object.code; const prop = DineroFactory({ amount: Number(_amount), currency: _currency as Currency, precision: scale, }).setLocale(options.locale); return Result.ok(new this(prop, isNull(_amount), options)); } public static createFromFormattedValue( value: NullOr, currencyCode: string, _options: IMoneyValueOptions = { locale: defaultMoneyValueOptions.locale, } ) { if (value === null || value === "") { return MoneyValue.create({ amount: null, scale: MoneyValue.DEFAULT_SCALE, currencyCode, }); } const valueStr = String(value); const [integerPart, decimalPart] = valueStr.split(","); let _amount = integerPart; let _scale = 2; if (decimalPart === undefined) { // 99 _scale = 0; } else { if (decimalPart === "") { // 99, _amount = integerPart + decimalPart.padEnd(1, "0"); _scale = 1; } if (decimalPart.length === 1) { // 99,1 _amount = integerPart + decimalPart.padEnd(1, "0"); _scale = 1; } else { if (decimalPart.length === 2) { // 99,12 _amount = integerPart + decimalPart.padEnd(2, "0"); _scale = 2; } else { if (decimalPart.length === 3) { // 99,123 _amount = integerPart + decimalPart.padEnd(3, "0"); _scale = 3; } else { if (decimalPart.length === 4) { // 99,1235 _amount = integerPart + decimalPart.padEnd(4, "0"); _scale = 4; } } } } } return MoneyValue.create( { amount: _amount, scale: _scale, currencyCode, }, _options ); } private static sanitize(amount: NullOr): NullOr { let _amount: NullOr = null; if (typeof amount === "string") { _amount = parseFloat(amount); } else { _amount = amount; } return _amount; } protected static createFromDinero(dinero: Dinero) { return Result.ok(new MoneyValue(dinero, false, defaultMoneyValueOptions)); } public static normalizeScale(objects: ReadonlyArray): MoneyValue[] { return DineroFactory.normalizePrecision(objects.map((object) => object.props)).map( (dinero) => MoneyValue.createFromDinero(dinero).object ); } public static minimum(objects: ReadonlyArray): MoneyValue { return MoneyValue.createFromDinero(DineroFactory.minimum(objects.map((object) => object.props))) .object; } public static maximum(objects: ReadonlyArray): MoneyValue { return MoneyValue.createFromDinero(DineroFactory.maximum(objects.map((object) => object.props))) .object; } private static _toString( value: NullOr, scale: number, locales?: Intl.LocalesArgument ): string { if (value === null) { return ""; } new Intl.NumberFormat(locales, { /*minimumSignificantDigits: scale, maximumSignificantDigits: scale, minimumFractionDigits: scale,*/ useGrouping: true, }).format(value === null ? 0 : adjustPrecision({ amount: value, scale })); const factor = Math.pow(10, scale); const amount = Number(value) / factor; return amount.toFixed(scale); } constructor(value: Dinero, isNull: boolean, options: IMoneyValueOptions) { super(value); this._isNull = Object.freeze(isNull); this._options = Object.freeze(options); } public isEmpty = (): boolean => { return this.isNull(); }; public toString(): string { return MoneyValue._toString( this.isNull() ? null : this.getAmount(), this.getScale(), this._options.locale ); } public toJSON() { return this._isNull ? {} : this.props?.toJSON(); } public toPrimitive(): NullOr { return this._isNull ? null : Number(this.props?.getAmount()); } public toPrimitives(): MoneyValueObject { return this.toObject(); } public isNull = (): boolean => { return this._isNull; }; public getAmount(): number { return this.props.getAmount(); } public getScale(): number { return this.props.getPrecision(); } public convertScale(newScale: number, roundingMode: RoundingMode = "HALF_UP"): MoneyValue { if (this._isNull) { return MoneyValue.create({ amount: null, scale: newScale, currencyCode: this.getCurrency().code, }).object; } else { return MoneyValue.createFromDinero(this.props.convertPrecision(newScale, roundingMode)) .object; } } public getCurrency(): CurrencyData { return CurrencyData.createFromCode(this.props.getCurrency()).object; } public getLocale(): string { return this.props.getLocale(); } public add(addend: MoneyValue): MoneyValue { return MoneyValue.createFromDinero(this.props.add(addend.props)).object; } public subtract(subtrahend: MoneyValue): MoneyValue { return MoneyValue.createFromDinero(this.props.subtract(subtrahend.props)).object; } public multiply(multiplier: number, roundingMode?: RoundingMode): MoneyValue { return MoneyValue.createFromDinero(this.props.multiply(multiplier, roundingMode)).object; } public divide(divisor: number, roundingMode?: RoundingMode): MoneyValue { return MoneyValue.createFromDinero(this.props.divide(divisor, roundingMode)).object; } public percentage(percentage: number, roundingMode?: RoundingMode): MoneyValue { return MoneyValue.createFromDinero(this.props.percentage(percentage, roundingMode)).object; } public allocate(ratios: ReadonlyArray): MoneyValue[] { return this.props.allocate(ratios).map((dinero) => MoneyValue.createFromDinero(dinero).object); } public equalsTo(comparator: MoneyValue): boolean { return this.props.equalsTo(comparator.props); } public lessThan(comparator: MoneyValue): boolean { return this.props.lessThan(comparator.props); } public lessThanOrEqual(comparator: MoneyValue): boolean { return this.props.lessThanOrEqual(comparator.props); } public greaterThan(comparator: MoneyValue): boolean { return this.props.greaterThan(comparator.props); } public greaterThanOrEqual(comparator: MoneyValue): boolean { return this.props.greaterThanOrEqual(comparator.props); } public isZero(): boolean { return this.props.isZero(); } public isPositive(): boolean { return this.props.isPositive(); } public isNegative(): boolean { return this.props.isNegative(); } public hasSameCurrency(comparator: MoneyValue): boolean { return this.props.hasSameCurrency(comparator.props); } public hasSameAmount(comparator: MoneyValue): boolean { return this.props.hasSameAmount(comparator.props); } public toFormat(format?: string, roundingMode?: RoundingMode): string { return this._isNull ? "" : this.props.toFormat(format, roundingMode); } public toUnit(): number { return this.props.toUnit(); } public toRoundedUnit(digits: number, roundingMode?: RoundingMode): number { return this.props.toRoundedUnit(digits, roundingMode); } public toObject(): MoneyValueObject { const obj = this.props.toObject(); return { amount: this._isNull ? null : obj.amount, scale: obj.precision, currency_code: String(obj.currency), }; } public toNumber(): number { return this.toUnit(); } }