Java Transaction Amount (Money) Class

When writing applications that need to track financial transactions most developers use BigDecimal to ensure that rounding is done properly and that there is no danger of arithmetic overflow or precision and scale being lost when performing arithmetic operations.

Using BigDecimal for tracking financial transactions is fine until you need to take into account other aspect of a financial transaction such as:

  • the source currency of the transaction,
  • whether its a debit or credit,
  • if the transaction is taxable i.e does it attract some kind of sales tax such as VAT or GST, and
  • the reporting currency of the transaction

In this case it is better to wrap the Big Decimal using the decorator pattern to handle some of this complexity for you. Below are the classes TransactionAmountTaxable or TransactionAmount which can be used. It would be good to have the two classes inherit from each other but they are used as @Embedded classes in @Entity jpa classes and a limitation on @Embeddable classes currently means that inheritance does not work well so we settle for a common interface instead.

The classes will check that the amounts are of the same currency before performing any calculation. The reporting currency for each amount may be different for each transaction amount and it is assumed that the leftmost object is the desired, end-reporting currency amount. (In fact the reporting currency can be completely removed as you may wish to simply calculate the reporting amount dynamically from the source currency amount by doing an exchange rate look up when necessary. This is also great if you expect the reporting currency to change frequently.)

The empty constructor is necessitated by the JPA requiresments.

 

 
@Embeddable
public class TransactionAmountTaxable implements Serializable,TransactionAmountInterface {

    @ManyToOne
    private Currency sourceCurrency;
    
    @ManyToOne
    private Currency reportingCurrency;
    @Column(scale = 4, precision = 12)
    private BigDecimal exchangeRate;

    @Column(scale = 4, precision = 12)
    private BigDecimal amountSourceCurrency;
    
    @Column(scale = 4, precision = 12)
    private BigDecimal vatSourceCurrency;
    
    @Transient
    private BigDecimal amountReportingCurrency;
    
    @Transient
    private BigDecimal vatReportingCurrency;
    //set precision at some hopefully improbably large number
    @Transient
    private MathContext DEFAULTCONTEXT = new MathContext(12, RoundingMode.HALF_DOWN);

    public TransactionAmountTaxable() {
        this.amountSourceCurrency = BigDecimal.ZERO;
        this.amountReportingCurrency = BigDecimal.ZERO;
        this.vatSourceCurrency = BigDecimal.ZERO;
        this.vatReportingCurrency = BigDecimal.ZERO;
        this.exchangeRate = BigDecimal.ONE;
    }

    public TransactionAmountTaxable(Currency sourceCurrency) {
        this.amountSourceCurrency = BigDecimal.ZERO;
        this.amountReportingCurrency = BigDecimal.ZERO;
        this.vatSourceCurrency = BigDecimal.ZERO;
        this.vatReportingCurrency = BigDecimal.ZERO;
        this.exchangeRate = BigDecimal.ONE;
        this.sourceCurrency=sourceCurrency;
        this.reportingCurrency=sourceCurrency;
    }    
    
    public TransactionAmountTaxable(BigDecimal amount, BigDecimal vat, BigDecimal reportingAmount, 
            Currency sourceCurrency, Currency reportingCurrency) throws InvalidStateException {
        this.sourceCurrency = sourceCurrency;
        this.vatSourceCurrency = vat.setScale(sourceCurrency.getDefaultFractionDigits(), 
                DEFAULTCONTEXT.getRoundingMode());
        this.reportingCurrency = reportingCurrency;
        this.amountSourceCurrency = amount.setScale(sourceCurrency.getDefaultFractionDigits(), 
                DEFAULTCONTEXT.getRoundingMode());
        this.amountReportingCurrency = reportingAmount.setScale(reportingCurrency.getDecimalPlaces(),
                DEFAULTCONTEXT.getRoundingMode());
        if (amount.compareTo(BigDecimal.ZERO) != 0) {
            this.exchangeRate = this.amountReportingCurrency.divide(this.amountSourceCurrency, 
                    DEFAULTCONTEXT);    
        } else if (this.amountSourceCurrency.equals(this.amountReportingCurrency)) {
            this.exchangeRate = BigDecimal.ONE;
        } else{
            throw new InvalidStateException("Error calculating exchange rate");
        }       
        this.vatReportingCurrency=vatSourceCurrency.multiply(exchangeRate);
    }

