File Coverage

blib/lib/Finance/Bank/LaPoste.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             package Finance::Bank::LaPoste;
2              
3 1     1   3935 use strict;
  1         2  
  1         26  
4              
5 1     1   4 use Carp qw(carp croak);
  1         2  
  1         38  
6 1     1   722 use Graphics::Magick;
  0            
  0            
7             use HTTP::Cookies;
8             use LWP::UserAgent;
9             use HTML::Parser;
10             use HTML::Form;
11             use Digest::MD5();
12              
13             our $VERSION = '8.01';
14              
15             # $Id: $
16             # $Log: LaPoste.pm,v $
17              
18             =pod
19              
20             =head1 NAME
21              
22             Finance::Bank::LaPoste - Check your "La Poste" accounts from Perl
23              
24             =head1 SYNOPSIS
25              
26             use Finance::Bank::LaPoste;
27              
28             my @accounts = Finance::Bank::LaPoste->check_balance(
29             username => "0120123456L", # your main account is something like
30             # 0123456 L 012, stick it together with the region first
31             password => "123456", # a password is usually 6 numbers
32             all_accounts => 1, # if you want credit card and savings accounts
33             );
34              
35             foreach my $account (@accounts) {
36             print "Name: ", $account->name, " ", $account->owner, " Account_no: ", $account->account_no, "\n", "*" x 80, "\n";
37             print $_->as_string, "\n" foreach $account->statements;
38             }
39              
40             =head1 DESCRIPTION
41              
42             This module provides a read-only interface to the Videoposte online banking
43             system at L. You will need either Crypt::SSLeay
44             installed.
45              
46             The interface of this module is similar to other Finance::Bank::* modules.
47              
48             =head1 WARNING
49              
50             This is code for B, and that means B, and that
51             means B. You are encouraged, nay, expected, to audit the source
52             of this module yourself to reassure yourself that I am not doing anything
53             untoward with your banking data. This software is useful to me, but is
54             provided under B, explicit or implied.
55              
56             =cut
57              
58             my $parse_table = sub {
59             my ($html) = @_;
60             my $h = HTML::Parser->new;
61              
62             my (@l, $row, $td, $href);
63             $h->report_tags('td', 'tr', 'a');
64             $h->handler(start => sub {
65             my ($tag, $attr) = @_;
66             if ($tag eq 'tr') {
67             $row = [];
68             } elsif ($tag eq 'td') {
69             push @$row, ('') x ($attr->{colspan} - 1) if $attr->{colspan};
70             $td = '';
71             } elsif ($tag eq 'a') {
72             $href = $attr->{href} if defined $td;
73             }
74             }, 'tag,attr');
75             $h->handler(end => sub {
76             my ($tag) = @_;
77             if ($tag eq '/tr') {
78             push @l, $row if $row;
79             undef $row;
80             } elsif ($tag eq '/td' && defined $td) {
81             $td =~ s/(
82             | |\s)+/ /g;
83             $td =~ s/^\s*//;
84             $td =~ s/\s*$//;
85             push @$row, $href ? [ $td, $href ] : $td;
86             $href = $td = undef;
87             }
88             }, 'tag');
89            
90             $h->handler(text => sub {
91             my ($text) = @_;
92             $td .= " $text" if defined $td;
93             }, 'text');
94             $h->parse($html);
95              
96             \@l;
97             };
98              
99             my $normalize_number = sub {
100             my ($s) = @_;
101             defined($s) or return 0;
102             $s =~ s/\xC2?\xA0//; # non breakable space, both in UTF8 and latin1
103             $s =~ s/ //;
104             $s =~ s/,/./;
105             $s + 0; # turn into a number
106             };
107              
108             =pod
109              
110             =head1 METHODS
111              
112             =head2 new(username => "0120123456L", password => "123456", cb_accounts => 1, all_accounts => 0, feedback => sub { warn "Finance::Bank::LaPoste: $_[0]\n" })
113              
114             Return an object . You can optionally provide to this method a LWP::UserAgent
115             object (argument named "ua"). You can also provide a function used for
116             feedback (useful for verbose mode or debugging) (argument named "feedback")
117              
118             =cut
119              
120             my $first_url = 'https://voscomptesenligne.labanquepostale.fr/voscomptes/canalXHTML/identif.ea?origin=particuliers';
121              
122             sub _login {
123             my ($self) = @_;
124             $self->{feedback}->("login") if $self->{feedback};
125              
126             my $cookie_jar = HTTP::Cookies->new;
127             my $response = $self->{ua}->request(HTTP::Request->new(GET => $first_url));
128             $cookie_jar->extract_cookies($response);
129             $self->{ua}->cookie_jar($cookie_jar);
130              
131             my %mangling_map = _get_number_mangling_map($self, _get_img_map_data($self, $response));
132             my $password = join('', map { $mangling_map{$_} } split('', $self->{password}));
133              
134             my $form = HTML::Form->parse($response->content, $first_url);
135             $form->value(username => $self->{username});
136             $form->value(password => $password);
137              
138             push @{$self->{ua}->requests_redirectable}, 'POST';
139             $response = $self->{ua}->request($form->click);
140             $response->is_success or die "login failed\n" . $response->error_as_HTML;
141              
142             $self->{feedback}->("list accounts") if $self->{feedback};
143              
144             $response = _handle_javascript_redirects($self, $response);
145              
146             $self->{accounts} = [ _list_accounts($self, $response) ];
147             }
148              
149             sub _handle_javascript_redirects {
150             my ($self, $response) =@_;
151              
152             while ($response->content =~ /top.location.replace\(["'](.*)["']\)/) {
153             $response = $self->{ua}->request(HTTP::Request->new(GET => _rel_url($response, $1)));
154             $response->is_success or die "login failed\n" . $response->error_as_HTML;
155             }
156             $response;
157             }
158              
159             sub _rel_url {
160             my ($response, $rel) = @_;
161             my $base = $response->base;
162             if ($rel =~ m!^/!) {
163             $base =~ m!([^/]*//[^/]*)! && "$1$rel";
164             } else {
165             $base =~ s/\?.*//;
166             my $s = "$base/../$rel";
167             while ($s =~ s![^/]*/\.\./!!) {}
168             $s;
169             }
170             }
171              
172             sub _output { my $f = shift; open(my $F, ">$f") or die "output in file $f failed: $!\n"; print $F $_ foreach @_; 1 }
173              
174             # to update %img_md5sum_to_number, set $debug_imgs to 1,
175             # then rename /tmp/img*.xpm into /tmp/[0-9].xpm according to the image
176             # then do "md5sum /tmp/[0-9].xpm"
177             my $debug_imgs = 0;
178             my %img_md5sum_to_number = (
179             'dbe97681a77bd75f811cd318e1b6def3' => 0,
180             '264fc8643f2277ce7df738d8bf0d4533' => 1,
181             'bc09366762776a5bca2161105607302b' => 2,
182             '71a5e8344d0343928ff077cf292fc7e3' => 3,
183             '50a363a8d16f6fbba5e8b14432e2d73e' => 4,
184             'd8ce75d8bd5c64a2ed10deede9ad7bc9' => 5,
185             '03c32205bcc9fa135b2a3d105dbb2644' => 6,
186             'ab159c63f95caa870429812c0cd09ea5' => 7,
187             '16454f3fb921be822f379682d0727f3f' => 8,
188             '336809b2bb178abdb8beec26e523af34' => 9,
189             '6110983d937627e8b2c131335c9c73e8' => 'blank',
190             );
191              
192             sub _get_number_mangling_map {
193             my ($self, $img_map_data) = @_;
194              
195             my $img_map=Graphics::Magick->new;
196             $img_map->BlobToImage($img_map_data);
197             $img_map->Threshold(threshold => '90%');
198              
199             my $size = 64;
200              
201             my $i = 0;
202             my %map;
203             for my $y (0 .. 3) {
204             for my $x (0 .. 3) {
205              
206             my $newimage = $img_map->Clone;
207             $newimage->Crop(geometry =>
208             sprintf("%dx%d+%d+%d",
209             12, 17,
210             25+ $x * ($size),
211             21 + $y * ($size)));
212             $newimage->Set(magick => 'xpm');
213             my ($img) = $newimage->ImageToBlob;
214             if ($debug_imgs) {
215             _output("/tmp/img$x$y.xpm", $img);
216             }
217             my $md5sum = Digest::MD5::md5_hex($img);
218             my $number = $img_md5sum_to_number{$md5sum};
219             defined($number) or die "missing md5sum, please update \%img_md5sum_to_number (setting \$debug_imgs will help)\n";
220             $map{$number} = sprintf("%02d", $i);
221             $i++;
222             }
223             }
224             %map;
225             }
226              
227             sub _get_img_map_data {
228             my ($self, $response) = @_;
229             my ($url) = $response->content =~ /background:url\((loginform.*?)\)/;
230             _GET_content($self, _rel_url($response, $url));
231             }
232              
233             sub _GET_content {
234             my ($self, $url) = @_;
235              
236             my $req = $self->{ua}->request(HTTP::Request->new(GET => $url));
237             $req->is_success or die "getting $url failed\n" . $req->error_as_HTML;
238             $req->content;
239             }
240              
241             sub _list_accounts {
242             my ($self, $response) = @_;
243             my $html = $response->content;
244             my @l = _list_accounts_one_page($self, $html);
245              
246             if ($self->{cb_accounts} || $self->{all_accounts}) {
247             if (my ($url) = $html =~ m!
248             $url =~ s/&/&/g;
249             push @l, _list_cb_accounts($self, $url);
250             }
251             }
252             if ($self->{all_accounts}) {
253             my $html = _GET_content($self, _rel_url($response, '/voscomptes/canalXHTML/comptesCommun/synthese_ep/afficheSyntheseEP-synthese_ep.ea'));
254             push @l, _list_accounts_one_page($self, $html);
255             }
256             @l;
257             }
258              
259             sub _list_accounts_one_page {
260             my ($self, $html) = @_;
261             my @l;
262              
263             my $flag = '';
264             my ($url, $name, $owner, $account_no);
265              
266             foreach (split("\n", $html)) {
267             if ($flag eq 'url' && m!
268             $url = $1;
269             } elsif (m!

(.*?)\s*

(?:(.*))?!) {
270             $name = $1;
271             $owner = $2;
272             } elsif (m!num(?:é|..?)ro de compte">.*(.*?)
273             $account_no = $1;
274             } elsif (m!([\d\s,.+-]*)! && $url) {
275             my $balance = $normalize_number->($1);
276             push @l, { url => $url, balance => $balance, name => $name, owner => $owner, account_no => $account_no } if $url;
277             $url = '';
278             }
279              
280             if (/account-resume--banq|account-resume--saving/) {
281             $flag = 'url';
282             } else {
283             $flag = '';
284             }
285             }
286              
287             @l;
288             }
289              
290             sub _list_cb_accounts {
291             my ($self, $url) = @_;
292              
293             my $response = $self->{ua}->request(HTTP::Request->new(GET => $url));
294             $response->is_success or die "getting $url failed\n" . $response->error_as_HTML;
295              
296             my $accounts = $parse_table->($response->content);
297             map {
298             my ($account, $account_no, $balance) = grep { $_ ne '' } @$_;
299             if (ref $account && $account_no) {
300             my $url = $account->[1];
301             $url =~ s/typeRecherche=1$/typeRecherche=10/; # 400 last operations
302             {
303             name => $account->[0],
304             account_no => $account_no,
305             balance => $normalize_number->($balance),
306             $url =~ /(releve_ccp|releve_cne|releve_cb|mouvementsCarteDD)\.ea/ ? (url => _rel_url($response, $url)) : (),
307             };
308             } else { () }
309             } @$accounts;
310             }
311              
312             sub new {
313             my ($class, %opts) = @_;
314             my $self = bless \%opts, $class;
315              
316             exists $self->{password} or croak "Must provide a password";
317             exists $self->{username} or croak "Must provide a username";
318              
319             $self->{ua} ||= LWP::UserAgent->new(agent => 'Mozilla');
320              
321             _login($self);
322             $self;
323             }
324              
325             sub default_account {
326             die "default_account can't be used anymore";
327             }
328              
329             =pod
330              
331             =head2 check_balance(username => "0120123456L", password => "123456")
332              
333             Return a list of account (F::B::LaPoste::Account) objects, one for each of
334             your bank accounts.
335              
336             =cut
337              
338             sub check_balance {
339             my $self = &new;
340              
341             map { Finance::Bank::LaPoste::Account->new($self, %$_) } @{$self->{accounts}};
342             }
343              
344             package Finance::Bank::LaPoste::Account;
345              
346             =pod
347              
348             =head1 Account methods
349              
350             =head2 sort_code()
351              
352             Return the sort code of the account. Currently, it returns an undefined
353             value.
354              
355             =head2 name()
356              
357             Returns the human-readable name of the account.
358              
359             =head2 owner()
360              
361             Return the account owner, if available.
362              
363             =head2 account_no()
364              
365             Return the account number, in the form C<0123456L012>.
366              
367             =head2 balance()
368              
369             Returns the balance of the account.
370              
371             =head2 statements()
372              
373             Return a list of Statement object (Finance::Bank::LaPoste::Statement).
374              
375             =head2 currency()
376              
377             Returns the currency of the account as a three letter ISO code (EUR, CHF,
378             etc.).
379              
380             =cut
381              
382             sub new {
383             my ($class, $bank, %account) = @_;
384             $account{$_} = $bank->{$_} foreach qw(ua feedback);
385             bless \%account, $class;
386             }
387              
388             sub sort_code { undef }
389             sub name { $_[0]{name} }
390             sub owner { $_[0]{owner} }
391             sub account_no { $_[0]{account_no} }
392             sub balance { $_[0]{balance} }
393             sub currency { 'EUR' }
394             sub statements {
395             my ($self) = @_;
396             $self->{url} or return;
397             $self->{statements} ||= do {
398             my $retry;
399             retry:
400             $self->{feedback}->("get statements") if $self->{feedback};
401             my $response = $self->{ua}->request(HTTP::Request->new(GET => $self->{url}));
402             $response->is_success or die "can't access account $self->{name} statements\n" . $response->error_as_HTML;
403              
404             my $html = $response->content;
405              
406             $self->{balance} ||= do {
407             my ($balance) = $html =~ m!(.*?) €!;
408             $normalize_number->($balance);
409             };
410             my $l = $parse_table->($html);
411              
412             @$l = map {
413             my ($date, $description, $amount1, $amount2) = @$_;
414             my $amount = $normalize_number->($amount2 || $amount1);
415             $date && $date =~ m!(\d+)/(\d+)! ? [ $date, $description, $amount ] : ();
416             } @$l;
417              
418             [ map {
419             my ($date, $description, $amount) = @$_;
420             my ($day, $month, $year) = $date =~ m|(\d+)/(\d+)/(\d+)|;
421             Finance::Bank::LaPoste::Statement->new(day => $day, month => $month, year => $year, description => $description, amount => $amount);
422             } @$l ];
423             };
424             @{$self->{statements}};
425             }
426              
427             sub _rel_url { &Finance::Bank::LaPoste::_rel_url }
428              
429              
430             package Finance::Bank::LaPoste::Statement;
431              
432             =pod
433              
434             =head1 Statement methods
435              
436             =head2 date()
437              
438             Returns the date when the statement occured, in DD/MM/YY format.
439              
440             =head2 description()
441              
442             Returns a brief description of the statement.
443              
444             =head2 amount()
445              
446             Returns the amount of the statement (expressed in Euros or the account's
447             currency). Although the Crédit Mutuel website displays number in continental
448             format (i.e. with a coma as decimal separator), amount() returns a real
449             number.
450              
451             =head2 as_string($separator)
452              
453             Returns a tab-delimited representation of the statement. By default, it uses
454             a tabulation to separate the fields, but the user can provide its own
455             separator.
456              
457             =cut
458              
459             sub new {
460             my ($class, %statement) = @_;
461             bless \%statement, $class;
462             }
463              
464             sub description { $_[0]{description} }
465             sub amount { $_[0]{amount} }
466             sub date {
467             my ($self) = @_;
468             my ($year) = $self->{year} =~ /..(..)/; # only 2 digits for year
469             "$year/$self->{month}/$self->{day}"
470             }
471              
472             sub as_string {
473             my ($self, $separator) = @_;
474             join($separator || "\t", $self->date, $self->{description}, $self->{amount});
475             }
476              
477             1;
478              
479             =pod
480              
481             =head1 COPYRIGHT
482              
483             Copyright 2002-2007, Pascal 'Pixel' Rigaux. All Rights Reserved. This module
484             can be redistributed under the same terms as Perl itself.
485              
486             =head1 AUTHOR
487              
488             Thanks to Cédric Bouvier for Finance::Bank::CreditMut
489             (and also to Simon Cozens and Briac Pilpré for various Finance::Bank::*)
490              
491             =head1 SEE ALSO
492              
493             Finance::Bank::BNPParibas, Finance::Bank::CreditMut
494              
495             =cut