It happens to be a powerful and expressive language by any standard. This blog posts details my very first Ruby project. Just like the title says, it would be a replica of the ATM (Automated Teller Machine) somewhere down your street.

USECASES

From the above, we sure know we’d need some classes. Here are the ones we would be creating :

Security must be built into any app. Ours isn’t an exception. We want to make sure there are only authorized usage of our application. No trolls. Else we end up in some serious legal suit. Authentication is done by providing a valid debit card and a matching password - for the card. Hence to save passwords, we’d be making use of a database - of a sort. A (flat) file would serve as our database. This is for several reasons :

Our db file would contain some data in a specialized format our app understands. Each line would represent a user which would ultimately be converted into a Customer object.

Here is what a sample line looks like : 5011; 0000-1234-5678-5011; The Real Clown; ?2313! ; 60_000; 3_000. They are in the following order when delimited by ; :

Since we are working on a cli app, we need to ask for certain inputs from the user which we then respond to. Here is time to put our Prompter to work.

class Prompter

  def prompt(question)
    puts question
    gets.strip
  end
end

gets is built in function that reads input from the keyboard. We show the user a question, then we wait till we get a reply. This is more like What would you like to do question we get on a real life machine.

Next up is the app container itself

class Atm

  FilePath = 'db.txt'

  Separator = ';' #separator for the data in the database file

  
  ##Here are the command the ATM understands 
  
  Balance = 0
  Withdraw = 1
  Logout = 2

  def initialize(prompter)
    @all_customers = []
    @last_fours = []
    @current_customer = nil
    @cli_prompter = prompter
    get_all_customer_details
  end
end

In Ruby, the constructor method is initialize not __construct like in PHP or the class name as in Java. Basic OO - We inject the prompter into the class, this is so as we can switch to something more sophisticated when we reach enterprise level.

  def start

    last_4_digits = @cli_prompter.prompt('ENTER the last 4 digits of your card ?')

    raise AtmRunTimeError, 'The digits must be 4 characters long' unless last_4_digits.length.eql? 4
    
  end

  protected

  def get_all_customer_details
    File.open(FilePath, 'r').each do |line|
      next unless line.match(/\w+/)
      @all_customers.push(line.strip)
    end
  end

The get_all_customer_details is simply fetching everything from the database and dumping them into an array in our app. This is so as we can get easily get all the data for the users without constantly digging into the file. The line that says /\w+/ is a regular expression that tells us to only get lines that contain words from the file (our database). Remember this method was called in the constructor.

Our start method tells our atm to do some real work. First we ask for the last four digits om his/her card. This is always unique so it helps us simulate a card insertion (into the slot). Then we throw an exception if the user provides some digit more or less than 4 in it’s length - raise is Ruby’s equivalent to PHP or Java’s throw statement.

   def start
    #previous code
    
    current_customer = get_customer_details_by_last_four_digits last_4_digits.to_i #method call here. Parenthesis are totally optional
    
    end

  def get_customer_details_by_last_four_digits(number)
    found = []

    @all_customers.each do |customer|
      next unless customer.slice(0, 4).to_i.eql? number
      found = customer.split Separator
    end

    raise UnknownCardError, 'Invalid debit card' if found.empty?

    found
  end