    public static TransactionAmountTaxable newTransactionAmountTaxableVatRate(BigDecimal amount, BigDecimal vatRate, 
            BigDecimal reportingAmount, Currency sourceCurrency, Currency reportingCurrency) throws InvalidStateException {
        BigDecimal vatSrc = amount.multiply(vatRate).setScale(sourceCurrency.getDefaultFractionDigits(),
                RoundingMode.HALF_DOWN);
        return  new TransactionAmountTaxable(amount,vatSrc,reportingAmount,sourceCurrency,reportingCurrency);
    }    
    
    public TransactionAmountTaxable(double amount, double vat, double reportingAmount, 
            Currency sourceCurrency, Currency reportingCurrency) throws InvalidStateException {
        this.sourceCurrency = sourceCurrency;
        this.reportingCurrency = reportingCurrency;

        this.vatSourceCurrency = new BigDecimal(vat).setScale(sourceCurrency.getDefaultFractionDigits(), 
                DEFAULTCONTEXT.getRoundingMode());
        BigDecimal tmpAmount = new BigDecimal(amount);
        this.amountSourceCurrency = tmpAmount.setScale(sourceCurrency.getDefaultFractionDigits(), 
                DEFAULTCONTEXT.getRoundingMode());
        this.amountReportingCurrency = new BigDecimal(reportingAmount).setScale(reportingCurrency.getDecimalPlaces(),
                DEFAULTCONTEXT.getRoundingMode());

        if (amount != 0) {
            this.exchangeRate = this.amountReportingCurrency.divide(this.amountSourceCurrency, 
                    DEFAULTCONTEXT);    
        } else if (this.amountSourceCurrency.equals(this.amountReportingCurrency)) {
            this.exchangeRate = BigDecimal.ONE;
        } else{
            throw new InvalidStateException("Error calculating exchange rate");
        }         
        this.vatReportingCurrency = this.vatSourceCurrency.multiply(exchangeRate);
    }

    public TransactionAmountTaxable(Currency sourceCurrency, Currency reportingCurrency) {
        this.sourceCurrency = sourceCurrency;
        this.vatSourceCurrency = BigDecimal.ZERO.setScale(sourceCurrency.getDefaultFractionDigits(),
                DEFAULTCONTEXT.getRoundingMode());
        this.reportingCurrency = reportingCurrency;
        BigDecimal tmpAmount = BigDecimal.ZERO;
        this.amountSourceCurrency = tmpAmount.setScale(sourceCurrency.getDefaultFractionDigits(),
                DEFAULTCONTEXT.getRoundingMode());
        this.amountReportingCurrency = BigDecimal.ZERO.setScale(reportingCurrency.getDefaultFractionDigits(), 
                DEFAULTCONTEXT.getRoundingMode());
        this.exchangeRate = BigDecimal.ONE;
        this.vatReportingCurrency = this.vatSourceCurrency.multiply(exchangeRate);
    }

    public boolean isLikeCurrency(TransactionAmountTaxable money) throws ConversionException {
        if (this.sourceCurrency == null) {
            throw new ConversionException("currency.invalid");
        }
        if (money == null) {
            throw new ConversionException("currency.invalid");
        }
        return money.getSourceCurrency().equals(this.sourceCurrency);
    }

    public boolean isLikeCurrency(Currency currency) throws ConversionException {
        if (this.sourceCurrency == null || currency == null) {
            throw new ConversionException("currency.invalid");
        }
        return currency.equals(this.sourceCurrency);
    }

    @Override
    public BigDecimal getAmountSourceCurrency() {
        return this.amountSourceCurrency;
    }

