/*
* Copyright (c) 2012 - 2014 Ngewi Fet <ngewif@gmail.com>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.gnucash.android.model;
import android.support.annotation.NonNull;
import android.util.Log;
import com.crashlytics.android.Crashlytics;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.util.Currency;
import java.util.Locale;
/**
* Money represents a money amount and a corresponding currency.
* Money internally uses {@link BigDecimal} to represent the amounts, which enables it
* to maintain high precision afforded by BigDecimal. Money objects are immutable and
* most operations return new Money objects.
* Money String constructors should not be passed any locale-formatted numbers. Only
* {@link Locale#US} is supported e.g. "2.45" will be parsed as 2.45 meanwhile
* "2,45" will be parsed to 245 although that could be a decimal in {@link Locale#GERMAN}
*
* @author Ngewi Fet<ngewif@gmail.com>
*
*/
public final class Money implements Comparable<Money>{
/**
* Currency of the account
*/
private Commodity mCommodity;
/**
* Amount value held by this object
*/
private BigDecimal mAmount;
/**
* Rounding mode to be applied when performing operations
* Defaults to {@link RoundingMode#HALF_EVEN}
*/
protected RoundingMode ROUNDING_MODE = RoundingMode.HALF_EVEN;
/**
* Default currency code (according ISO 4217)
* This is typically initialized to the currency of the device default locale,
* otherwise US dollars are used
*/
public static String DEFAULT_CURRENCY_CODE = "USD";
/**
* A zero instance with the currency of the default locale.
* This can be used anywhere where a starting amount is required without having to create a new object
*/
private static Money sDefaultZero;
/**
* Returns a Money instance initialized to the local currency and value 0
* @return Money instance of value 0 in locale currency
*/
public static Money getZeroInstance(){
if (sDefaultZero == null) {
sDefaultZero = new Money(BigDecimal.ZERO, Commodity.DEFAULT_COMMODITY);
}
return sDefaultZero;
}
/**
* Returns the {@link BigDecimal} from the {@code numerator} and {@code denominator}
* @param numerator Number of the fraction
* @param denominator Denominator of the fraction
* @return BigDecimal representation of the number
*/
public static BigDecimal getBigDecimal(long numerator, long denominator) {
int scale;
if (numerator == 0 && denominator == 0) {
denominator = 1;
}
scale = Integer.numberOfTrailingZeros((int)denominator);
return new BigDecimal(BigInteger.valueOf(numerator), scale);
}
/**
* Creates a new money amount
* @param amount Value of the amount
* @param commodity Commodity of the money
*/
public Money(BigDecimal amount, Commodity commodity){
this.mCommodity = commodity;
setAmount(amount); //commodity has to be set first. Because we use it's scale
}
/**
* Overloaded constructor.
* Accepts strings as arguments and parses them to create the Money object
* @param amount Numerical value of the Money
* @param currencyCode Currency code as specified by ISO 4217
*/
public Money(String amount, String currencyCode){
//commodity has to be set first
mCommodity = Commodity.getInstance(currencyCode);
setAmount(new BigDecimal(amount));
}
/**
* Constructs a new money amount given the numerator and denominator of the amount.
* The rounding mode used for the division is {@link BigDecimal#ROUND_HALF_EVEN}
* @param numerator Numerator as integer
* @param denominator Denominator as integer
* @param currencyCode 3-character currency code string
*/
public Money(long numerator, long denominator, String currencyCode){
mAmount = getBigDecimal(numerator, denominator);
setCommodity(currencyCode);
}
/**
* Copy constructor.
* Creates a new Money object which is a clone of <code>money</code>
* @param money Money instance to be cloned
*/
public Money(Money money){
setCommodity(money.getCommodity());
setAmount(money.asBigDecimal());
}
/**
* Creates a new Money instance with 0 amount and the <code>currencyCode</code>
* @param currencyCode Currency to use for this money instance
* @return Money object with value 0 and currency <code>currencyCode</code>
*/
public static Money createZeroInstance(@NonNull String currencyCode){
Commodity commodity = Commodity.getInstance(currencyCode);
return new Money(BigDecimal.ZERO, commodity);
}
/**
* Returns the commodity used by the Money
* @return Instance of commodity
*/
public Commodity getCommodity(){
return mCommodity;
}
/**
* Returns a new <code>Money</code> object the currency specified by <code>currency</code>
* and the same value as this one. No value exchange between the currencies is performed.
* @param commodity {@link Commodity} to assign to new <code>Money</code> object
* @return {@link Money} object with same value as current object, but with new <code>currency</code>
*/
public Money withCurrency(@NonNull Commodity commodity){
return new Money(mAmount, commodity);
}
/**
* Sets the commodity for the Money
* <p>No currency conversion is performed</p>
* @param commodity Commodity instance
*/
private void setCommodity(@NonNull Commodity commodity){
this.mCommodity = commodity;
}
/**
* Sets the commodity for the Money
* @param currencyCode ISO 4217 currency code
*/
private void setCommodity(@NonNull String currencyCode){
mCommodity = Commodity.getInstance(currencyCode);
}
/**
* Returns the GnuCash format numerator for this amount.
* <p>Example: Given an amount 32.50$, the numerator will be 3250</p>
* @return GnuCash numerator for this amount
*/
public long getNumerator() {
try {
return mAmount.scaleByPowerOfTen(getScale()).longValueExact();
} catch (ArithmeticException e) {
String msg = "Currency " + mCommodity.getCurrencyCode() +
" with scale " + getScale() +
" has amount " + mAmount.toString();
Crashlytics.log(msg);
Log.e(getClass().getName(), msg);
throw e;
}
}
/**
* Returns the GnuCash amount format denominator for this amount
* <p>The denominator is 10 raised to the power of number of fractional digits in the currency</p>
* @return GnuCash format denominator
*/
public long getDenominator() {
int scale = getScale();
return BigDecimal.ONE.scaleByPowerOfTen(scale).longValueExact();
}
/**
* Returns the scale (precision) used for the decimal places of this amount.
* <p>The scale used depends on the commodity</p>
* @return Scale of amount as integer
*/
private int getScale() {
int scale = mCommodity.getSmallestFractionDigits();
if (scale < 0) {
scale = mAmount.scale();
}
if (scale < 0) {
scale = 0;
}
return scale;
}
/**
* Returns the amount represented by this Money object
* <p>The scale and rounding mode of the returned value are set to that of this Money object</p>
* @return {@link BigDecimal} valure of amount in object
*/
public BigDecimal asBigDecimal() {
return mAmount.setScale(mCommodity.getSmallestFractionDigits(), RoundingMode.HALF_EVEN);
}
/**
* Returns the amount this object
* @return Double value of the amount in the object
*/
public double asDouble(){
return mAmount.doubleValue();
}
/**
* An alias for {@link #toPlainString()}
* @return Money formatted as a string (excludes the currency)
*/
public String asString(){
return toPlainString();
}
/**
* Returns a string representation of the Money object formatted according to
* the <code>locale</code> and includes the currency symbol.
* The output precision is limited to the number of fractional digits supported by the currency
* @param locale Locale to use when formatting the object
* @return String containing formatted Money representation
*/
public String formattedString(Locale locale){
NumberFormat currencyFormat = NumberFormat.getCurrencyInstance(locale);
String symbol;
//if we want to show US Dollars for locales which also use Dollars, for example, Canada
if (mCommodity.equals(Commodity.USD) && !locale.equals(Locale.US)) {
symbol = "US$";
} else {
symbol = mCommodity.getSymbol();
}
DecimalFormatSymbols decimalFormatSymbols = ((DecimalFormat)currencyFormat).getDecimalFormatSymbols();
decimalFormatSymbols.setCurrencySymbol(symbol);
((DecimalFormat)currencyFormat).setDecimalFormatSymbols(decimalFormatSymbols);
currencyFormat.setMinimumFractionDigits(mCommodity.getSmallestFractionDigits());
currencyFormat.setMaximumFractionDigits(mCommodity.getSmallestFractionDigits());
return currencyFormat.format(asDouble());
/*
// old currency formatting code
NumberFormat formatter = NumberFormat.getInstance(locale);
formatter.setMinimumFractionDigits(mCommodity.getSmallestFractionDigits());
formatter.setMaximumFractionDigits(mCommodity.getSmallestFractionDigits());
Currency currency = Currency.getInstance(mCommodity.getCurrencyCode());
return formatter.format(asDouble()) + " " + currency.getSymbol(locale);
*/
}
/**
* Equivalent to calling formattedString(Locale.getDefault())
* @return String formatted Money representation in default locale
*/
public String formattedString(){
return formattedString(Locale.getDefault());
}
/**
* Returns a new Money object whose amount is the negated value of this object amount.
* The original <code>Money</code> object remains unchanged.
* @return Negated <code>Money</code> object
*/
public Money negate(){
return new Money(mAmount.negate(), mCommodity);
}
/**
* Sets the amount value of this <code>Money</code> object
* @param amount {@link BigDecimal} amount to be set
*/
private void setAmount(@NonNull BigDecimal amount) {
mAmount = amount.setScale(mCommodity.getSmallestFractionDigits(), ROUNDING_MODE);
}
/**
* Returns a new <code>Money</code> object whose value is the sum of the values of
* this object and <code>addend</code>.
*
* @param addend Second operand in the addition.
* @return Money object whose value is the sum of this object and <code>money</code>
* @throws CurrencyMismatchException if the <code>Money</code> objects to be added have different Currencies
*/
public Money add(Money addend){
if (!mCommodity.equals(addend.mCommodity))
throw new CurrencyMismatchException();
BigDecimal bigD = mAmount.add(addend.mAmount);
return new Money(bigD, mCommodity);
}
/**
* Returns a new <code>Money</code> object whose value is the difference of the values of
* this object and <code>subtrahend</code>.
* This object is the minuend and the parameter is the subtrahend
* @param subtrahend Second operand in the subtraction.
* @return Money object whose value is the difference of this object and <code>subtrahend</code>
* @throws CurrencyMismatchException if the <code>Money</code> objects to be added have different Currencies
*/
public Money subtract(Money subtrahend){
if (!mCommodity.equals(subtrahend.mCommodity))
throw new CurrencyMismatchException();
BigDecimal bigD = mAmount.subtract(subtrahend.mAmount);
return new Money(bigD, mCommodity);
}
/**
* Returns a new <code>Money</code> object whose value is the quotient of the values of
* this object and <code>divisor</code>.
* This object is the dividend and <code>divisor</code> is the divisor
* <p>This method uses the rounding mode {@link BigDecimal#ROUND_HALF_EVEN}</p>
* @param divisor Second operand in the division.
* @return Money object whose value is the quotient of this object and <code>divisor</code>
* @throws CurrencyMismatchException if the <code>Money</code> objects to be added have different Currencies
*/
public Money divide(Money divisor){
if (!mCommodity.equals(divisor.mCommodity))
throw new CurrencyMismatchException();
BigDecimal bigD = mAmount.divide(divisor.mAmount, mCommodity.getSmallestFractionDigits(), ROUNDING_MODE);
return new Money(bigD, mCommodity);
}
/**
* Returns a new <code>Money</code> object whose value is the quotient of the division of this objects
* value by the factor <code>divisor</code>
* @param divisor Second operand in the addition.
* @return Money object whose value is the quotient of this object and <code>divisor</code>
*/
public Money divide(int divisor){
Money moneyDiv = new Money(new BigDecimal(divisor), mCommodity);
return divide(moneyDiv);
}
/**
* Returns a new <code>Money</code> object whose value is the product of the values of
* this object and <code>money</code>.
*
* @param money Second operand in the multiplication.
* @return Money object whose value is the product of this object and <code>money</code>
* @throws CurrencyMismatchException if the <code>Money</code> objects to be added have different Currencies
*/
public Money multiply(Money money){
if (!mCommodity.equals(money.mCommodity))
throw new CurrencyMismatchException();
BigDecimal bigD = mAmount.multiply(money.mAmount);
return new Money(bigD, mCommodity);
}
/**
* Returns a new <code>Money</code> object whose value is the product of this object
* and the factor <code>multiplier</code>
* <p>The currency of the returned object is the same as the current object</p>
* @param multiplier Factor to multiply the amount by.
* @return Money object whose value is the product of this objects values and <code>multiplier</code>
*/
public Money multiply(int multiplier){
Money moneyFactor = new Money(new BigDecimal(multiplier), mCommodity);
return multiply(moneyFactor);
}
/**
* Returns a new <code>Money</code> object whose value is the product of this object
* and the factor <code>multiplier</code>
* @param multiplier Factor to multiply the amount by.
* @return Money object whose value is the product of this objects values and <code>multiplier</code>
*/
public Money multiply(@NonNull BigDecimal multiplier){
return new Money(mAmount.multiply(multiplier), mCommodity);
}
/**
* Returns true if the amount held by this Money object is negative
* @return <code>true</code> if the amount is negative, <code>false</code> otherwise.
*/
public boolean isNegative(){
return mAmount.compareTo(BigDecimal.ZERO) == -1;
}
/**
* Returns the string representation of the amount (without currency) of the Money object.
* <p>This string is not locale-formatted. The decimal operator is a period (.)</p>
* @return String representation of the amount (without currency) of the Money object
*/
public String toPlainString(){
return mAmount.setScale(mCommodity.getSmallestFractionDigits(), ROUNDING_MODE).toPlainString();
}
/**
* Returns the string representation of the Money object (value + currency) formatted according
* to the default locale
* @return String representation of the amount formatted with default locale
*/
@Override
public String toString() {
return formattedString(Locale.getDefault());
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (mAmount.hashCode());
result = prime * result + (mCommodity.hashCode());
return result;
}
/** //FIXME: equality failing for money objects
* Two Money objects are only equal if their amount (value) and currencies are equal
* @param obj Object to compare with
* @return <code>true</code> if the objects are equal, <code>false</code> otherwise
*/
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Money other = (Money) obj;
if (!mAmount.equals(other.mAmount))
return false;
if (!mCommodity.equals(other.mCommodity))
return false;
return true;
}
@Override
public int compareTo(@NonNull Money another) {
if (!mCommodity.equals(another.mCommodity))
throw new CurrencyMismatchException();
return mAmount.compareTo(another.mAmount);
}
/**
* Returns a new instance of {@link Money} object with the absolute value of the current object
* @return Money object with absolute value of this instance
*/
public Money abs() {
return new Money(mAmount.abs(), mCommodity);
}
/**
* Checks if the value of this amount is exactly equal to zero.
* @return {@code true} if this money amount is zero, {@code false} otherwise
*/
public boolean isAmountZero() {
return mAmount.compareTo(BigDecimal.ZERO) == 0;
}
public class CurrencyMismatchException extends IllegalArgumentException{
@Override
public String getMessage() {
return "Cannot perform operation on Money instances with different currencies";
}
}
}