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   3663 use strict;
  1         1  
  1         24  
4              
5 1     1   3 use Carp qw(carp croak);
  1         1  
  1         43  
6 1     1   740 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 = '7.11';
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             );
33              
34             foreach my $account (@accounts) {
35             print "Name: ", $account->name, " 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/ //;
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", 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             my ($self) = @_;
123             $self->{feedback}->("login") if $self->{feedback};
124              
125             my $cookie_jar = HTTP::Cookies->new;
126             my $response = $self->{ua}->request(HTTP::Request->new(GET => $first_url));
127             $cookie_jar->extract_cookies($response);
128             $self->{ua}->cookie_jar($cookie_jar);
129              
130             my %mangling_map = _get_number_mangling_map($self, _get_img_map_data($self, $response));
131             my $password = join('', map { $mangling_map{$_} } split('', $self->{password}));
132              
133             my $form = HTML::Form->parse($response->content, $first_url);
134             $form->value(username => $self->{username});
135             $form->value(password => $password);
136              
137             push @{$self->{ua}->requests_redirectable}, 'POST';
138             $response = $self->{ua}->request($form->click);
139             $response->is_success or die "login failed\n" . $response->error_as_HTML;
140              
141             $self->{feedback}->("list accounts") if $self->{feedback};
142              
143             $response = _handle_javascript_redirects($self, $response);
144              
145             $self->{accounts} = [ _list_accounts($self, $response) ];
146             }
147              
148             sub _handle_javascript_redirects {
149             my ($self, $response) =@_;
150              
151             while ($response->content =~ /top.location.replace\(["'](.*)["']\)/) {
152             $response = $self->{ua}->request(HTTP::Request->new(GET => _rel_url($response, $1)));
153             $response->is_success or die "login failed\n" . $response->error_as_HTML;
154             }
155             $response;
156             }
157              
158             sub _rel_url {
159             my ($response, $rel) = @_;
160             my $base = $response->base;
161             if ($rel =~ m!^/!) {
162             $base =~ m!([^/]*//[^/]*)! && "$1$rel";
163             } else {
164             $base =~ s/\?.*//;
165             my $s = "$base/../$rel";
166             while ($s =~ s![^/]*/\.\./!!) {}
167             $s;
168             }
169             }
170              
171             sub _output { my $f = shift; open(my $F, ">$f") or die "output in file $f failed: $!\n"; print $F $_ foreach @_; 1 }
172              
173             # to update %img_md5sum_to_number, set $debug_imgs to 1,
174             # then rename /tmp/img*.xpm into /tmp/[0-9].xpm according to the image
175             # then do "md5sum /tmp/[0-9].xpm"
176             my $debug_imgs = 0;
177             my %img_md5sum_to_number = (
178             'dbe97681a77bd75f811cd318e1b6def3' => 0,
179             '264fc8643f2277ce7df738d8bf0d4533' => 1,
180             'bc09366762776a5bca2161105607302b' => 2,
181             '71a5e8344d0343928ff077cf292fc7e3' => 3,
182             '50a363a8d16f6fbba5e8b14432e2d73e' => 4,
183             'd8ce75d8bd5c64a2ed10deede9ad7bc9' => 5,
184             '03c32205bcc9fa135b2a3d105dbb2644' => 6,
185             'ab159c63f95caa870429812c0cd09ea5' => 7,
186             '16454f3fb921be822f379682d0727f3f' => 8,
187             '336809b2bb178abdb8beec26e523af34' => 9,
188             '6110983d937627e8b2c131335c9c73e8' => 'blank',
189             );
190              
191             sub _get_number_mangling_map {
192             my ($self, $img_map_data) = @_;
193              
194             my $img_map=Graphics::Magick->new;
195             $img_map->BlobToImage($img_map_data);
196             $img_map->Threshold(threshold => '90%');
197              
198             my $size = 64;
199              
200             my $i = 0;
201             my %map;
202             for my $y (0 .. 3) {
203             for my $x (0 .. 3) {
204              
205             my $newimage = $img_map->Clone;
206             $newimage->Crop(geometry =>
207             sprintf("%dx%d+%d+%d",
208             12, 17,
209             25+ $x * ($size),
210             21 + $y * ($size)));
211             $newimage->Set(magick => 'xpm');
212             my ($img) = $newimage->ImageToBlob;
213             if ($debug_imgs) {
214             _output("/tmp/img$x$y.xpm", $img);
215             }
216             my $md5sum = Digest::MD5::md5_hex($img);
217             my $number = $img_md5sum_to_number{$md5sum};
218             defined($number) or die "missing md5sum, please update \%img_md5sum_to_number (setting \$debug_imgs will help)\n";
219             $map{$number} = sprintf("%02d", $i);
220             $i++;
221             }
222             }
223             %map;
224             }
225              
226             sub _get_img_map_data {
227             my ($self, $response) = @_;
228             my ($url) = $response->content =~ /background:url\((loginform.*?)\)/;
229             _GET_content($self, _rel_url($response, $url));
230             }
231              
232             sub _GET_content {
233             my ($self, $url) = @_;
234              
235             my $req = $self->{ua}->request(HTTP::Request->new(GET => $url));
236             $req->is_success or die "getting $url failed\n" . $req->error_as_HTML;
237             $req->content;
238             }
239              
240             sub _list_accounts {
241             my ($self, $response) = @_;
242              
243             my $accounts = $parse_table->($response->content);
244             map {
245             my ($account, $account_no, $balance) = grep { $_ ne '' } @$_;
246             if (ref $account && $account_no) {
247             my $url = $account->[1];
248             $url =~ s/typeRecherche=1$/typeRecherche=10/; # 400 last operations
249             {
250             name => $account->[0],
251             account_no => $account_no,
252             balance => $normalize_number->($balance),
253             $url =~ /(releve_ccp|releve_cne|releve_cb|mouvementsCarteDD)\.ea/ ? (url => _rel_url($response, $url)) : (),
254             };
255             } else { () }
256             } @$accounts;
257             }
258              
259             sub new {
260             my ($class, %opts) = @_;
261             my $self = bless \%opts, $class;
262              
263             exists $self->{password} or croak "Must provide a password";
264             exists $self->{username} or croak "Must provide a username";
265              
266             $self->{ua} ||= LWP::UserAgent->new(agent => 'Mozilla');
267              
268             _login($self);
269             $self;
270             }
271              
272             sub default_account {
273             die "default_account can't be used anymore";
274             }
275              
276             =pod
277              
278             =head2 check_balance(username => "0120123456L", password => "123456")
279              
280             Return a list of account (F::B::LaPoste::Account) objects, one for each of
281             your bank accounts.
282              
283             =cut
284              
285             sub check_balance {
286             my $self = &new;
287              
288             map { Finance::Bank::LaPoste::Account->new($self, %$_) } @{$self->{accounts}};
289             }
290              
291             package Finance::Bank::LaPoste::Account;
292              
293             =pod
294              
295             =head1 Account methods
296              
297             =head2 sort_code()
298              
299             Return the sort code of the account. Currently, it returns an undefined
300             value.
301              
302             =head2 name()
303              
304             Returns the human-readable name of the account.
305              
306             =head2 account_no()
307              
308             Return the account number, in the form C<0123456L012>.
309              
310             =head2 balance()
311              
312             Returns the balance of the account.
313              
314             =head2 statements()
315              
316             Return a list of Statement object (Finance::Bank::LaPoste::Statement).
317              
318             =head2 currency()
319              
320             Returns the currency of the account as a three letter ISO code (EUR, CHF,
321             etc.).
322              
323             =cut
324              
325             sub new {
326             my ($class, $bank, %account) = @_;
327             $account{$_} = $bank->{$_} foreach qw(ua feedback);
328             bless \%account, $class;
329             }
330              
331             sub sort_code { undef }
332             sub name { $_[0]{name} }
333             sub account_no { $_[0]{account_no} }
334             sub balance { $_[0]{balance} }
335             sub currency { 'EUR' }
336             sub statements {
337             my ($self) = @_;
338             $self->{url} or return;
339             $self->{statements} ||= do {
340             my $retry;
341             retry:
342             $self->{feedback}->("get statements") if $self->{feedback};
343             my $response = $self->{ua}->request(HTTP::Request->new(GET => $self->{url}));
344             $response->is_success or die "can't access account $self->{name} statements\n" . $response->error_as_HTML;
345              
346             my $html = $response->content;
347              
348             if ($html =~ /D..?tail de vos cartes/ && !$retry) {
349             my @l = $html =~ /a href="(.*preparerRecherche-mouvementsCarteDD.ea.*?)"/g;
350             $self->{url} = _rel_url($response, $l[0]); # taking first (??)
351             $retry++;
352             goto retry;
353             }
354              
355             my ($solde_month, $year) =
356             $html =~ /Solde\s+au\s+\d+\s+(\S+)\s+(20\d\d)/ ? ($1, $2) :
357             $html =~ m!au \d\d/(\d\d)/(20\d\d)!;
358              
359             $self->{balance} ||= do {
360             my ($balance) = $html =~ /(?:Solde|Encours\s+prélevé)\s+au.*?:\s+(.*?)\beuros/s;
361             $balance =~ s/<.*?>\s*//g; # (since 24/06/2004) remove: or ...
362             $normalize_number->($balance);
363             };
364             my $fourth_column_is_Francs = $html =~ m!>Francs.*!;
365             my $l = $parse_table->($html);
366              
367             @$l = map {
368             my ($date, $description, $amount_neg, $amount_pos) = @$_;
369             my $amount = $normalize_number->($amount_neg) + ($fourth_column_is_Francs ? 0 : $normalize_number->($amount_pos));
370             $date && $date =~ m!(\d+)/(\d+)! ? [ $date, $description, $amount ] : ();
371             } @$l;
372              
373             my $prev_month = $solde_month eq 'janvier' || $solde_month eq '01' ? 1 : 12;
374             [ map {
375             my ($date, $description, $amount) = @$_;
376             my ($day, $month) = $date =~ m|(\d+)/(\d+)|;
377             $year-- if $month > $prev_month;
378             $prev_month = $month;
379             Finance::Bank::LaPoste::Statement->new(day => $day, month => $month, year => $year, description => $description, amount => $amount);
380             } @$l ];
381             };
382             @{$self->{statements}};
383             }
384              
385             sub _rel_url { &Finance::Bank::LaPoste::_rel_url }
386              
387              
388             package Finance::Bank::LaPoste::Statement;
389              
390             =pod
391              
392             =head1 Statement methods
393              
394             =head2 date()
395              
396             Returns the date when the statement occured, in DD/MM/YY format.
397              
398             =head2 description()
399              
400             Returns a brief description of the statement.
401              
402             =head2 amount()
403              
404             Returns the amount of the statement (expressed in Euros or the account's
405             currency). Although the Crédit Mutuel website displays number in continental
406             format (i.e. with a coma as decimal separator), amount() returns a real
407             number.
408              
409             =head2 as_string($separator)
410              
411             Returns a tab-delimited representation of the statement. By default, it uses
412             a tabulation to separate the fields, but the user can provide its own
413             separator.
414              
415             =cut
416              
417             sub new {
418             my ($class, %statement) = @_;
419             bless \%statement, $class;
420             }
421              
422             sub description { $_[0]{description} }
423             sub amount { $_[0]{amount} }
424             sub date {
425             my ($self) = @_;
426             my ($year) = $self->{year} =~ /..(..)/; # only 2 digits for year
427             "$year/$self->{month}/$self->{day}"
428             }
429              
430             sub as_string {
431             my ($self, $separator) = @_;
432             join($separator || "\t", $self->date, $self->{description}, $self->{amount});
433             }
434              
435             1;
436              
437             =pod
438              
439             =head1 COPYRIGHT
440              
441             Copyright 2002-2007, Pascal 'Pixel' Rigaux. All Rights Reserved. This module
442             can be redistributed under the same terms as Perl itself.
443              
444             =head1 AUTHOR
445              
446             Thanks to Cédric Bouvier for Finance::Bank::CreditMut
447             (and also to Simon Cozens and Briac Pilpré for various Finance::Bank::*)
448              
449             =head1 SEE ALSO
450              
451             Finance::Bank::BNPParibas, Finance::Bank::CreditMut
452              
453             =cut