File Coverage

blib/lib/Finance/Bank/LloydsTSB.pm
Criterion Covered Total %
statement 24 114 21.0
branch 0 42 0.0
condition 0 15 0.0
subroutine 8 13 61.5
pod 3 3 100.0
total 35 187 18.7


line stmt bran cond sub pod time code
1             package Finance::Bank::LloydsTSB;
2              
3             =head1 NAME
4              
5             Finance::Bank::LloydsTSB - Check your bank accounts from Perl
6              
7             =head1 SYNOPSIS
8              
9             use Finance::Bank::LloydsTSB;
10             my @accounts = Finance::Bank::LloydsTSB->check_balance(
11             username => $username,
12             password => $password
13             memorable => $memorable_phrase
14             );
15              
16             my $total = 0;
17             my $format = "%20s : %21s : GBP %9.2f\n";
18             for my $acc (@accounts) {
19             $total += $acc->balance;
20             printf $format, $acc->name, $acc->descr_num, $acc->balance;
21             }
22             print "-" x 70, "\n";
23             printf $format, 'TOTAL', '', $total;
24              
25             my $statement = $accounts[0]->fetch_statement;
26              
27             # Retrieve QIF for all transactions in January 2008.
28             my $qif = $accounts[1]->download_statement(2008, 01, 01, 5);
29              
30             See F for a working example.
31              
32             =head1 DESCRIPTION
33              
34             This module provides a rudimentary interface to the LloydsTSB online
35             banking system at C. You will need
36             either C or C installed for HTTPS
37             support to work with LWP.
38              
39             =cut
40              
41 1     1   6836 use strict;
  1         3  
  1         33  
42 1     1   4 use warnings;
  1         2  
  1         29  
43              
44 1     1   4 use Carp;
  1         2  
  1         92  
45              
46             our $VERSION = '1.35';
47             our $DEBUG = 0;
48              
49 1     1   3 use Carp qw(carp cluck croak confess);
  1         2  
  1         53  
50 1     1   988 use HTML::TableExtract qw(tree);
  1         36879  
  1         9  
51 1     1   67902 use WWW::Mechanize;
  1         477319  
  1         45  
52              
53 1     1   702 use Finance::Bank::LloydsTSB::utils qw(debug trim);
  1         3  
  1         66  
54 1     1   549 use Finance::Bank::LloydsTSB::Account;
  1         3  
  1         1406  