    @Override
    public BigDecimal getAmountReportingCurrency() {
        if (this.amountReportingCurrency == null) {
            this.onLoad();
        }          
        return this.amountReportingCurrency;
    }

    @Override
    public BigDecimal getTotalSourceCurrency() {
        return this.amountSourceCurrency.add(this.vatSourceCurrency);
    }

    @Override
    public BigDecimal getTotalReportingCurrency() {
        if (this.amountReportingCurrency == null) {
            this.onLoad();
        }          
        return this.amountReportingCurrency.add(this.vatReportingCurrency);
    }

    @Override
    public Currency getSourceCurrency() {
        return sourceCurrency;
    }

    @Override
    public int hashCode() {
        int hash = (int) (amountSourceCurrency.hashCode() ^ (amountSourceCurrency.hashCode() >>> 32));
        return hash;
    }

    @Override
    public boolean equals(Object other) {
        return (other instanceof TransactionAmountTaxable && equals((TransactionAmountTaxable) other));
    }

    public boolean equals(TransactionAmountTaxable other) {
        return (sourceCurrency.equals(other.getSourceCurrency()) && (amountSourceCurrency.equals(other.getAmountSourceCurrency()))
                && vatSourceCurrency.equals(other.getVatSourceCurrency()));
    }

    public TransactionAmountTaxable add(TransactionAmountTaxable other) throws ConversionException, InvalidStateException {
        isLikeCurrency(other);
        return newTransactionAmountTaxable(amountSourceCurrency.add(other.amountSourceCurrency, DEFAULTCONTEXT),
                vatSourceCurrency.add(other.vatSourceCurrency, DEFAULTCONTEXT));
    }

    private TransactionAmountTaxable newTransactionAmountTaxable(BigDecimal amount, BigDecimal vat) throws InvalidStateException {
       BigDecimal reportingAmount = amount.multiply(this.getExchangeRate());
       return new TransactionAmountTaxable(amount, vat, reportingAmount, this.sourceCurrency, this.reportingCurrency);
    }

    private int compareTo(TransactionAmountTaxable money) throws ConversionException {
        isLikeCurrency(money);
        return this.getTotalSourceCurrency().compareTo(money.getTotalSourceCurrency());
    }

    public TransactionAmountTaxable multiply(BigDecimal amount) throws InvalidStateException {
        return newTransactionAmountTaxable(this.amountSourceCurrency.multiply(amount, DEFAULTCONTEXT),
                this.vatSourceCurrency.multiply(amount, DEFAULTCONTEXT));
    }

    public TransactionAmountTaxable multiply(double amount) throws InvalidStateException {
        return multiply(new BigDecimal(amount));
    }

    public TransactionAmountTaxable subtract(TransactionAmountTaxable other) throws ConversionException, InvalidStateException {
        isLikeCurrency(other);
        return newTransactionAmountTaxable(amountSourceCurrency.subtract(other.amountSourceCurrency, DEFAULTCONTEXT),
                vatSourceCurrency.subtract(other.vatSourceCurrency, DEFAULTCONTEXT));
    }

    public int compareTo(BigDecimal other) throws ConversionException {
        return this.amountSourceCurrency.add(this.vatSourceCurrency).compareTo(other);
    }

    public boolean greaterThan(TransactionAmountTaxable other) throws ConversionException {
        return (compareTo(other) > 0);
    }

    public TransactionAmountTaxable divide(double divisor) throws InvalidStateException {
        if(divisor==0.0) throw new InvalidStateException("Divide by Zero error");        
        BigDecimal div = BigDecimal.valueOf(divisor);
        BigDecimal result = this.amountSourceCurrency.divide(div, DEFAULTCONTEXT);
        BigDecimal vatResult = this.vatSourceCurrency.divide(div, DEFAULTCONTEXT);
        return newTransactionAmountTaxable(result, vatResult);
    }

