Permalink
Browse files

Initial commit

  • Loading branch information...
runpaint committed Jan 20, 2010
0 parents commit d5b8de5e93e94377409f55585eecf47c67e2deea
Showing with 933 additions and 0 deletions.
  1. +5 −0 .document
  2. +21 −0 .gitignore
  3. +674 −0 LICENSE
  4. +17 −0 README.rdoc
  5. +47 −0 Rakefile
  6. +1 −0 VERSION
  7. +39 −0 bin/nw
  8. +112 −0 lib/natwest.rb
  9. +7 −0 spec/natwest_spec.rb
  10. +1 −0 spec/spec.opts
  11. +9 −0 spec/spec_helper.rb
@@ -0,0 +1,5 @@
+README.rdoc
+lib/**/*.rb
+bin/*
+features/**/*.feature
+LICENSE
@@ -0,0 +1,21 @@
+## MAC OS
+.DS_Store
+
+## TEXTMATE
+*.tmproj
+tmtags
+
+## EMACS
+*~
+\#*
+.\#*
+
+## VIM
+*.swp
+
+## PROJECT::GENERAL
+coverage
+rdoc
+pkg
+
+## PROJECT::SPECIFIC
674 LICENSE

Large diffs are not rendered by default.

Oops, something went wrong.
@@ -0,0 +1,17 @@
+= natwest
+
+Description goes here.
+
+== Note on Patches/Pull Requests
+
+* Fork the project.
+* Make your feature addition or bug fix.
+* Add tests for it. This is important so I don't break it in a
+ future version unintentionally.
+* Commit, do not mess with rakefile, version, or history.
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
+* Send me a pull request. Bonus points for topic branches.
+
+== Copyright
+
+Copyright (c) 2010 Run Paint Run Run. See LICENSE for details.
@@ -0,0 +1,47 @@
+require 'rubygems'
+require 'rake'
+
+begin
+ require 'jeweler'
+ Jeweler::Tasks.new do |gem|
+ gem.name = "natwest"
+ gem.summary = %Q{Rudimentary API for Natwest Online Banking}
+ gem.description = "View balance and recent transactions of " +
+ "a Natwest account from the command line."
+ gem.email = "runrun@runpaint.org"
+ gem.homepage = "http://github.com/runpaint/natwest"
+ gem.authors = ["Run Paint Run Run"]
+ gem.add_development_dependency "rspec", ">= 1.2.9"
+ gem.add_development_dependency "yard", ">= 0"
+ gem.add_dependency "highline", ">= 0"
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
+ end
+ Jeweler::GemcutterTasks.new
+rescue LoadError
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
+end
+
+require 'spec/rake/spectask'
+Spec::Rake::SpecTask.new(:spec) do |spec|
+ spec.libs << 'lib' << 'spec'
+ spec.spec_files = FileList['spec/**/*_spec.rb']
+end
+
+Spec::Rake::SpecTask.new(:rcov) do |spec|
+ spec.libs << 'lib' << 'spec'
+ spec.pattern = 'spec/**/*_spec.rb'
+ spec.rcov = true
+end
+
+task :spec => :check_dependencies
+
+task :default => :spec
+
+begin
+ require 'yard'
+ YARD::Rake::YardocTask.new
+rescue LoadError
+ task :yardoc do
+ abort "YARD is not available. In order to run yardoc, you must: sudo gem install yard"
+ end
+end
@@ -0,0 +1 @@
+0.0.0
39 bin/nw
@@ -0,0 +1,39 @@
+#!/usr/bin/env ruby
+$LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
+require 'natwest'
+require 'highline/import'
+
+CONFIG = File.expand_path("~/.natwest.yaml")
+
+if File.exists?(CONFIG)
+ if File.world_readable?(CONFIG) or not File.owned?(CONFIG)
+ mode = File.stat(CONFIG).mode.to_s(8)
+ $stderr.puts "#{CONFIG}: Insecure permissions: #{mode}"
+ end
+end
+
+credentials = YAML.load(File.read(CONFIG)) rescue {}
+
+['Customer number', 'PIN', 'password'].each do |credential|
+ key = credential.tr(' ','_').downcase.to_sym
+ next if credentials.key?(key)
+ unless $stdin.tty? and $stdout.tty?
+ $stderr.puts "Can't prompt for credentials; STDIN or STDOUT is not a TTY"
+ exit(1)
+ end
+ credentials[key] = ask("Please enter your #{credential}:") do |q|
+ q.echo = false
+ end
+end
+
+Natwest::Account.new.tap do |nw|
+ nw.login credentials
+ puts "#{nw.account_number} (#{nw.sort_code}) " +
+ "balance: #{nw.balance}; available: #{nw.available}"
+ puts "Recent Transactions:"
+ nw.recent_transactions.each do |trans|
+ amount = trans[:credit] ? "+#{trans[:credit]}" : "-#{trans[:debit]}"
+ puts "#{trans[:date]}: #{amount}"
+ puts "\t" + trans[:details]
+ end
+end
@@ -0,0 +1,112 @@
+# coding: utf-8
+require 'mechanize'
+
+module Kernel
+ def assert(condition, message)
+ raise message unless condition
+ end
+end
+
+module Natwest
+ URL = 'https://nwolb.com/'
+
+ module Login
+ attr_reader :ua, :pin
+ attr_accessor :password, :pin, :customer_number
+
+ def logged_in?
+ @logged_in ||= false
+ end
+
+ def login(credentials)
+ credentials.each_pair{|name, value| send("#{name}=".to_sym, value)}
+ enter_customer_number
+ enter_pin_and_password
+ confirm_last_login
+ @logged_in = true
+ end
+
+ private
+ def enter_customer_number
+ login_form = ua.get(URL).frames.first.click.forms.first
+ login_form['ctl00$mainContent$LI5TABA$DBID_edit'] = customer_number
+ self.page = login_form.submit
+ assert(page.title.include?('PIN and Password details'),
+ "Got '#{page.title}' instead of PIN/Password prompt")
+ end
+
+ def enter_pin_and_password
+ expected = expected('PIN','number') + expected('Password','character')
+ self.page = page.forms.first.tap do |form|
+ ('A'..'F').map do |letter|
+ "ctl00$mainContent$LI6PPE#{letter}_edit"
+ end.zip(expected).each {|field, value| form[field] = value}
+ end.submit
+ assert(page.title.include?('Last log in confirmation'),
+ "Got '#{page.title}' instead of last login confirmation")
+ end
+
+ def confirm_last_login
+ self.page = page.forms.first.submit
+ assert(page.title.include?('Accounts summary'),
+ "Got '#{page.title}' instead of accounts summary")
+ end
+
+ def expected(credential, type)
+ page.body.
+ scan(/Enter the (\d+)[a-z]{2} #{type}/).
+ flatten.map{|i| i.to_i - 1}.tap do |indices|
+ assert(indices.uniq.size == 3,
+ "Unexpected #{credential} characters requested")
+ characters = [*send(credential.downcase.to_sym).to_s.chars]
+ indices.map! {|i| characters[i]}
+ end
+ end
+ end
+
+ class Account
+ include Login
+ NO_DETAILS = 'No further transaction details held'
+ attr_accessor :page
+
+ def initialize
+ @ua = WWW::Mechanize.new {|ua| ua.user_agent_alias = 'Windows IE 7'}
+ end
+
+ def meta_row(field=nil)
+ assert(logged_in?, "Not logged in")
+ @meta_row ||= page.parser.
+ css('table#ctl00_mainContent_Accounts > tbody > tr').
+ first
+ return @meta_row unless field
+ @meta_row.css("td > span.#{field} > span").first.inner_text.tr(' ','')
+ end
+
+ def account_number
+ meta_row('AccountNumber').to_i
+ end
+
+ def sort_code
+ meta_row('SortCode')
+ end
+
+ def balance
+ meta_row.css('td')[3].inner_text
+ end
+
+ def available
+ meta_row.css('td')[4].inner_text
+ end
+
+ def recent_transactions
+ page.parser.css('table.InnerAccountTable > tbody > tr').map do |tr|
+ transaction = Hash[[:date, :details, :credit, :debit].
+ zip((cells = tr.css('td')).map(&:inner_text))]
+ unless (further = cells[1]['title']) == NO_DETAILS
+ transaction[:details] += " (#{further.squeeze(' ')})"
+ end
+ Hash[transaction.map{|k,v| [k, v == ' - ' ? nil : v]}]
+ end
+ end
+ end
+end
@@ -0,0 +1,7 @@
+require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
+
+describe "Natwest" do
+ it "fails" do
+ fail "hey buddy, you should probably rename this file and start specing for real"
+ end
+end
@@ -0,0 +1 @@
+--color
@@ -0,0 +1,9 @@
+$LOAD_PATH.unshift(File.dirname(__FILE__))
+$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
+require 'natwest'
+require 'spec'
+require 'spec/autorun'
+
+Spec::Runner.configure do |config|
+
+end

0 comments on commit d5b8de5

Please sign in to comment.