File Coverage

blib/lib/Finance/Bank/Halifax/Sharedealing.pm
Criterion Covered Total %
statement 33 154 21.4
branch 0 48 0.0
condition 0 57 0.0
subroutine 11 21 52.3
pod 8 8 100.0
total 52 288 18.0


line stmt bran cond sub pod time code
1             package Finance::Bank::Halifax::Sharedealing;
2              
3 1     1   21055 use strict;
  1         3  
  1         39  
4 1     1   5 use warnings;
  1         2  
  1         27  
5 1     1   5 use Carp;
  1         6  
  1         73  
6 1     1   180492 use HTML::TokeParser;
  1         178687  
  1         36  
7 1     1   1377 use WWW::Mechanize;
  1         295047  
  1         68  
8              
9             =head1 NAME
10              
11             Finance::Bank::Halifax::Sharedealing - access Halifax Sharedealing accounts from Perl.
12              
13             =head1 VERSION
14              
15             Version 0.03
16              
17             =cut
18              
19             our $VERSION = '0.03';
20              
21             =head1 SYNOPSIS
22              
23             use Finance::Bank::Halifax::Sharedealing;
24              
25             # Set up the login details
26             my $sd = Finance::Bank::Halifax::Sharedealing->new(
27             username => 'myusername',
28             password => 'mysecretpassword',
29             security_mother_first_name => 'Alice',
30             security_father_first_name => 'Bob',
31             security_school_name => 'Somewheretown Primary School',
32             security_birthplace => 'Somewheretown',
33             );
34              
35             $sd->log_in();
36              
37             # Get the user's accounts and print a brief statement for each one.
38             my %accounts = $sd->get_all_accounts();
39             foreach my $account_id (keys(%accounts)) {
40             $sd->set_account($account_id);
41             print "Account: " . $accounts{$account_id} . "\n";
42             print "Available to invest: " . $sd->get_available_cash() . "\n";
43              
44             my @portfolio = $sd->get_portfolio();
45             if (@portfolio) {
46             print "Share\tValuation\n";
47             foreach my $share (@portfolio) {
48             print $share->{'symbol'} . "\t";
49             print $share->{'valuation'} . "\n";
50             }
51             }
52             print "\n";
53             }
54              
55             $sd->log_out();
56              
57             =head1 DESCRIPTION
58              
59             This module provides an interface to the Halifax online share dealing
60             service at L. It requires
61             C, C, and either C or
62             C.
63              
64             =head1 METHODS
65              
66             =cut
67              
68             # Global constants - these will only change if Halifax reorganise their
69             # share dealing site.
70              
71             # URL of the login page
72 1     1   11 use constant LOGIN_PAGE => 'https://www.halifaxsharedealing-online.co.uk/_mem_bin/formslogin.asp';
  1         3  
  1         73  
73              
74             # Text of the link to view your statements
75 1     1   7 use constant STATEMENTS_LINK_TEXT => 'My Statements';
  1         2  
  1         41  
76              
77             # Name of the account select box on the statements page.
78 1     1   5 use constant ACCOUNT_SELECT_BOX_NAME => 'AccNavList';
  1         2  
  1         35  
79              
80             # Name of the form that contains the 'Sign out' button.
81 1     1   4 use constant HEADER_FORM_NAME => 'frmHeaderButtons';
  1         2  
  1         169  
