File Coverage

blib/lib/Finance/Bank/LaPoste.pm
Criterion Covered Total %
statement 21 170 12.3
branch 0 56 0.0
condition 0 32 0.0
subroutine 7 36 19.4
pod 2 3 66.6
total 30 297 10.1


line stmt bran cond sub pod time code
1             package Finance::Bank::LaPoste;
2              
3 1     1   5592 use strict;
  1         3  
  1         35  
4              
5 1     1   5 use Carp qw(carp croak);
  1         2  
  1         50  
6 1     1   559 use HTTP::Cookies;
  1         12896  
  1         34  
7 1     1   844 use LWP::UserAgent;
  1         52290  
  1         44  
8 1     1   653 use HTML::Parser;
  1         5743  
  1         33  
9 1     1   590 use HTML::Form;
  1         26856  
  1         35  
10 1     1   10 use Digest::MD5();
  1         2  
  1         3497  
11              
12             our $VERSION = '9.05';
13              
14             # $Id: $
15             # $Log: LaPoste.pm,v $
16              
17             =pod
18              
19             =head1 NAME
20              
21             Finance::Bank::LaPoste - Check your "La Poste" accounts from Perl
22              
23             =head1 SYNOPSIS
24              
25             use Finance::Bank::LaPoste;
26              
27             my @accounts = Finance::Bank::LaPoste->check_balance(
28             username => "0120123456L", # your main account is something like
29             # 0123456 L 012, stick it together with the region first
30             password => "123456", # a password is usually 6 numbers
31             all_accounts => 1, # if you want credit card and savings accounts
32             );
33              
34             foreach my $account (@accounts) {
35             print "Name: ", $account->name, " ", $account->owner, " Account_no: ", $account->account_no, "\n", "*" x 80, "\n";
36             print $_->as_string, "\n" foreach $account->statements;
37             }
38              
39             =head1 DESCRIPTION
40              
41             This module provides a read-only interface to the Videoposte online banking
42             system at L. You will need either Crypt::SSLeay
43             installed.
44              
45             The interface of this module is similar to other Finance::Bank::* modules.
46              
47             =head1 WARNING
48              
49             This is code for B, and that means B, and that
50             means B. You are encouraged, nay, expected, to audit the source
51             of this module yourself to reassure yourself that I am not doing anything
52             untoward with your banking data. This software is useful to me, but is
53             provided under B, explicit or implied.
54              
55             =cut
56              
57             my $parse_table = sub {
58             my ($html) = @_;
59             my $h = HTML::Parser->new;
60              
61             my (@l, $row, $td, $href);
62             $h->report_tags('td', 'tr', 'a');
63             $h->handler(start => sub {
64             my ($tag, $attr) = @_;
65             if ($tag eq 'tr') {
66             $row = [];
67             } elsif ($tag eq 'td') {
68             push @$row, ('') x ($attr->{colspan} - 1) if $attr->{colspan};
69             $td = '';
70             } elsif ($tag eq 'a') {
71             $href = $attr->{href} if defined $td;
72             }
73             }, 'tag,attr');
74             $h->handler(end => sub {
75             my ($tag) = @_;
76             if ($tag eq '/tr') {
77             push @l, $row if $row;
78             undef $row;
79             } elsif ($tag eq '/td' && defined $td) {
80             $td =~ s/(
81             | |\s)+/ /g;
82             $td =~ s/^\s*//;
83             $td =~ s/\s*$//;
84             push @$row, $href ? [ $td, $href ] : $td;
85             $href = $td = undef;
86             }
87             }, 'tag');
88            
89             $h->handler(text => sub {
90             my ($text) = @_;
91             $td .= " $text" if defined $td;
92             }, 'text');
93             $h->parse($html);
94              
95             \@l;
96             };
97              
98             my $normalize_number = sub {
99             my ($s) = @_;
100             defined($s) or return 0;
101             $s =~ s/\xC2?\xA0//; # non breakable space, both in UTF8 and latin1
102             $s =~ s/ //g;
103             $s =~ s/,/./;
104             $s + 0; # turn into a number
105             };
106              
107             =pod
108              
109             =head1 METHODS
110              
111             =head2 new(username => "0120123456L", password => "123456", cb_accounts => 1, all_accounts => 0, feedback => sub { warn "Finance::Bank::LaPoste: $_[0]\n" })
112              
113             Return an object . You can optionally provide to this method a LWP::UserAgent
114             object (argument named "ua"). You can also provide a function used for
115             feedback (useful for verbose mode or debugging) (argument named "feedback")
116              
117             =cut
118              
119             my $first_url = 'https://voscomptesenligne.labanquepostale.fr/voscomptes/canalXHTML/identif.ea?origin=particuliers';
120              
121             sub _login {
122 0     0     my ($self) = @_;
123 0 0         $self->{feedback}->("login") if $self->{feedback};
124              
125 0           my $cookie_jar = HTTP::Cookies->new;
126 0           my $response = $self->{ua}->request(HTTP::Request->new(GET => $first_url));
127 0           $cookie_jar->extract_cookies($response);
128 0           $self->{ua}->cookie_jar($cookie_jar);
129              
130 0           my %mangling_map = _get_number_mangling_map($self, $response->content);
131 0           my $password = join('', map { $mangling_map{$_} } split('', $self->{password}));
  0            
132              
133 0           my $form = HTML::Form->parse($response->content, $first_url);
134 0           $form->value(username => $self->{username});
135 0           $form->value(password => $password);
136              
137 0           push @{$self->{ua}->requests_redirectable}, 'POST';
  0            
138 0           $response = $self->{ua}->request($form->click);
139 0 0         $response->is_success or die "login failed\n" . $response->error_as_HTML;
140              
141 0 0         $self->{feedback}->("list accounts") if $self->{feedback};
142              
143 0           $response = _handle_javascript_redirects($self, $response);
144              
145 0           $self->{accounts} = [ _list_accounts($self, $response) ];
146             }
147              
148             sub _handle_javascript_redirects {
149 0     0     my ($self, $response) =@_;
150              
151 0           while ($response->content =~ /top.location.replace\(["'](.*)["']\)/) {
152 0           $response = $self->{ua}->request(HTTP::Request->new(GET => _rel_url($response, $1)));
153 0 0         $response->is_success or die "login failed\n" . $response->error_as_HTML;
154             }
155 0           $response;
156             }
157              
158             sub _rel_url {
159 0     0     my ($response, $rel) = @_;
160 0           my $base = $response->base;
161 0 0         if ($rel =~ m!^/!) {
162 0 0         $base =~ m!([^/]*//[^/]*)! && "$1$rel";
163             } else {
164 0           $base =~ s/\?.*//;
165 0           _rel_fullurl("$base/../$rel");
166             }
167             }
168              
169             sub _rel_fullurl {
170 0     0     my ($s) = @_;
171 0           while ($s =~ s![^/]*/\.\./!!) {}
172 0           $s;
173             }
174              
175              
176 0 0   0     sub _output { my $f = shift; open(my $F, ">$f") or die "output in file $f failed: $!\n"; print $F $_ foreach @_; 1 }
  0            
  0            
  0            
177              
178             sub _get_number_mangling_map {
179 0     0     my ($self, $html) = @_;
180              
181 0           my %map;
182 0           my $i = 0;
183 0           foreach (split("\n", $html)) {
184 0 0         if (/div data-tb-cvd-keys/ ... m!!) {
185 0 0         if (m!!) {
186 0           $map{$1} = $i++
187             }
188             }
189             }
190 0           %map;
191             }
192              
193             sub _get_img_map_data {
194 0     0     my ($self, $response) = @_;
195 0           my ($url) = $response->content =~ /background:url\((loginform.*?)\)/;
196 0           _GET_content($self, _rel_url($response, $url));
197             }
198              
199             sub _GET_content {
200 0     0     my ($self, $url) = @_;
201              
202 0           my $req = $self->{ua}->request(HTTP::Request->new(GET => $url));
203 0 0         $req->is_success or die "getting $url failed\n" . $req->error_as_HTML;
204 0           $req->content;
205             }
206              
207             sub _list_accounts {
208 0     0     my ($self, $response) = @_;
209 0           my $html = $response->content;
210 0           my @l = _list_accounts_one_page($self, $html);
211              
212 0           foreach my $account (@l) {
213 0 0         if ($account->{type} eq 'cb') {
214 0           my $html = _GET_content($self, $account->{url});
215 0           my @urls = $html =~ //g;
216 0 0         if (@urls) {
217 0           $account->{url} = _rel_url($response, $urls[-1]); # take last
218             }
219             }
220             }
221 0 0         if ($self->{all_accounts}) {
222 0           my $html = _GET_content($self, _rel_url($response, '/voscomptes/canalXHTML/comptesCommun/synthese_ep/afficheSyntheseEP-synthese_ep.ea'));
223 0           push @l, _list_accounts_one_page($self, $html, 'savings');
224             }
225 0           @l;
226             }
227              
228             sub _list_accounts_one_page {
229 0     0     my ($self, $html, $type) = @_;
230 0           my @l;
231              
232 0           my $flag = '';
233 0           my ($url, $account_info, $owner, $account_no, $balance_cb);
234              
235 0           foreach (split("\n", $html)) {
236 0 0         if ($flag) {
237 0 0 0       if (m!
    0 0        
    0 0        
238 0           $url = $1;
239 0           $account_info = ''
240             } elsif ($flag eq 'account_resume' && m!Solde ([\d\s,.+-]*)!) {
241 0           my $balance = $normalize_number->($1);
242 0           my $name;
243 0           ($name, $owner, $account_no) = $account_info =~ m!(.*?)(.*?)\s*N° (\w+)!;
244 0 0         push @l, { url => $url, balance => $balance, name => $name, owner => $owner, account_no => $account_no, type => $type } if $url;
245 0           $url = '';
246             } elsif ($flag eq 'balance_cb' && m!([\d\s,.+-]*)!) {
247 0           $flag = '';
248 0           $balance_cb = $normalize_number->($1);
249 0           $url =~ s/&/&/g;
250 0 0 0       push @l, { url => $url, balance => $balance_cb, name => "Carte bancaire", owner => $owner, account_no => $account_no, type => 'cb' } if $self->{cb_accounts} || $self->{all_accounts};
251             } else {
252 0           s/^\s*//;
253 0           s/\s*$//;
254 0           $account_info .= $_;
255             }
256             }
257 0 0         if (/account-resume--ccp|account-resume--saving/) {
    0          
258 0           $flag = 'account_resume';
259             } elsif (/
260
0           $flag = 'balance_cb';
261             }
262             }
263              
264 0           @l;
265             }
266              
267             sub new {
268 0     0 1   my ($class, %opts) = @_;
269 0           my $self = bless \%opts, $class;
270              
271 0 0         exists $self->{password} or croak "Must provide a password";
272 0 0         exists $self->{username} or croak "Must provide a username";
273              
274 0   0       $self->{ua} ||= LWP::UserAgent->new(agent => 'Mozilla');
275              
276             $self->{ua}->add_handler(request_preprepare => sub {
277 0     0     my ($request, $ua, $h) = @_;
278 0 0         if ($request->uri =~ m!/\.\./!) {
279 0           $request->uri(_rel_fullurl($request->uri));
280             }
281             #print $request->uri . "\n";
282 0           });
283              
284 0           _login($self);
285 0           $self;
286             }
287              
288             sub default_account {
289 0     0 0   die "default_account can't be used anymore";
290             }
291              
292             =pod
293              
294             =head2 check_balance(username => "0120123456L", password => "123456")
295              
296             Return a list of account (F::B::LaPoste::Account) objects, one for each of
297             your bank accounts.
298              
299             =cut
300              
301             sub check_balance {
302 0     0 1   my $self = &new;
303              
304 0           map { Finance::Bank::LaPoste::Account->new($self, %$_) } @{$self->{accounts}};
  0            
  0            
305             }
306              
307             package Finance::Bank::LaPoste::Account;
308              
309             =pod
310              
311             =head1 Account methods
312              
313             =head2 sort_code()
314              
315             Return the sort code of the account. Currently, it returns an undefined
316             value.
317              
318             =head2 name()
319              
320             Returns the human-readable name of the account.
321              
322             =head2 owner()
323              
324             Return the account owner, if available.
325              
326             =head2 account_no()
327              
328             Return the account number, in the form C<0123456L012>.
329              
330             =head2 balance()
331              
332             Returns the balance of the account.
333              
334             =head2 type()
335              
336             Returns the account type, like C or C.
337              
338             =head2 statements()
339              
340             Return a list of Statement object (Finance::Bank::LaPoste::Statement).
341              
342             =head2 currency()
343              
344             Returns the currency of the account as a three letter ISO code (EUR, CHF,
345             etc.).
346              
347             =cut
348              
349             sub new {
350 0     0     my ($class, $bank, %account) = @_;
351 0           $account{$_} = $bank->{$_} foreach qw(ua feedback);
352 0           bless \%account, $class;
353             }
354              
355 0     0     sub sort_code { undef }
356 0     0     sub name { $_[0]{name} }
357 0     0     sub owner { $_[0]{owner} }
358 0     0     sub account_no { $_[0]{account_no} }
359 0     0     sub balance { $_[0]{balance} }
360 0     0     sub type { $_[0]{type} }
361 0     0     sub currency { 'EUR' }
362             sub statements {
363 0     0     my ($self) = @_;
364 0 0         my $url = $self->{url} or return;
365 0           $url =~ s/typeRecherche=1$/typeRecherche=10/; # 400 last operations
366              
367 0   0       $self->{statements} ||= do {
368 0           my $retry;
369             retry:
370 0 0         $self->{feedback}->("get statements") if $self->{feedback};
371 0           my $response = $self->{ua}->request(HTTP::Request->new(GET => $url));
372 0 0         $response->is_success or die "can't access account $self->{name} statements\n" . $response->error_as_HTML;
373              
374 0           my $html = $response->content;
375              
376 0   0       $self->{balance} ||= do {
377 0   0       my $balance = ($html =~ m!(.*?) euros! || $html =~ m!(.*?) €!) && $1;
378 0           $normalize_number->($balance);
379             };
380 0           my $l = $parse_table->($html);
381              
382             @$l = map {
383 0           my ($date, $description, $amount1, $amount2) = @$_;
  0            
384 0   0       my $amount = $normalize_number->($amount2 || $amount1);
385 0 0 0       $date && $date =~ m!(\d+)/(\d+)! ? [ $date, $description, $amount ] : ();
386             } @$l;
387              
388             [ map {
389 0           my ($date, $description, $amount) = @$_;
  0            
390 0           my ($day, $month, $year) = $date =~ m|(\d+)/(\d+)/(\d+)|;
391 0           Finance::Bank::LaPoste::Statement->new(day => $day, month => $month, year => $year, description => $description, amount => $amount);
392             } @$l ];
393             };
394 0           @{$self->{statements}};
  0            
395             }
396              
397 0     0     sub _rel_url { &Finance::Bank::LaPoste::_rel_url }
398              
399              
400             package Finance::Bank::LaPoste::Statement;
401              
402             =pod
403              
404             =head1 Statement methods
405              
406             =head2 date()
407              
408             Returns the date when the statement occured, in DD/MM/YY format.
409              
410             =head2 description()
411              
412             Returns a brief description of the statement.
413              
414             =head2 amount()
415              
416             Returns the amount of the statement (expressed in Euros or the account's
417             currency). Although the Crédit Mutuel website displays number in continental
418             format (i.e. with a coma as decimal separator), amount() returns a real
419             number.
420              
421             =head2 as_string($separator)
422              
423             Returns a tab-delimited representation of the statement. By default, it uses
424             a tabulation to separate the fields, but the user can provide its own
425             separator.
426              
427             =cut
428              
429             sub new {
430 0     0     my ($class, %statement) = @_;
431 0           bless \%statement, $class;
432             }
433              
434 0     0     sub description { $_[0]{description} }
435 0     0     sub amount { $_[0]{amount} }
436             sub date {
437 0     0     my ($self) = @_;
438 0           my ($year) = $self->{year} =~ /..(..)/; # only 2 digits for year
439 0           "$year/$self->{month}/$self->{day}"
440             }
441              
442             sub as_string {
443 0     0     my ($self, $separator) = @_;
444 0   0       join($separator || "\t", $self->date, $self->{description}, $self->{amount});
445             }
446              
447             1;
448              
449             =pod
450              
451             =head1 COPYRIGHT
452              
453             Copyright 2002-2007, Pascal 'Pixel' Rigaux. All Rights Reserved. This module
454             can be redistributed under the same terms as Perl itself.
455              
456             =head1 AUTHOR
457              
458             Thanks to Cédric Bouvier for Finance::Bank::CreditMut
459             (and also to Simon Cozens and Briac Pilpré for various Finance::Bank::*)
460              
461             =head1 SEE ALSO
462              
463             Finance::Bank::BNPParibas, Finance::Bank::CreditMut
464              
465             =cut