Coding fun converting monetary value to words

April 20 2012
c#

I’m going to gloss over the fact that I’m looking for another job already, after being at my current employer for 7 weeks thus far.   Let’s just say they should have indicated the package included super…

I’ve dropped my resume in at a few jobs in the last two weeks.   One of them is TechnologyOne.  They were one of only two companies that asked for a coding submission with the application (the other being Readify – which i haven’t attempted yet).  TechnologyOne’s task was to write an app that converted a monetary amount into works, such as you’d find on a cheque.   For example, 123.45 would be converted to ONE HUNDRED AND TWENTY-THREE DOLLARS AND FORTY-FIVE CENTS.  It’s basically a repeat of an assignment I had to complete in Turbo Pascal in Highschool and as such shouldn’t be too difficult.  They preferred that this time it be done in either VB.NET or C# (which suits me fine – I don’t have a copy of Turbal Pascal laying about ).

I jotted down some ideas on paper first and noticed that the grouping of three digits is the same in word form, with the difference being that each  Thousands Block is suffixed with thousand, million, billion, etc.  With that in mind, the starting point for my solution was to develop a class that would handle the conversion of a number between 1 and 999 to words.  To that end I wrote the following class:

using System;

namespace ChequeDisplay.Logic
{
    /// <summary>
    /// Represents a three digit grouping of 'thousands'.
    /// These 'blocks' are repeated mlutiple times in a large number.  
    /// Each represnts at most 999.  
    /// e.g. 999,999,999 has three ThousandsBlocks - '999','999','999'
    /// </summary>
    public class ThousandsBlock
    {
        private readonly string numberString;
        private readonly int numberValue;

        public ThousandsBlock(string numberString)
        {
            this.numberValue = int.Parse(numberString);
            if (numberValue > 999) throw new ArgumentOutOfRangeException("numberString", "number must be less than 1000");
            this.numberString = numberValue.ToString("000");            
        }

        public int Value { get { return numberValue; } }

        /// <summary>
        /// Converts block of a thousand to words.
        /// e.g.  a value of 436 will be converted to words of
        /// 'FOUR HUNDRED AND THIRTY-SIX'
        /// </summary>        
        public string ToWords()
        {
            string result = string.Empty;

            int remainingValue = Value;
            if (remainingValue > 99)
            {
                remainingValue = remainingValue - ((remainingValue / 100) * 100);
                result += NumberToWordLookup.DigitWords[numberString.Substring(0, 1)] + " HUNDRED" + (remainingValue > 0 ? " AND " : string.Empty);
            }

            if (remainingValue > 19)
            {
                remainingValue = remainingValue - ((remainingValue / 10) * 10);
                result += NumberToWordLookup.TensDigitWords[numberString.Substring(1, 1)] + (remainingValue > 0 ? "-" : string.Empty);
            }

            if (remainingValue > 9 && remainingValue < 20)
            {
                result += NumberToWordLookup.TeensDigitWords[numberString.Substring(1, 2)];
            }
            else if (remainingValue > 0)
            {
                result += NumberToWordLookup.DigitWords[numberString.Substring(2, 1)];
            }

            return result;
        }

        public override string ToString()
        {
            return ToWords();
        }
    }
}

The other part of a monetary number are the cents.  They’re handled pretty much the same and I supposed I could have performed some superclass extract refactoring to pull out duplicated logic in both, but I didn’t….  Lets see the CentsBlock class.

using System;

namespace ChequeDisplay.Logic
{
    public class CentsBlock
    {
        private readonly int numberValue;
        private readonly string numberString;

        public CentsBlock(string centsString)
        {
            this.numberValue = int.Parse(centsString);
            if (numberValue > 99) throw new ArgumentOutOfRangeException("centsString", "Cents must be less than 100");

            this.numberString = numberValue.ToString("00");
        }

        public int Value { get { return numberValue; } }

        public string ToWords()
        {
            string result = string.Empty;

            int tempValue = Value;

            if (tempValue > 19)
            {
                tempValue = tempValue - ((tempValue / 10) * 10);
                result += NumberToWordLookup.TensDigitWords[numberString.Substring(0, 1)] + (tempValue > 0 ? "-" : string.Empty);
            }

            if (tempValue > 9 && tempValue < 20)
            {
                result += NumberToWordLookup.TeensDigitWords[numberString.Substring(0, 2)];
            }
            else if (tempValue > 0)
            {
                result += NumberToWordLookup.DigitWords[numberString.Substring(1, 1)];
            }

            return result;
        }