    public TransactionAmountTaxable divide(BigDecimal rate) throws InvalidStateException {
        if(rate.compareTo(BigDecimal.ZERO)==0) throw new InvalidStateException("Divide by Zero error");
        BigDecimal result = this.amountSourceCurrency.divide(rate, DEFAULTCONTEXT);
        BigDecimal vatResult = this.vatSourceCurrency.divide(rate, DEFAULTCONTEXT);
        return newTransactionAmountTaxable(result, vatResult);
    }

    @Override
    public String toFormattedStringSourceCurrency() {
        NumberFormat fmt = NumberFormat.getCurrencyInstance();
        fmt.setCurrency(java.util.Currency.getInstance(sourceCurrency.getCode()));
        fmt.setGroupingUsed(true);
        fmt.setMaximumFractionDigits(sourceCurrency.getDefaultFractionDigits());
        return fmt.format(this.getAmountSourceCurrency());
    }

    @Override
    public String toFormattedStringReportingCurrency() {
        if (this.amountReportingCurrency == null) {
            this.onLoad();
        }          
        NumberFormat fmt = NumberFormat.getCurrencyInstance();
        fmt.setCurrency(java.util.Currency.getInstance(reportingCurrency.getCode()));
        fmt.setGroupingUsed(true);
        fmt.setMaximumFractionDigits(reportingCurrency.getDefaultFractionDigits());
        return fmt.format(this.getAmountReportingCurrency());
    }
    
    public String toFormattedStringVatSourceCurrency(){
        NumberFormat fmt = NumberFormat.getCurrencyInstance();
        fmt.setCurrency(java.util.Currency.getInstance(reportingCurrency.getCode()));
        fmt.setGroupingUsed(true);
        fmt.setMaximumFractionDigits(reportingCurrency.getDefaultFractionDigits());
        return fmt.format(this.getVatSourceCurrency());
    }

    @Override
    public String getSourceCurrencyCode() {
        return sourceCurrency.getCode();
    }

    @Override
    public String getReportingCurrencyCode() {
        return reportingCurrency.getCode();
    }

    @Override
    public String toString() {
        return amountSourceCurrency.toString();
    }

    public BigDecimal divide(TransactionAmountTaxable money) {
        //set scale to 4 for division
        return this.amountSourceCurrency.add(vatSourceCurrency).divide(money.amountSourceCurrency, DEFAULTCONTEXT);
    }

    public TransactionAmountTaxable convert(ExchangeRate rate, ExchangeRate reportingRate) throws InvalidStateException {
        Currency from = rate.getFromCurrency();
        Currency to = rate.getToCurrency();
        this.isLikeCurrency(from);
        BigDecimal tmpAmount;
        tmpAmount = this.amountSourceCurrency.multiply(rate.getRate()).setScale(to.getDecimalPlaces());
        BigDecimal tmpVat = this.vatSourceCurrency.multiply(rate.getRate()).setScale(to.getDecimalPlaces());
        TransactionAmountTaxable newMoney = new TransactionAmountTaxable(tmpAmount, tmpVat, this.amountReportingCurrency, to, this.reportingCurrency);
        return newMoney;
    }

    /**
     * @return the reportingCurrency
     */
    @Override
    public Currency getReportingCurrency() {
        return reportingCurrency;
    }


    /**
     * @return the exchangeRate
     */
    @Override
    public BigDecimal getExchangeRate() {
        return exchangeRate;
    }

    /**
     * @return the vatSourceCurrency
     */
    public BigDecimal getVatSourceCurrency() {
        return vatSourceCurrency;
    }


    /**
     * @return the vatReportingCurrency
     */
    public BigDecimal getVatReportingCurrency() {
        if (this.vatReportingCurrency == null) {
            this.onLoad();
        }        
        return vatReportingCurrency;
    }

    public TransactionAmount asTransactionAmount() throws InvalidStateException {
        return new TransactionAmount(this.getTotalSourceCurrency(), this.getTotalReportingCurrency(), 
                this.sourceCurrency, this.reportingCurrency);

    }
    private static Logger logger = Logger.getLogger(TransactionAmountTaxable.class);

