Coverage Summary for Class: MoneyKt (com.georgeci.moneysurfer.domain.primitives)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| MoneyKt |
100%
(1/1)
|
100%
(4/4)
|
100%
(20/20)
|
100%
(19/19)
|
100%
(117/117)
|
package com.georgeci.moneysurfer.domain.primitives
import kotlin.jvm.JvmInline
import kotlin.math.roundToLong
@JvmInline
value class Money(val minor: Long) : Comparable<Money> {
// --- arithmetic ---
// Checked: a crafted amount (see CSV import, issue #159) must never silently
// wrap the cached account balance. Overflow throws rather than corrupting the
// total. In practice values are capped at [MAX_MINOR] on entry, so honest
// balances never come close to the Long limit and these never throw.
operator fun plus(other: Money): Money = Money(addExact(minor, other.minor))
operator fun minus(other: Money): Money = Money(subtractExact(minor, other.minor))
operator fun unaryMinus(): Money = Money(negateExact(minor))
operator fun times(multiplier: Int): Money = Money(multiplyExact(minor, multiplier.toLong()))
operator fun div(divisor: Int): Money = Money(minor / divisor)
// --- compare ---
override fun compareTo(other: Money): Int =
minor.compareTo(other.minor)
// --- utils ---
fun isZero(): Boolean = minor == 0L
fun isPositive(): Boolean = minor > 0
fun isNegative(): Boolean = minor < 0
// negateExact guards Long.MIN_VALUE, whose naive abs() wraps back to itself.
fun abs(): Money = if (minor < 0) Money(negateExact(minor)) else this
override fun toString(): String = "Money($minor)"
companion object {
const val MINOR_PER_MAJOR = 100.0
/**
* Domain cap on `|minor|`: 10^15 minor units (10 trillion major). Larger
* than any honest balance yet small enough that summing many rows stays
* well inside [Long]. Enforced at the CSV import boundary so a crafted
* `amount_minor` (e.g. [Long.MIN_VALUE]) can never enter the domain and
* wrap a cached balance. See issue #159.
*/
const val MAX_MINOR: Long = 1_000_000_000_000_000L
private const val CENTS_RANGE_MAX = 99
private const val MINOR_FACTOR = 100
fun fromMinor(minor: Long): Money = Money(minor)
fun fromMajor(
units: Long,
cents: Int = 0,
): Money {
require(cents in 0..CENTS_RANGE_MAX)
// Checked: fromMajor(Long.MAX_VALUE) must not silently wrap before the
// arithmetic operators ever run. See issue #159.
return Money(addExact(multiplyExact(units, MINOR_FACTOR.toLong()), cents.toLong()))
}
fun fromDouble(value: Double): Money {
return Money((value * MINOR_FACTOR).roundToLong())
}
fun zero(): Money = Money(0)
}
}
// Overflow-checked Long arithmetic (Math.*Exact is JVM-only; Money is common
// KMP code). Each mirrors the JDK algorithm and throws on overflow instead of
// wrapping. Kept file-private to the Money value type.
private const val LONG_HALF_BITS = 32
private fun addExact(a: Long, b: Long): Long {
val r = a + b
// Overflow iff the operands share a sign that the result does not.
if ((a xor r) and (b xor r) < 0L) {
throw ArithmeticException("Money overflow: $a + $b")
}
return r
}
private fun subtractExact(a: Long, b: Long): Long {
val r = a - b
// Overflow iff the operands differ in sign and the result takes b's sign.
if ((a xor b) and (a xor r) < 0L) {
throw ArithmeticException("Money overflow: $a - $b")
}
return r
}
private fun negateExact(a: Long): Long {
if (a == Long.MIN_VALUE) {
throw ArithmeticException("Money overflow: -($a)")
}
return -a
}
private fun multiplyExact(a: Long, b: Long): Long {
val r = a * b
val absHigh = (if (a < 0) -a else a) or (if (b < 0) -b else b)
// Fast path: when both magnitudes fit in 31 bits the product cannot overflow.
if (absHigh ushr (LONG_HALF_BITS - 1) == 0L) return r
val divisionMismatch = b != 0L && r / b != a
val minTimesNegativeOne = a == Long.MIN_VALUE && b == -1L
if (divisionMismatch || minTimesNegativeOne) {
throw ArithmeticException("Money overflow: $a * $b")
}
return r
}