There is something interesting in the get_customer_details_by_last_four_digits method. This method shows us many nice stuffs about the ruby language :

  def start
  
    ###previous code
    
    begin

      is_password_valid(current_customer, @prompter.prompt('Please provide your password ?'))

      puts '', 'Authenticating you via our secure server'

      login_error_count = 0
      @all_customers = nil

      hydrate_data(current_customer)

      puts 'You have been authenticated', ''

    rescue InvalidPasswordError => e

     ###In other to prevent users from taking our app down with too many requests, we shut them out after 3 invalid password entries
      raise LoginThrottleError, e.message + '. Atm would exit now' if login_error_count >= 3

     ###Increment the error count, then re-prompt the user for a password
      login_error_count += 1

      retry
    end

    bootstrap_atm_commands # if we get here, we golden.
  end  
  
  def is_password_valid(customer, password)
      raise InvalidPasswordError, 'Please input the right password' unless customer[3].strip.eql? password
  end
  
  def process_command(command)
    case command.to_i
      when Balance
        puts "Available Balance -> #{@current_customer.available_balance}", ''

      when Withdraw
        puts ''

        amount_to_withdraw = @prompter.prompt('How much would you like to withdraw ?').to_f

        if @current_customer.can_withdraw?(amount_to_withdraw)
          puts 'Authenticating your withdrawal'
          @current_customer.withdraw!(amount_to_withdraw)
          puts 'Done'
        else
          puts 'Insufficient funds!', ''
        end

      when Logout
        puts 'Unauthenticating you via our secure sever', 'You have been successfully logged out'

        exit

      else
        puts '', 'Unknown Command', ''
    end

    bootstrap_atm_commands
  end
  

  def bootstrap_atm_commands
    print_instructions
    process_command(@prompter.prompt('How may we help you today ? Please Enter a command'))
  end

  def hydrate_data(customer)
    @current_customer = Customer.new(customer[2].strip, customer[4].strip.to_f, customer[5].strip.to_f)
  end

  def print_instructions
    puts "Hello, #{@current_customer.full_name}", ''

    commands = [
        [Balance, 'check your balance'], [Withdraw, 'withdraw some cash'], [Logout, 'logout']
    ]

    commands.each { |key, value| puts "Press #{key} to #{value}." }

    puts ''
  end  
 

2

This is quite large but quite self explanatory. We check if the user provided the right password. If yes, save the current user to the instance variable current_customer - which is actually more a sort of session stuff.

The bootstrap_atm_commands simply print some information to the screen while waiting for the user to enter some response, so as to perform the requested action. Our atm understands some basic commands - 0 for account balance, 1 to withdraw some cash (this command in turn prompts the user to specify how much he’d like to withdraw), 2 is for logging out.

The most interesting parts here are the extra methods we called on the customer instance - @current_customer.can_withdraw? and @current_customer.withdraw!. The method can_withdraw would return a boolean which is actually why it has a ? in it’s method definition - a standard ruby practice by the way WHILE the withdraw! method would actually reduce the balance of the customer.

Great!!! But what does the Customer object look like ? Nothing complex if you ask.

class Customer

  attr_accessor :full_name, :available_balance

  def initialize(full_name, available_balance, minimum_balance)
    @full_name = full_name
    @available_balance = available_balance
    @minimum_balance = minimum_balance
  end

  def withdraw!(amount)
    @available_balance -= amount
  end

  def can_withdraw?(amount)
    #some banks have a minimum balance policy. Let's put that in perspective too.
    cannot_withdraw = (@available_balance - amount) > @minimum_balance
    cannot_withdraw ||= amount < @available_balance
  end
end

For the curious minded, here are the exceptions definition

AtmRunTimeError = Class.new(RuntimeError)

BalanceOverflowError = Class.new(ArgumentError)

class FraudError < AtmRunTimeError
end

class UnknownCardError < FraudError
end

class InvalidPasswordError < FraudError
end

LoginThrottleError = Class.new(RuntimeError)

With this, our application is complete and works. Tests should be written obviously especially the Customer object since we have more fine grained control over it - unlike others where there is a lot of prompting and reading stuffs from the keyboard.

To test out the atm, a dummy file - say app.rb - should be created with the following content :

#!/usr/bin/env ruby

require './lib/atm'
require './lib/prompter'
require './lib/exceptions'
require './lib/customer'

atm = Atm.new(Prompter.new)
atm.start    

Make sure you make the file executable, then run ./app.rb. Or just ruby app.rb

Footnotes

[1] Our app has one account per head. This is by design. In the real world, users can have multiple account.

[2] Our Atm class obviously goes against SRP (Single Responsibility Principle). It does password validation via the is_password_valid method. It loads all users from the database file (get_all_customer_details method). This two operations can be refactored into their own specific classes - say a PasswordValidatorService and FileReader (or something).