    @PostLoad
    public void onLoad() {
  
        int scale;
        if (this.reportingCurrency == null) {
            scale = 2;
        } else {
            scale = this.reportingCurrency.getDecimalPlaces();
        }
        this.amountReportingCurrency = this.amountSourceCurrency.multiply(this.exchangeRate,DEFAULTCONTEXT);
        this.vatReportingCurrency = this.vatSourceCurrency.multiply(this.exchangeRate,DEFAULTCONTEXT);
    }

    @Override
    public String toFormattedStringTotalSourceCurrency() {
        NumberFormat fmt = NumberFormat.getCurrencyInstance();
        fmt.setCurrency(java.util.Currency.getInstance(sourceCurrency.getCode()));
        fmt.setGroupingUsed(true);
        fmt.setMaximumFractionDigits(sourceCurrency.getDefaultFractionDigits());
        return fmt.format(this.getTotalSourceCurrency());    
    }

    @Override
    public String toFormattedStringTotalReportingCurrency() {
        NumberFormat fmt = NumberFormat.getCurrencyInstance();
        fmt.setCurrency(java.util.Currency.getInstance(sourceCurrency.getCode()));
        fmt.setGroupingUsed(true);
        fmt.setMaximumFractionDigits(sourceCurrency.getDefaultFractionDigits());
        return fmt.format(this.getTotalReportingCurrency());    
    }

    @Override
    public BigDecimal getTaxRate() {
        return this.vatSourceCurrency.divide(this.amountSourceCurrency,4);
    }

    @Override
    public BigDecimal getRateForBaseAmount() {
        return this.getTaxRate().add(BigDecimal.ONE);
    }
    
    public  TransactionAmountTaxable asTransactionAmountTaxableEx(BigDecimal amountExTax) throws InvalidStateException{
        BigDecimal tax = amountExTax.multiply(this.getTaxRate());
        return this.newTransactionAmountTaxable(amountExTax,tax);
    }

    public  TransactionAmountTaxable asTransactionAmountTaxableInc(BigDecimal amountIncTax) throws InvalidStateException{
        if (this.vatSourceCurrency.compareTo(BigDecimal.ZERO)==0 && this.amountSourceCurrency.compareTo(BigDecimal.ZERO)==0){
          return  this.newTransactionAmountTaxable(BigDecimal.ZERO, BigDecimal.ZERO);
        }

        BigDecimal tax = amountIncTax.subtract(amountIncTax.divide(this.getRateForBaseAmount(),DEFAULTCONTEXT));
        return this.newTransactionAmountTaxable(amountIncTax.subtract(tax),tax);
    }
    
    
    /**
     * This method is used to set the average exchange rates when you want to aggregate the 
     * reporting amount as well.
     * It should not be used for normal currency transactions and should be set after the
     * aggregation has completed.
     * 
     * @param exchangeRate the exchangeRate to set
     */
    public void setExchangeRate(BigDecimal exchangeRate) {
        this.exchangeRate = exchangeRate;
        this.onLoad();
    }
}


The classes above would probably benefit from a factory design pattern. I have also not implemented the debit/credit flag in the above class but it can easily be extended to accommodate this.
 

Some argue, with some validity, that using Big Decimal is prone to coding errors, as its immutable, and object  nature means that coders need to use methods such as add, subtract etc to do arithmetic and the compareTo method for compariosn rhater htan the more natural +/- and = operator symbols. Usually making use of long with the tracking of the decimal placebeing done manully is advanced as a solution.  In addition adding big decimal figures is a lot slower than adding longs.

The only caveat is, although a long may seem big enough now to capture any financial amount that anyone may care to think of now, this may not be the case a few years from now. Just ask any economy thats been through hyper inflation such as Zimbabwe where they had to issue trillion dollar notes!

Comments

Hi, could you share the other related code for this TransactionAmountTaxable class? such as Currency etc?
Thnx, Phil