// Percentages as exact basis points, and the operations that apply them.
//
// A percentage stated by a tax authority or a price list is a decimal with a
// small number of places, so it is stored as basis points -- an integer number
// of hundredths of a percent -- rather than as a Double. 8.25% is 825 basis
// points, and applying it to an amount stays exact until the single rounding
// step at the end.
///|
/// A percentage as an exact number of basis points: 1% is 100 bp.
pub(all) struct Percent {
bp : Int64
} derive(Eq, Debug)
///|
pub extend Percent with Eq::{not_equal, equal}
///|
pub extend Percent with @debug.Debug::{to_repr}
///|
let big_hundred : @bigint.BigInt = @bigint.BigInt::from_int(100)
///|
pub fn Percent::of_bp(bp : Int64) -> Percent {
{ bp, }
}
///|
/// A whole number of percent, e.g. `of_whole(8L)` is 8%.
pub fn Percent::of_whole(whole : Int64) -> Percent {
{ bp: whole * 100L, }
}
///|
pub fn Percent::zero() -> Percent {
{ bp: 0L, }
}
///|
pub fn Percent::bp(self : Percent) -> Int64 {
self.bp
}
///|
pub fn Percent::is_zero(self : Percent) -> Bool {
self.bp == 0L
}
///|
pub fn Percent::as_double(self : Percent) -> Double {
self.bp.to_double() / 10000.0
}
///|
/// `825` basis points becomes `8.25%`, and trailing zeros are dropped.
pub fn Percent::text(self : Percent) -> String {
let negative = self.bp < 0L
let magnitude = if negative {
@bigint.BigInt::from_int64(self.bp).neg()
} else {
@bigint.BigInt::from_int64(self.bp)
}
let whole = magnitude.div(big_hundred)
let fraction = magnitude.mod(big_hundred).to_int()
let body = if fraction == 0 {
whole.to_string()
} else if fraction % 10 == 0 {
"\{whole}.\{fraction / 10}"
} else if fraction < 10 {
"\{whole}.0\{fraction}"
} else {
"\{whole}.\{fraction}"
}
if negative {
"-\{body}%"
} else {
"\{body}%"
}
}
///|
/// Reads a percentage such as `8.25%`, `8.25` or `-8`.
///
/// A leading or trailing percent sign is accepted and ignored. At most two
/// decimal places are accepted, because a basis point is the smallest unit the
/// type holds; anything finer is refused rather than silently rounded.
pub fn Percent::parse(text : StringView) -> Percent raise MoneyError {
let raw = text.trim()
let unsigned = match raw.strip_suffix("%") {
Some(inner) => inner.trim()
None =>
match raw.strip_prefix("%") {
Some(inner) => inner.trim()
None => raw
}
}
if unsigned.is_empty() {
raise InvalidAmount(raw.to_owned(), "empty percentage")
}
let negative = unsigned.has_prefix("-")
let digits_text = if negative || unsigned.has_prefix("+") {
unsigned[1:]
} else {
unsigned
}
if digits_text.is_empty() {
raise InvalidAmount(raw.to_owned(), "no digits after the sign")
}
let (whole_text, fraction_text) = match digits_text.find(".") {
Some(dot) => (digits_text[0:dot], digits_text[dot + 1:])
None => (digits_text, "")
}
let fraction_digits = fraction_text.length()
if fraction_digits > 2 {
raise InvalidAmount(
raw.to_owned(),
"at most two decimal places are supported",
)
}
if digits_text.contains(".") && whole_text.is_empty() && fraction_digits == 0 {
raise InvalidAmount(raw.to_owned(), "no digits")
}
let whole = if whole_text.is_empty() {
0L
} else {
parse_int64_or_fail(whole_text, raw)
}
let fraction = if fraction_digits == 0 {
0L
} else {
let digits = parse_int64_or_fail(fraction_text, raw)
if fraction_digits == 1 {
digits * 10L
} else {
digits
}
}
let scaled = mul_checked(whole, 100L, "percentage")
let magnitude = add_checked(scaled, fraction, "percentage")
Percent::of_bp(if negative { 0L - magnitude } else { magnitude })
}
///|
/// Parses an integer, reporting the original text when it does not fit.
fn parse_int64_or_fail(
text : StringView,
original : StringView,
) -> Int64 raise MoneyError {
@string.parse_int64(text) catch {
_ => raise InvalidAmount(original.to_owned(), "not a number")
}
}
///|
/// A ratio as a percentage, rounded to the nearest basis point.
pub fn Percent::of_ratio(
numerator : Int64,
denominator : Int64,
mode~ : RoundingMode = HalfUp,
) -> Percent raise MoneyError {
{ bp: mul_div_round(numerator, 10000L, denominator, mode), }
}
///|
/// The part of an amount that the percentage represents.
///
/// This is the rounding boundary of a tax or discount calculation: the product
/// is exact and exactly one rounding step is applied.
pub fn Money::percent(
self : Money,
percent : Percent,
mode~ : RoundingMode = HalfUp,
) -> Money raise MoneyError {
{
minor: mul_div_round(self.minor, percent.bp(), 10000L, mode),
currency: self.currency,
}
}
///|
/// The amount plus the percentage, e.g. net plus tax.
pub fn Money::add_percent(
self : Money,
percent : Percent,
mode~ : RoundingMode = HalfUp,
) -> Money raise MoneyError {
let part = self.percent(percent, mode~)
self.add(part)
}
///|
/// The amount with the percentage taken off, e.g. a discount.
pub fn Money::sub_percent(
self : Money,
percent : Percent,
mode~ : RoundingMode = HalfUp,
) -> Money raise MoneyError {
let part = self.percent(percent, mode~)
self.sub(part)
}