55              
56             our $ua = WWW::Mechanize->new(
57             env_proxy => 1,
58             keep_alive => 1,
59             timeout => 30,
60             autocheck => 1,
61             );
62              
63             our $logged_in = 0;
64              
65             =head1 CLASS METHODS
66              
67             =cut
68              
69             sub _login {
70 0     0     my $self = shift;
71              
72 0           $ua->get("https://online.lloydstsb.co.uk/customer.ibc");
73 0           my $form = $ua->current_form;
74 0 0 0       die "Couldn't get current_form" unless $form && $form->isa("HTML::Form");
75 0           my $field = $form->find_input("UserId1");
76 0 0         die "Couldn't find UserId1 input field" unless $field;
77 0           $ua->field(UserId1 => $self->{username});
78 0           $ua->field(Password => $self->{password});
79 0           $ua->click;
80              
81 0 0         croak "Couldn't log in; check your password and username\n" . $ua->content
82             unless $ua->content =~ /memorable\s+information/i;
83              
84             # Now we're at the new "memorable information" page, so parse that
85             # and input the right form data.
86              
87 0           for my $i (0..2) {
88 0           my $key;
89 0           eval { $key = $ua->current_form->find_input("ResponseKey$i")->value; };
  0            
90 0 0         die "Couldn't find ResponseKey$i on memorable info page; has the login process changed?" if $@;
91 0           my $value = substr(lc $self->{memorable}, $key-1, 1);
92 0           $ua->field("ResponseValue$i" => $value);
93             }
94              
95 0           $ua->click;
96 0           $logged_in = 1;
97             }
98              
99             =head2 get_accounts(username => $u, password => $p, memorable => $m)
100              
101             Return a list of Finance::Bank::LloydsTSB::Account objects, one for
102             each of your bank accounts.
103              
104             =cut
105              
106             sub get_accounts {
107 0     0 1   my ($class, %opts) = @_;
108 0 0         croak "Must provide a password" unless exists $opts{password};
109 0 0         croak "Must provide a username" unless exists $opts{username};
110 0 0         croak "Must provide memorable information" unless exists $opts{memorable};
111              
112 0           my $self = bless { %opts }, $class;
113              
114 0           $self->_login;
115              
116 0 0         if ($ua->content =~ /To suppress a message/i) {
117 0           warn "Got messages screen; clicking through ...\n";
118 0           $ua->click;
119             }
120              
121 0 0         croak "Couldn't find account overview at memorable info stage:", $ua->content
122             unless $ua->content =~ /Account\s+Overview/;
123              
124 0           my $html = $ua->content;
125 0           $html =~ s/ ?/ /g;
126              
127             # Now we have the account list page; we need to parse it.
128 0           my $te = new HTML::TableExtract(
129             headers => [
130             "Account name",
131             "Balance",
132             "O/D Limit",
133             "Options",
134             ],
135             # Only use keep_html if extraction mode is raw text/HTML
136             # i.e. subclass of HTML::Parser! Otherwise there seems to be
137             # a bug which includes start tag in the text segment.
138             # keep_html => 1,
139             );
140 0           $te->parse($html);
141 0           my @tables = $te->tables;
142 0 0         croak "HTML::TableExtract failed to find table:\n$html" unless @tables;
143 0 0         croak "HTML::TableExtract found >1 tables" unless @tables == 1;
144              
145 0           my $acc_action_forms = $class->_get_acc_action_form_mapping;
146              
147             # Assume only one matching table using $te->rows shorthand
148 0           my @accounts;
149 0           foreach my $row ($te->rows) {
150 0           my ($descr, $balance, $OD_limit, $options) =
151 0           map { $class->trim($_) } @$row;
152             # Grr!! Sometimes $balance ends up being a scalar reference?!
153 0 0 0       next unless ref($balance) =~ /^HTML::/
154             # and $balance->can('find_by_attribute')
155             and $balance->find_by_attribute('class', 'prodDetail');
156 0           my $link = $descr->find('a');
157 0           my $name = $link->as_text;
158 0           $name =~ s/Lloyds TSB\s+//i;
159 0           my $num = $class->trim($link->right->right);
160              
161 0           my ($sort_code, $account_no, $descr_num, $terse_num);
162 0 0         if ($num =~ /^(\d\d-\d\d-\d\d) (\d{6,10})$/) {
    0          
163 0           ($sort_code, $account_no) = ($1, $2);
164 0           $descr_num = "$sort_code / $account_no";
165 0           $terse_num = "$sort_code$account_no";
166 0           $terse_num =~ tr/-//d;
167             }
168             elsif ($num =~ /^\d{4} \d{4} \d{4} \d{4}$/) {
169 0           $sort_code = undef;
170 0           $terse_num = $descr_num = $account_no = $num;
171 0           $terse_num =~ tr/ //d;
172             }
173             else {
174 0           croak "Couldn't parse '$num' as (sort code, a/c number) or c/c number\n";
175             }
176            
177 0           my $form_index = $acc_action_forms->{$terse_num};
178 0 0         if (exists $acc_action_forms->{$terse_num}) {
179 0           $class->debug("Found form index $form_index for $terse_num\n");
180             }
181             else {
182 0           die "Couldn't figure out form index for $terse_num";
183             }
184              
185 0   0       push @accounts, (bless {
      0        
186             ua => $ua,
187             name => $name,
188             sort_code => $sort_code || undef,
189             descr_num => $descr_num,
190             account_no => $account_no,
191             balance => $class->normalize_balance($balance->as_trimmed_text),
192              
193             # what's this one for?
194             parent => $self,
195              
196             # $options ISA HTML::ElementTable::DataElement
197             # which ISA HTML::ElementTable::Element
198             # which ISA HTML::ElementSuper
199             # which ISA HTML::Element
200             # $options->position gives us (x,y) of cell within table
201             # $options->tree gives us the containing HTML::ElementTable
202             # options => $options,
203             form_index => $form_index || undef
204             }, "Finance::Bank::LloydsTSB::Account");
205             }
206 0           return @accounts;
207             }
208              
209             sub _get_acc_action_form_mapping {
210 0     0     my $class = shift;
211              
212             # WWW::Mechanize only lets us select forms by name or number, but
213             # the account action forms don't have a unique name, so we need a
214             # way of mapping between an account and its number as appearing
215             # sequentially on the page.
216 0           my %acc_action_forms;
217 0           my @forms = ('WWW::Mechanize::form_number counts from 1', $ua->forms);
218 0           $class->debug("Found $#forms forms on page\n");
219 0           foreach my $i (1 .. $#forms) {
220 0           my $form = $forms[$i];
221             # using HTML::Form
222 0 0 0       unless (($form->attr('class') || '') eq 'acc_action_form') {
223 0           $class->debug("Form $i is not an acc_action_form\n");
224 0           next;
225             }
226 0           my $input = $form->find_input('Account', 'hidden');
227 0 0         if (! $input) {
228 0           $class->debug("skipping form $i since no hidden 'Account' input found\n");
229 0           next;
230             }
231 0           my $num = $input->value; # this should be sortcode + acc #, no punctuation
232 0           $acc_action_forms{$num} = $i;
233 0           $class->debug("Form with hidden 'Account' input '$num' is number $i\n");
234             }
235 0           return \%acc_action_forms;
236             }
237              
238             =head2 normalize_balance($balance)
239              
240             Converts the website's textual representation of a balance sum into
241             numeric form.
242              
243             =cut
244              
245             sub normalize_balance {
246 0     0 1   my $class = shift;
247 0           my ($balance) = @_;
248 0 0         $balance = '0' if $balance eq 'Nil';
249 0           $balance =~ s/ CR//;
250 0 0         $balance = "-$balance" if $balance =~ s/ DR//;
251 0           return $balance;
252             }
253              
254             =head2 logoff()
255              
256             Logs off, if you want to be nice and not bloat the sessions table they
257             no doubt have in their backend database.
258              
259             =cut
260              
261             sub logoff {
262 0     0 1   my $class = shift;
263 0 0 0       return unless $ua and $logged_in;
264 0 0         if ($ua->follow_link( text_regex => qr/Logoff/ )) {
265 0           $class->debug("Logged off\n");
266             }
267             else {
268 0           warn "Couldn't find Logoff button\n";
269             }
270             }
271              
272             1;
273              
274              
275             =head1 ACCOUNT OBJECT METHODS
276              
277             =over 4
278              
279             =item * $ac->name
280              
281             =item * $ac->sort_code
282              
283             =item * $ac->account_no
284              
285              
286             Return the name of the account, the sort code formatted as the familiar
287             XX-YY-ZZ, and the account number.
288              
289             =item * $ac->balance
290              
291             Return the balance as a signed floating point value.
292              
293             =item * $ac->statement
294              
295             Return a mini-statement as a line-separated list of transactions.
296             Each transaction is a comma-separated list. B: this interface
297             is currently only useful for display, and hence may change in later
298             versions of this module.
299              
300             =back
301              
302             =head1 WARNING
303              
304             This is code for B, and that means B, and
305             that means B. You are encouraged, nay, expected, to audit
306             the source of this module yourself to reassure yourself that I am not
307             doing anything untoward with your banking data. This software is useful
308             to me, but is provided under B, explicit or implied.
309              
310             =head1 AUTHORS
311              
312             Original by Simon Cozens
313              
314             Improvements by Adam Spiers
315              
316             =cut
317