Files
household-bot/packages/domain/src/money.ts

298 lines
8.1 KiB
TypeScript

import { DOMAIN_ERROR_CODE, DomainError } from './errors'
export const CURRENCIES = ['GEL', 'USD'] as const
export const FX_RATE_SCALE_MICROS = 1_000_000n
export type CurrencyCode = (typeof CURRENCIES)[number]
const MAJOR_MONEY_PATTERN = /^([+-]?)(\d+)(?:\.(\d{1,2}))?$/
function isIntegerNumber(value: number): boolean {
return Number.isFinite(value) && Number.isInteger(value)
}
function parseMinorUnits(value: bigint | number | string): bigint {
if (typeof value === 'bigint') {
return value
}
if (typeof value === 'number') {
if (!isIntegerNumber(value)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT,
'Money minor amount must be an integer'
)
}
return BigInt(value)
}
if (!/^[+-]?\d+$/.test(value)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT,
'Money minor amount string must contain only integer digits'
)
}
return BigInt(value)
}
function parseMajorUnits(value: string): bigint {
const trimmed = value.trim()
const match = MAJOR_MONEY_PATTERN.exec(trimmed)
if (!match) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_MONEY_MAJOR_FORMAT,
`Invalid money major format: ${value}`
)
}
const [, sign, wholePart, fractionalPart = ''] = match
const normalizedFraction = fractionalPart.padEnd(2, '0')
const composed = `${wholePart}${normalizedFraction}`
const signPrefix = sign === '-' ? '-' : ''
return BigInt(`${signPrefix}${composed}`)
}
function ensureSupportedCurrency(currency: string): CurrencyCode {
if ((CURRENCIES as readonly string[]).includes(currency)) {
return currency as CurrencyCode
}
throw new DomainError(DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT, `Unsupported currency: ${currency}`)
}
function formatMajorUnits(minor: bigint): string {
const sign = minor < 0n ? '-' : ''
const absolute = minor < 0n ? -minor : minor
const whole = absolute / 100n
const fraction = absolute % 100n
const fractionString = fraction.toString().padStart(2, '0')
return `${sign}${whole.toString()}.${fractionString}`
}
function divideRoundedHalfUp(dividend: bigint, divisor: bigint): bigint {
if (divisor === 0n) {
throw new DomainError(DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT, 'Division by zero')
}
const sign = dividend < 0n ? -1n : 1n
const absoluteDividend = dividend < 0n ? -dividend : dividend
const quotient = absoluteDividend / divisor
const remainder = absoluteDividend % divisor
if (remainder * 2n >= divisor) {
return (quotient + 1n) * sign
}
return quotient * sign
}
export class Money {
readonly amountMinor: bigint
readonly currency: CurrencyCode
private constructor(amountMinor: bigint, currency: CurrencyCode) {
this.amountMinor = amountMinor
this.currency = currency
}
static fromMinor(amountMinor: bigint | number | string, currency: CurrencyCode = 'GEL'): Money {
const supportedCurrency = ensureSupportedCurrency(currency)
return new Money(parseMinorUnits(amountMinor), supportedCurrency)
}
static fromMajor(amountMajor: string, currency: CurrencyCode = 'GEL'): Money {
const supportedCurrency = ensureSupportedCurrency(currency)
return new Money(parseMajorUnits(amountMajor), supportedCurrency)
}
static zero(currency: CurrencyCode = 'GEL'): Money {
return Money.fromMinor(0n, currency)
}
add(other: Money): Money {
this.assertSameCurrency(other)
return new Money(this.amountMinor + other.amountMinor, this.currency)
}
subtract(other: Money): Money {
this.assertSameCurrency(other)
return new Money(this.amountMinor - other.amountMinor, this.currency)
}
multiplyBy(multiplier: bigint | number): Money {
const parsedMultiplier = typeof multiplier === 'number' ? BigInt(multiplier) : multiplier
if (typeof multiplier === 'number' && !isIntegerNumber(multiplier)) {
throw new DomainError(DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT, 'Multiplier must be an integer')
}
return new Money(this.amountMinor * parsedMultiplier, this.currency)
}
splitEvenly(parts: number): readonly Money[] {
if (!isIntegerNumber(parts) || parts <= 0) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_PARTS,
'Split parts must be a positive integer'
)
}
return this.splitByWeights(Array.from({ length: parts }, () => 1n))
}
splitByWeights(weightsInput: readonly (bigint | number)[]): readonly Money[] {
if (weightsInput.length === 0) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'At least one weight is required'
)
}
const weights = weightsInput.map((weight) => {
const parsed = typeof weight === 'number' ? BigInt(weight) : weight
if (typeof weight === 'number' && !isIntegerNumber(weight)) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'Split weights must be integers'
)
}
if (parsed <= 0n) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'Split weights must be positive'
)
}
return parsed
})
const totalWeight = weights.reduce((sum, current) => sum + current, 0n)
if (totalWeight <= 0n) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'Total split weight must be positive'
)
}
const isNegative = this.amountMinor < 0n
const absoluteAmount = isNegative ? -this.amountMinor : this.amountMinor
const baseAllocations = weights.map((weight) => (absoluteAmount * weight) / totalWeight)
const remainders = weights.map((weight, index) => ({
index,
remainder: (absoluteAmount * weight) % totalWeight
}))
const allocatedBase = baseAllocations.reduce((sum, current) => sum + current, 0n)
const leftover = absoluteAmount - allocatedBase
remainders.sort((left, right) => {
if (left.remainder === right.remainder) {
return left.index - right.index
}
return left.remainder > right.remainder ? -1 : 1
})
const finalAllocations = [...baseAllocations]
for (let offset = 0n; offset < leftover; offset += 1n) {
const target = remainders[Number(offset)]
if (!target) {
break
}
const currentAllocation = finalAllocations[target.index]
if (currentAllocation === undefined) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_SPLIT_WEIGHTS,
'Unexpected split allocation index state'
)
}
finalAllocations[target.index] = currentAllocation + 1n
}
return finalAllocations.map(
(allocatedMinor) => new Money(isNegative ? -allocatedMinor : allocatedMinor, this.currency)
)
}
equals(other: Money): boolean {
return this.currency === other.currency && this.amountMinor === other.amountMinor
}
isNegative(): boolean {
return this.amountMinor < 0n
}
isZero(): boolean {
return this.amountMinor === 0n
}
compare(other: Money): -1 | 0 | 1 {
this.assertSameCurrency(other)
if (this.amountMinor < other.amountMinor) {
return -1
}
if (this.amountMinor > other.amountMinor) {
return 1
}
return 0
}
toMajorString(): string {
return formatMajorUnits(this.amountMinor)
}
toJSON(): { amountMinor: string; currency: CurrencyCode } {
return {
amountMinor: this.amountMinor.toString(),
currency: this.currency
}
}
private assertSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new DomainError(
DOMAIN_ERROR_CODE.CURRENCY_MISMATCH,
`Money operation currency mismatch: ${this.currency} vs ${other.currency}`
)
}
}
}
export function convertMoney(
amount: Money,
targetCurrency: CurrencyCode,
rateMicros: bigint
): Money {
if (rateMicros <= 0n) {
throw new DomainError(
DOMAIN_ERROR_CODE.INVALID_MONEY_AMOUNT,
`Exchange rate must be positive: ${rateMicros.toString()}`
)
}
if (amount.currency === targetCurrency) {
return amount
}
const convertedMinor = divideRoundedHalfUp(amount.amountMinor * rateMicros, FX_RATE_SCALE_MICROS)
return Money.fromMinor(convertedMinor, targetCurrency)
}