        public override string ToString()
        {
            return ToWords();
        }
    }
}

Finally we need a class to wrap these two concepts. This class should have a notion of cents and a collection of thousand blocks, as many as needed to represent the number. It should also be responsible for adding the words “Dollars” and “Cents”, and the thousands identifiers, “thousands”, “millions”, etc. Lets meet CurrencyText

using System;
using System.Collections.Generic;
using System.Linq;

namespace ChequeDisplay.Logic
{
    /// <summary>
    /// Encapsulates a monetary amount represented by english words
    /// </summary>
    public class CurrencyText
    {
        private readonly CentsBlock cents;
        private readonly List<ThousandsBlock> dollars;

        public CurrencyText(string numberString)
        {
            dollars = new List<ThousandsBlock>();

            if (!numberString.Contains(".")) numberString += ".00";

            var dollarsAndCents = numberString.Split(new [] {"."}, StringSplitOptions.RemoveEmptyEntries);
            cents = new CentsBlock(dollarsAndCents[1]);
            if (dollarsAndCents[0] != "0")
            {
                var thousandDollarChunks = dollarsAndCents[0].Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries);
                                
                foreach (var thousand in thousandDollarChunks)
                {
                    dollars.Add(new ThousandsBlock(thousand));
                }
            }
        }

        /// <summary>
        /// Converts the numerical value represented by the instance of CurrencyText 
        /// to english words.
        /// </summary>        
        public string ToWords()
        {
            var numberString = string.Empty;
            var total = 0;
            for (var index = 0; index < dollars.Count; index++)
            {
                var block = dollars[index];
                var thousandsSegmentNumber = dollars.Count - index;
                if (block.Value > 0)
                {
                    if (total > 0) numberString += " ";

                    numberString += block.ToString() + (thousandsSegmentNumber > 1 ? " " + NumberToWordLookup.ThousandsBlockWords[thousandsSegmentNumber] : "");
                }
                total += block.Value * (thousandsSegmentNumber * (thousandsSegmentNumber > 1 ? 1000 : 1));
            }

            numberString = numberString.Trim() + (total > 1 ? " DOLLARS" : total == 1 ? " DOLLAR" : "");

            if (cents.Value > 0)
            {
                if (dollars.Sum(d => d.Value) > 0)
                    numberString += " AND ";
                numberString += cents.ToString() + " CENT" + (cents.Value > 1 ? "S" : "");
            }

            return numberString;
        }

        public override string ToString()
        {
            return ToWords();
        }
    }
}

Seems pretty simple, but how do we get input in?

using System;
using System.Globalization;
using ChequeDisplay.Logic;

namespace ChequeDisplay
{
    class Program
    {
        static void Main(string[] args)
        {
            bool isCurrency = false;
            decimal currency = 0;
            while (!isCurrency)
            {
                Console.Write("Enter the value of the cheque: ");
                var inputCurrency = Console.ReadLine();
                
                isCurrency = decimal.TryParse(inputCurrency, NumberStyles.Currency, new CultureInfo("en-AU"), out currency);
                if (isCurrency)
                {
                    if (currency <= 0)
                    {
                        Console.WriteLine("Please enter an amount greater than 0");
                        isCurrency = false;
                    }  else if (((int)(currency * 100)) /100 != currency)
                    {
                        Console.WriteLine("Please enter a monetary amount with two decimal places");
                        isCurrency = false;
                    }
                }
                else
                {
                    Console.WriteLine("Please enter a monetary amount with two decimal places");
                }
            }

            var currencyString = new CurrencyText(currency.ToString("c").Substring(1)).ToWords();
            Console.WriteLine(currencyString);
            Console.WriteLine("Press any key to exit");
            Console.ReadKey();
        }
    }
}

I also used a helper class to do the digit to word conversion and improve readability in the important classes

Great! but now I need to verify that it all works. Take a deep breath...

using Microsoft.VisualStudio.TestTools.UnitTesting;
using ChequeDisplay.Logic;

