File Coverage

blib/lib/Finance/Bank/US/INGDirect.pm
Criterion Covered Total %
statement 21 121 17.3
branch 0 40 0.0
condition 0 22 0.0
subroutine 7 13 53.8
pod 5 5 100.0
total 33 201 16.4


line stmt bran cond sub pod time code
1             package Finance::Bank::US::INGDirect;
2              
3 1     1   21659 use strict;
  1         3  
  1         41  
4              
5 1     1   7 use Carp 'croak';
  1         1  
  1         59  
6 1     1   1132 use LWP::UserAgent;
  1         48550  
  1         31  
7 1     1   1069 use HTTP::Cookies;
  1         7393  
  1         32  
8 1     1   958 use HTML::TableExtract;
  1         23342  
  1         7  
9 1     1   968 use Date::Parse;
  1         5568  
  1         167  
10 1     1   1061 use Data::Dumper;
  1         8279  
  1         2157  
11              
12             =pod
13              
14             =head1 NAME
15              
16             Finance::Bank::US::INGDirect - Check balances and transactions for US INGDirect accounts
17              
18             =head1 VERSION
19              
20             Version 0.08
21              
22             =cut
23              
24             our $VERSION = '0.08';
25              
26             =head1 SYNOPSIS
27              
28             use Finance::Bank::US::INGDirect;
29             use Finance::OFX::Parse::Simple;
30              
31             my $ing = Finance::Bank::US::INGDirect->new(
32             saver_id => '...',
33             customer => '########',
34             questions => {
35             # Your questions may differ; examine the form to find them
36             'AnswerQ1.4' => '...', # In what year was your mother born?
37             'AnswerQ1.5' => '...', # In what year was your father born?
38             'AnswerQ1.8' => '...', # What is the name of your hometown newspaper?
39             },
40             pin => '########',
41             );
42              
43             my $parser = Finance::OFX::Parse::Simple->new;
44             my @txs = @{$parser->parse_scalar($ing->recent_transactions)};
45             my %accounts = $ing->accounts;
46              
47             for (@txs) {
48             print "Account: $_->{account_id}\n";
49             printf "%s %-50s %8.2f\n", $_->{date}, $_->{name}, $_->{amount} for @{$_->{transactions}};
50             print "\n";
51             }
52              
53             =head1 DESCRIPTION
54              
55             This module provides methods to access data from US INGdirect accounts,
56             including account balances and recent transactions in OFX format (see
57             Finance::OFX and related modules). It also provides a method to transfer
58             money from one account to another on a given date.
59              
60             =cut
61              
62             my $base = 'https://secure.ingdirect.com/myaccount';
63              
64             =pod
65              
66             =head1 METHODS
67              
68             =head2 new( saver_id => '...', customer => '...', questions => {...}, pin => '...' )
69              
70             Return an object that can be used to retrieve account balances and statements.
71             See SYNOPSIS for examples of challenge questions.
72              
73             =cut
74              
75             sub new {
76 0     0 1   my ($class, %opts) = @_;
77 0           my $self = bless \%opts, $class;
78              
79 0   0       $self->{ua} ||= LWP::UserAgent->new(cookie_jar => HTTP::Cookies->new);
80              
81 0           _login($self);
82 0           $self;
83             }
84              
85             sub _login {
86 0     0     my ($self) = @_;
87              
88 0           my $response = $self->{ua}->get("$base/INGDirect/login.vm");
89              
90 0           $response = $self->{ua}->post("$base/INGDirect/login.vm", [
91             publicUserId => $self->{saver_id},
92             ]);
93 0 0 0       $response->is_redirect && $response->header('location') =~ /security_questions.vm/
94             or croak "Initial login failed.";
95              
96 0           $response = $self->{ua}->get("$base/INGDirect/security_questions.vm");
97 0 0         $response->is_success or croak "Retrieving challenge questions failed.";
98              
99 0           my @questions = map { s/^.*(AnswerQ.*)span".*$/$1/; $_ }
  0            
  0            
100             grep /AnswerQ/,
101             split('\n', $response->content);
102 0 0         croak "Didn't understand questions." if @questions != 2;
103              
104 0           $response = $self->{ua}->post("$base/INGDirect/security_questions.vm", [
105             TLSearchNum => $self->{customer},
106             'customerAuthenticationResponse.questionAnswer[0].answerText' => $self->{questions}{$questions[0]},
107             'customerAuthenticationResponse.questionAnswer[1].answerText' => $self->{questions}{$questions[1]},
108             '_customerAuthenticationResponse.device[0].bind' => 'false',
109             ]);
110 0 0 0       $response->is_redirect && $response->header('location') =~ /login_pinpad.vm/
111             or croak "Submitting challenge responses failed.";
112              
113 0           $response = $self->{ua}->get("$base/INGDirect/login_pinpad.vm");
114 0 0         $response->is_success or croak "Loading PIN form failed.";
115              
116 0           my @keypad = map { s/^.*mouseUpKb\("([A-Z])".*$/$1/; $_ }
  0            
  0            
117             grep /pinKeyboard[A-Z]number/,
118             split('\n', $response->content);
119              
120 0           unshift(@keypad, pop @keypad);
121              
122 0           $response = $self->{ua}->post("$base/INGDirect/login_pinpad.vm", [
123 0           'customerAuthenticationResponse.PIN' => join '', map { $keypad[$_] } split//, $self->{pin},
124             ]);
125 0 0 0       $response->is_redirect && $response->header('location') =~ /postlogin/
126             or croak "Submitting PIN failed.";
127              
128 0           $response = $self->{ua}->get("$base/INGDirect/postlogin");
129             # XXX This is how it behaves in my browser, but not with
130             # LWP::UserAgent, so we can apparently just skip this step...
131             #$response->is_redirect && $response->header('location') =~ /account_summary.vm/
132             # or croak "Post login redirect failed.";
133              
134             #$response = $self->{ua}->get("$base/INGDirect/account_summary.vm");
135             # XXX ...and the postlogin screen has the account summary.
136 0 0         $response->is_success or croak "Account summary fetch failed.";
137 0           $self->{_account_screen} = $response->content;
138             }
139              
140             =pod
141              
142             =head2 accounts( )
143              
144             Retrieve a list of accounts:
145              
146             ( '####' => [ number => '####', type => 'Orange Savings', nickname => '...',
147             available => ###.##, balance => ###.## ],
148             ...
149             )
150              
151             =cut
152              
153             sub accounts {
154 0     0 1   my ($self) = @_;
155              
156 0           my $te = HTML::TableExtract->new(
157             attribs => { cellpadding => 0, cellspacing => 0 }
158             );
159 0           my $account_screen = $self->{_account_screen};
160 0           $account_screen =~ s/ / /g; #   makes TableExtract unhappy
161 0           $te->parse($account_screen);
162              
163 0           my %accounts;
164 0           my $seen_header = 0;
165              
166 0 0         $te->tables or croak "Can't extract accounts table.";
167              
168 0           foreach my $row (($te->tables)[0]->rows) {
169 0 0         if ($row->[0] =~ /Account Type/) {
170 0           $seen_header++;
171 0           next;
172             }
173 0 0         next unless $seen_header;
174              
175 0           foreach (@$row) {
176 0           s/^\s*//; s/\s*$//; s/[\n\r]/ /g; s/\s+/ /g;
  0            
  0            
  0            
177             }
178              
179 0           my %account;
180 0           ($account{type}, $account{nickname}) = split / - /, shift @$row;
181 0           ($account{number},
182             $account{balance},
183 0           $account{available}) = map { s/^.+:\s+//; s/,//g; $_ ; } @$row;
  0            
  0            
184 0 0         next unless $account{type}; # don't include total row
185 0 0         $accounts{$account{number}} = \%account
186             if $account{number};
187             }
188              
189 0           %accounts;
190             }
191              
192             =pod
193              
194             =head2 recent_transactions( $account, $days )
195              
196             Retrieve a list of transactions in OFX format for the given account
197             (default: all accounts) for the past number of days (default: 30).
198              
199             =cut
200              
201             sub recent_transactions {
202 0     0 1   my ($self, $account, $days) = @_;
203              
204 0   0       $account ||= 'ALL';
205 0   0       $days ||= 30;
206              
207 0           my $response = $self->{ua}->post("$base/download.qfx", [
208             type => 'OFX',
209             TIMEFRAME => 'STANDARD',
210             account => $account,
211             FREQ => $days,
212             ]);
213 0 0         $response->is_success or croak "OFX download failed.";
214              
215 0           $response->content;
216             }
217              
218             =pod
219              
220             =head2 transactions( $account, $from, $to )
221              
222             Retrieve a list of transactions in OFX format for the given account
223             (default: all accounts) in the given time frame (default: pretty far in the
224             past to pretty far in the future).
225              
226             =cut
227              
228             sub transactions {
229 0     0 1   my ($self, $account, $from, $to) = @_;
230              
231 0   0       $account ||= 'ALL';
232 0   0       $from ||= '2000-01-01';
233 0   0       $to ||= '2038-01-01';
234              
235 0           my @from = strptime($from);
236 0           my @to = strptime($to);
237              
238 0           $from[4]++;
239 0           $to[4]++;
240 0           $from[5] += 1900;
241 0           $to[5] += 1900;
242              
243 0           my $response = $self->{ua}->post("$base/download.qfx", [
244             type => 'OFX',
245             TIMEFRAME => 'VARIABLE',
246             account => $account,
247             startDate => sprintf("%02d/%02d/%d", @from[4,3,5]),
248             endDate => sprintf("%02d/%02d/%d", @to[4,3,5]),
249             ]);
250 0 0         $response->is_success or croak "OFX download failed.";
251              
252 0           $response->content;
253             }
254              
255             =pod
256              
257             =head2 transfer( $from, $to, $amount, $when )
258              
259             Transfer money from one account number to another on the given date
260             (default: immediately). Returns the confirmation number. Use at your
261             own risk.
262              
263             =cut
264              
265             sub transfer {
266 0     0 1   my ($self, $from, $to, $amount, $when) = @_;
267 0 0         my $type = $when ? 'SCHEDULED' : 'NOW';
268              
269 0 0         if($when) {
270 0           my @when = strptime($when);
271 0           $when[4]++;
272 0           $when[5] += 1900;
273 0           $when = sprintf("%02d/%02d/%d", @when[4,3,5]);
274             }
275              
276 0           my $response = $self->{ua}->get("$base/INGDirect/money_transfer.vm");
277 0           my ($page_token) = map { s/^.*value="(.*?)".*$/$1/; $_ }
  0            
  0            
278             grep /
279             split('\n', $response->content);
280              
281 0 0         $response = $self->{ua}->post("$base/INGDirect/deposit_transfer_input.vm", [
282             pageToken => $page_token,
283             action => 'continue',
284             amount => $amount,
285             sourceAccountNumber => $from,
286             destinationAccountNumber => $to,
287             depositTransferType => $type,
288             $when ? (scheduleDate => $when) : (),
289             ]);
290 0 0         $response->is_redirect or croak "Transfer setup failed.";
291              
292 0           $response = $self->{ua}->get("$base/INGDirect/deposit_transfer_validate.vm");
293 0           ($page_token) = map { s/^.*value="(.*?)".*$/$1/; $_ }
  0            
  0            
294             grep /
295             split('\n', $response->content);
296              
297 0           $response = $self->{ua}->post("$base/INGDirect/deposit_transfer_validate.vm", [
298             pageToken => $page_token,
299             action => 'submit',
300             ]);
301 0 0         $response->is_redirect or croak "Transfer validation failed. Check your account!";
302              
303 0           $response = $self->{ua}->get("$base/INGDirect/deposit_transfer_confirmation.vm");
304 0 0         $response->is_success or croak "Transfer confirmation failed. Check your account!";
305 0           my ($confirmation) = map { s/^.*Number">(\d+)<.*$/$1/; $_ }
  0            
  0            
306             grep //,
307             split('\n', $response->content);
308              
309 0           $confirmation;
310             }
311              
312             1;
313              
314             =pod
315              
316             =head1 AUTHOR
317              
318             This version by Steven N. Severinghaus
319             with contributions by Robert Spier.
320              
321             =head1 COPYRIGHT
322              
323             Copyright (c) 2011 Steven N. Severinghaus. All rights reserved. This
324             program is free software; you can redistribute it and/or modify it under
325             the same terms as Perl itself.
326              
327             =head1 SEE ALSO
328              
329             Finance::Bank::INGDirect, Finance::OFX::Parse::Simple
330              
331             =cut
332