mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 00:14:03 +00:00
298 lines
8.1 KiB
TypeScript
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)
|
|
}
|