namespace ChequeDisplay.Tests
{
    [TestClass]
    public class CentsBlockFixture
    {
        [TestMethod]
        public void ToString_can_handle_zero_amount()
        {
            var block = new CentsBlock("0");
            Assert.AreEqual(string.Empty, block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_one_cent()
        {
            var block = new CentsBlock("1");
            Assert.AreEqual("ONE", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_tens()
        {
            var block = new CentsBlock("20");
            Assert.AreEqual("TWENTY", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_teens()
        {
            var block = new CentsBlock("15");
            Assert.AreEqual("FIFTEEN", block.ToString());
        }
        
        [TestMethod]
        public void ToString_can_handle_ones()
        {
            var block = new CentsBlock("3");
            Assert.AreEqual("THREE", block.ToString());
        }
        
        [TestMethod]
        public void ToString_can_handle_tens_and_ones()
        {
            var block = new CentsBlock("46");
            Assert.AreEqual("FORTY-SIX", block.ToString());
        }      
    }
}

namespace ChequeDisplay.Tests
{
    [TestClass]
    public class CurrencyTextFixture
    {
        [TestMethod]
        public void ToString_can_handle_cents()
        {
            var block = new CurrencyText("0.43");
            Assert.AreEqual("FORTY-THREE CENTS", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_one_cent()
        {
            var block = new CurrencyText("0.01");
            Assert.AreEqual("ONE CENT", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_whole_dollars()
        {
            var block = new CurrencyText("3");
            Assert.AreEqual("THREE DOLLARS", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_one_dollar()
        {
            var block = new CurrencyText("1.00");
            Assert.AreEqual("ONE DOLLAR", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_one_thousand()
        {
            var block = new CurrencyText("2,000.00");
            Assert.AreEqual("TWO THOUSAND DOLLARS", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_one_hundred_thousand()
        {
            var block = new CurrencyText("100,000.00");
            Assert.AreEqual("ONE HUNDRED THOUSAND DOLLARS", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_one_million_dollars()
        {
            var block = new CurrencyText("1,000,000.00");
            Assert.AreEqual("ONE MILLION DOLLARS", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_one_billion_dollars()
        {
            var block = new CurrencyText("1,000,000,000.00");
            Assert.AreEqual("ONE BILLION DOLLARS", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_one_billion_dollars_and_cents()
        {
            var block = new CurrencyText("1,000,000,000.22");
            Assert.AreEqual("ONE BILLION DOLLARS AND TWENTY-TWO CENTS", block.ToString());
        }
    }
}

namespace ChequeDisplay.Tests
{
    [TestClass]
    public class ThousandsBlockFixture
    {
        [TestMethod]
        public void ToString_can_handle_zero_amount()
        {
            var block = new ThousandsBlock("0");
            Assert.AreEqual(string.Empty, block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_hundreds()
        {
            var block = new ThousandsBlock("100");
            Assert.AreEqual("ONE HUNDRED", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_tens()
        {
            var block = new ThousandsBlock("20");
            Assert.AreEqual("TWENTY", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_teens()
        {
            var block = new ThousandsBlock("15");
            Assert.AreEqual("FIFTEEN", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_hundreds_and_tens()
        {
            var block = new ThousandsBlock("430");
            Assert.AreEqual("FOUR HUNDRED AND THIRTY", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_hundrends_and_teens()
        {
            var block = new ThousandsBlock("817");
            Assert.AreEqual("EIGHT HUNDRED AND SEVENTEEN", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_ones()
        {
            var block = new ThousandsBlock("3");
            Assert.AreEqual("THREE", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_hundreds_and_ones()
        {
            var block = new ThousandsBlock("702");
            Assert.AreEqual("SEVEN HUNDRED AND TWO", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_tens_and_ones()
        {
            var block = new ThousandsBlock("46");
            Assert.AreEqual("FORTY-SIX", block.ToString());
        }

        [TestMethod]
        public void ToString_can_handle_hundreds_tens_and_ones()
        {
            var block = new ThousandsBlock("561");
            Assert.AreEqual("FIVE HUNDRED AND SIXTY-ONE", block.ToString());
        }
    }
}

How would you do it?

TechnologyOne clearly stated it’s bad form and instant grounds for rejecting an applicant if they’re found to copy someone else’s solution from the interwebs, so I figure if you’ve found your way here to copy my solution, you fail.

Post a comment

comments powered by Disqus