82              
83              
84             ##################
85             # Public methods #
86             ##################
87              
88             =head2 new(username => $u, password => $p, security_mother_first_name => $m, security_father_first_name => $f, security_school_name => $s, security_birthplace => $b)
89              
90             Returns a new Sharedealing object.
91              
92             The required arguments are the user's login details as a list of key/value
93             pairs. Answers are required for all the possible security questions, as we
94             don't know in advance which one the site will ask us.
95              
96             =cut
97              
98             sub new {
99 0     0 1   my ($class, %opts) = @_;
100              
101 0 0         croak 'Username not specified' if not exists $opts{username};
102 0 0         croak 'Password not specified' if not exists $opts{password};
103 0 0         croak "Security answer not specified: mother's first name"
104             if not exists $opts{security_mother_first_name};
105 0 0         croak "Security answer not specified: father's first name"
106             if not exists $opts{security_father_first_name};
107 0 0         croak 'Security answer not specified: name of first school'
108             if not exists $opts{security_school_name};
109 0 0         croak 'Security answer not specified: place/town of birth'
110             if not exists $opts{security_birthplace};
111              
112 0           my $self = {
113             agent => new WWW::Mechanize(autocheck => 1),
114             username => $opts{username},
115             password => $opts{password},
116             security_mother_first_name => $opts{security_mother_first_name},
117             security_father_first_name => $opts{security_father_first_name},
118             security_school_name => $opts{security_school_name},
119             security_birthplace => $opts{security_birthplace},
120             _account => '',
121             };
122              
123 0           bless $self, $class;
124 0           return $self;
125             }
126              
127              
128             =head2 log_in()
129              
130             Log in, using the security details that were passed to C. This will
131             set the currently-selected account to the user's default account.
132              
133             Returns true if logging in was successful.
134              
135             =cut
136              
137             sub log_in {
138             # ID attribute of the login form
139 1     1   5 use constant LOGIN_FORM_ID => 'frmFormsLogin';
  1         1  
  1         317  
140              
141 0     0 1   my $self = shift;
142 0           $self->{agent}->get(LOGIN_PAGE);
143 0           $self->{agent}->form_id(LOGIN_FORM_ID);
144 0           $self->{agent}->field('Username', $self->{username});
145 0           $self->{agent}->field('password', $self->{password});
146              
147             # Find out what the security question is and select the appropriate answer
148 0           my $stream = HTML::TokeParser->new(\$self->{agent}->{content});
149 0           my $answer = '';
150 0           while (my $tag = $stream->get_tag('strong')) {
151 0           my $text = '';
152 0           $text = $stream->get_trimmed_text('/strong');
153 0 0         if ($text =~ /town of birth/) {
    0          
    0          
    0          
154 0           $answer = $self->{security_birthplace};
155             } elsif ($text =~ /Your father's first name/) {
156 0           $answer = $self->{security_father_first_name};
157             } elsif ($text =~ /Your mother's first name/) {
158 0           $answer = $self->{security_mother_first_name};
159             } elsif ($text =~ /The name of your first school/) {
160 0           $answer = $self->{security_school_name};
161             }
162             }
163 0 0         die 'Security question field not found' if !$answer;
164 0           $self->{agent}->field('answer', $answer);
165              
166             # Now we've got the answer, submit the page.
167 0           $self->{agent}->submit();
168              
169             # Set the currently-selected account to the default.
170 0           $self->{_account} = $self->_get_account_from_url($self->{agent}->uri);
171 0           return 1;
172             }
173              
174              
175             =head2 log_out()
176              
177             Log out by clicking the 'Sign Out' button.
178              
179             Returns true if logging out was successful.
180              
181             =cut
182              
183             sub log_out {
184             # The 'Sign off' URL, where the sign off/out button sends you.
185 1     1   8 use constant SIGN_OFF_URL => 'https://www.halifaxsharedealing-online.co.uk/_mem_bin/SignOff.asp';
  1         1  
  1         1112  
186              
187 0     0 1   my $self = shift;
188              
189 0           my $form = $self->{agent}->get(SIGN_OFF_URL);
190 0           return 1;
191             }
192              
193              
194             =head2 get_all_accounts()
195              
196             Get all the accounts that can be managed from this user's login.
197              
198             Returns a hash with account IDs (e.g. "D12345678") as the keys, and account
199             names (e.g. "MR J SMITH, Halifax ShareBuilder 01") as the values.
200              
201             =cut
202              
203             sub get_all_accounts {
204 0     0 1   my $self = shift;
205 0           my %accounts;
206              
207             # Go to the statements page, which has a select box with all the
208             # accounts in it.
209 0           $self->{agent}->follow_link(text => STATEMENTS_LINK_TEXT);
210              
211             # Find the select box and extract the account details.
212 0           my $stream = HTML::TokeParser->new(\$self->{agent}->{content});
213              
214 0           while (my $token = $stream->get_token) {
215 0           my $ttype = shift @{ $token };
  0            
216              
217             # Is this a start tag?
218 0 0         if($ttype eq 'S') {
219 0           my ($tag, $attr, $attrseq, $rawtxt) = @{ $token };
  0            
220              
221             # Is it the account selection box?
222 0 0 0       if($tag eq 'select' && $attr->{name} eq ACCOUNT_SELECT_BOX_NAME) {
223             # Found the select box.
224             # Now go through each option until we reach the ending select tag.
225 0   0       until ($ttype eq 'E' && $tag eq 'select') {
226 0           $token = $stream->get_token;
227 0           ($ttype, $tag, $attr, $attrseq, $rawtxt) = @{ $token };
  0            
228             # if we find an opening 'option' tag AND it has a non-blank value
229             # (so we skip the 'Show me a different account' option).
230 0 0 0       if($ttype eq 'S' && $tag eq 'option' && $attr->{value}) {
      0        
231             # parse for account ID and account name, then add them to %accounts
232 0           my $account_code = $self->_get_account_from_url($attr->{value});
233 0           my $account_name = $stream->get_trimmed_text('/option');
234 0           $accounts{$account_code} = $account_name;
235             }
236             }
237             }
238             }
239             }
240 0           return %accounts;
241             }
242              
243              
244             =head2 set_account($account)
245              
246             Set or change the account we're using.
247              
248             C<$account>: the account ID of the account to switch to.
249              
250             Returns true if the account was successfully set, otherwise false.
251              
252             =cut
253              
254             sub set_account {
255 0     0 1   my ($self, $account) = @_;
256              
257 0           my $base_url = $self->_get_url_without_account_code($self->{agent}->uri);
258              
259 0 0         if ($base_url) {
260 0           $self->{agent}->get($base_url . $account);
261 0           $self->{_account} = $account;
262 0           return 1;
263             }
264 0           warn "Couldn't set the account ID to $account\n";
265 0           return 0;
266             }
267              
268              
269             =head2 get_account()
270              
271             Get the ID of the account we're currently using.
272              
273             Returns the account ID of the currently-selected account (or an empty string
274             if there isn't a selected account yet).
275              
276             =cut
277              
278             sub get_account {
279 0     0 1   my $self = shift;
280              
281 0           return $self->{_account};
282             }
283              
284              
285             =head2 get_portfolio()
286              
287             Get a portfolio statement for the currently-selected account.
288              
289             Returns an array. Each element of the array is a hash with the keys:
290              
291             symbol: the ticker symbol of the stock.
292             exchange: the exchange on which the stock is listed.
293             quantity: the number of shares owned.
294             avg_cost: the average cost per share (in pence).
295             latest_price: the latest quoted price per share (in pence).
296             change: result of subtracting avg_cost from latest_price (in pence)
297             book_cost: total cost of the holding (in pounds)
298             valuation: value of the holding at the latest market price (in pounds).
299             profit_loss_absolute: the profit or loss on the holding in pounds.
300             profit_loss_percent: the profit or loss on the holding in percent.
301              
302             All hash values are the raw contents of the data cell - you should not assume
303             that any of them will be valid numbers.
304              
305             =cut
306              
307             sub get_portfolio {
308 0     0 1   my $self = shift;
309              
310 0           $self->{agent}->follow_link(text => STATEMENTS_LINK_TEXT);
311              
312 0           my @portfolio;
313 0           my $stream = HTML::TokeParser->new(\$self->{agent}->{content});
314              
315             # Find the portfolio table
316 0           my $table;
317 0           my ($ttype, $tag, $attr, $attr_seq, $text);
318 0   0       do {
      0        
319 0           $table = $stream->get_tag('table');
320 0 0         ($tag, $attr, $attr_seq, $text) = @{ $table } if $table;
  0            
321             } until (!$table || ($attr->{class} && $attr->{class} eq 'DataTable'));
322              
323             # Couldn't find the table, so just return. Don't give an error,
324             # because we might just be looking at an account with no holdings.
325 0 0         if (!$table) {
326 0           return @portfolio;
327             }
328              
329             # Until we get to the end of the table:
330 0   0       do {
      0        
331 0           my $token = $stream->get_token;
332 0           ($ttype, $tag, $attr, $attr_seq, $text) = @{ $token };
  0            
333              
334             # Process each row we find
335 0 0 0       if ($ttype eq 'S' && $tag eq 'tr') {
336 0           my @row_contents;
337             # Until we get to the end of the row:
338 0   0       do {
      0        
      0        
339 0           $token = $stream->get_token;
340 0           ($ttype, $tag, $attr, $attr_seq, $text) = @{ $token };
  0            
341             # Get the contents of each cell, but ignore header cells.
342 0 0 0       if ($ttype eq 'S' && $tag eq 'td' && $attr->{class} && $attr->{class} ne 'DataTableCollHeader') {
      0        
      0        
343 0           my $cell_contents = $stream->get_trimmed_text('/td');
344 0           push(@row_contents, $cell_contents);
345             }
346             } until (!$tag || ($ttype eq 'E' && ($tag eq 'table' || $tag eq 'tr')));
347             # Add the contents of the row we've just processed to the output array.
348 0 0         if (@row_contents >= 10) {
349 0           my $new_row = {
350             'symbol' => $row_contents[0],
351             'exchange' => $row_contents[1],
352             'quantity' => $row_contents[2],
353             'avg_cost' => $row_contents[3],
354             'latest_price' => $row_contents[4],
355             'change' => $row_contents[5],
356             'book_cost' => $row_contents[6],
357             'valuation' => $row_contents[7],
358             'profit_loss_absolute' => $row_contents[8],
359             'profit_loss_percent' => $row_contents[9],
360             };
361 0           push(@portfolio, $new_row);
362             }
363             }
364             } until (!$tag || ($ttype eq 'E' && $tag eq 'table'));
365              
366 0           return @portfolio;
367             }
368              
369              
370             =head2 get_available_cash()
371              
372             Get the uninvested cash balance for the currently-selected account.
373              
374             Returns the uninvested cash balance as a (ISO-8859-1) string, with currency
375             symbol.
376              
377             =cut
378              
379             sub get_available_cash {
380 0     0 1   my $self = shift;
381 0           my $cash;
382              
383 0           $self->{agent}->follow_link(text => STATEMENTS_LINK_TEXT);
384              
385 0           my $stream = HTML::TokeParser->new(\$self->{agent}->{content});
386              
387 0           while (my $td = $stream->get_tag('td')) {
388 0           my ($tag, $attr, $attr_seq, $text) = @{ $td };
  0            
389             # Look in the account summary area.
390 0 0 0       if($attr->{class} && $attr->{class} eq 'summaryBoxesText') {
391             # Are we at the "Available to invest" line?
392 0           my $text = $stream->get_trimmed_text('/td');
393 0 0 0       if ($text && ($text =~ /Available to invest/ || $text =~ /Cash in account/)) {
      0        
394             # If so, get what's in the next
395 0           my $cash_td;
396 0           my ($c_tag, $c_attr, $c_attr_seq, $c_text);
397 0   0       do {
398 0           $cash_td = $stream->get_tag('td');
399 0 0         if ($cash_td) {
400 0           ($c_tag, $c_attr, $c_attr_seq, $c_text) = @{ $cash_td };
  0            
401             }
402             } until (!$cash_td || $c_attr->{class} eq 'summaryBoxesValues');
403 0 0         $cash = $stream->get_trimmed_text('/td') if $cash_td;
404             }
405             }
406             }
407 0           return $cash;
408             }
409              
410             ###################
411             # Private methods #
412             ###################
413              
414             # _get_url_without_account_code()
415             #
416             # Given a URL from the share dealing site (after logging in), return it
417             # without the account code on the end.
418             #
419             # Arguments:
420             # $url: the URL from which to remove the account code.
421             #
422             # Returns the URL without the account code, or false if the account code
423             # was not found.
424             sub _get_url_without_account_code {
425 0     0     my ($self, $url) = @_;
426 0           my $base = '';
427              
428 0           $url =~ /^(.*PortCode=)\w+/i;
429 0           $base = $1;
430              
431 0           return $base;
432             }
433              
434             # _get_account_from_url()
435             #
436             # Given a URL from the share dealing site (after logging in), return the
437             # account code from it. Returns an empty string if no account code could
438             # be found.
439             #
440             # Arguments:
441             # $url: the URL from which to extract the account code.
442             #
443             # Returns the account code, or false if the account code was not found in
444             # the URL.
445             sub _get_account_from_url {
446 0     0     my ($self, $url) = @_;
447 0           my $account = '';
448              
449 0           $url =~ /PortCode=(\w+)/i;
450 0           $account = $1;
451              
452 0           return $account;
453             }
454              
455              
456             =head1 WARNING
457              
458             Taken from Simon Cozens' C, because it's just
459             as relevant here:
460              
461             This is code for B, and that means B, and
462             that means B. You are encouraged, nay, expected, to audit
463             the source of this module yourself to reassure yourself that I am not
464             doing anything untoward with your banking data. This software is useful
465             to me, but is provided under B, explicit or implied.
466              
467             =head1 AUTHOR
468              
469             Rayner Lucas, C<< >>
470              
471             =head1 BUGS
472              
473             Please report any bugs or feature requests to
474             C, or through the web
475             interface at
476             L.
477             I will be notified, and then you'll automatically be notified of progress
478             on your bug as I make changes.
479              
480              
481             =head1 SUPPORT
482              
483             You can find documentation for this module with the perldoc command.
484              
485             perldoc Finance::Bank::Halifax::Sharedealing
486              
487              
488             You can also look for information at:
489              
490             =over 4
491              
492             =item * RT: CPAN's request tracker
493              
494             L
495              
496             =item * AnnoCPAN: Annotated CPAN documentation
497              
498             L
499              
500             =item * CPAN Ratings
501              
502             L
503              
504             =item * Search CPAN
505              
506             L
507              
508             =back
509              
510              
511             =head1 ACKNOWLEDGEMENTS
512              
513             Thanks to the CPAN authors whose modules made this one possible, and to
514             Simon Cozens for C.
515              
516             =head1 LICENSE AND COPYRIGHT
517              
518             Copyright 2011 Rayner Lucas.
519              
520             This program is free software; you can redistribute it and/or modify it
521             under the terms of either: the GNU General Public License as published
522             by the Free Software Foundation; or the Artistic License.
523              
524             See http://dev.perl.org/licenses/ for more information.
525              
526              
527             =cut
528              
529              
530             1; # End of Finance::Bank::Halifax::Sharedealing