File Coverage

blib/lib/Finance/Bank/Postbank_de/Account.pm
Criterion Covered Total %
statement 127 135 94.0
branch 57 70 81.4
condition 18 21 85.7
subroutine 14 14 100.0
pod 4 7 57.1
total 220 247 89.0


line stmt bran cond sub pod time code
1             package Finance::Bank::Postbank_de::Account;
2 14     14   4343 use 5.006;
  14         42  
3 14     14   76 use strict;
  14         22  
  14         237  
4 14     14   53 use warnings;
  14         22  
  14         371  
5 14     14   64 use Carp qw(croak);
  14         48  
  14         601  
6 14     14   2063 use POSIX qw(strftime);
  14         25291  
  14         61  
7 14     14   8450 use Moo 2;
  14         45197  
  14         74  
8              
9             our $VERSION = '0.56';
10              
11             has [
12             'number',
13             'balance',
14             'balance_unavailable',
15             'balance_prev',
16             'transactions_future',
17             'iban',
18             'blz',
19             'account_type',
20             'name',
21             ] => ( is => 'rw' );
22              
23             around BUILDARGS => sub {
24             my ($orig, $class,%args) = @_;
25              
26             if( exists $args{ number } and exists $args{ kontonummer }
27             and $args{ number } ne $args{ kontonummer } ) {
28             croak "'kontonummer' is '$args{kontonummer}' and 'number' is '$args{ number }'";
29             };
30             my $num = delete $args{number} || delete $args{kontonummer};
31             $args{ number } = $num
32             if defined $num;
33              
34             $orig->( $class, %args );
35             };
36              
37 14     14   10078 { no warnings 'once';
  14         28  
  14         27028  
38             *kontonummer = *number;
39             }
40              
41             our %safety_check = (
42             name => 1,
43             kontonummer => 1,
44             );
45              
46             our %tags = (
47             #Girokonto => [qw(Name BLZ Kontonummer IBAN)],
48             "gebuchte Ums\x{00E4}tze" => [qw(Name BLZ Kontonummer IBAN)],
49             Tagesgeldkonto => [qw(Name BLZ Kontonummer IBAN)],
50             Sparcard => [qw(Name BLZ Kontonummer IBAN)],
51             Sparkonto => [qw(Name BLZ Kontonummer IBAN)],
52             Kreditkarte => [qw(Name BLZ Kontonummer IBAN)],
53             '' => [qw(Name BLZ Kontonummer IBAN)],
54             );
55              
56             our %totals = (
57             "gebuchte Ums\x{00E4}tze" => [
58             [qr'^Aktueller Kontostand' => 'balance'],
59             [qr'^Summe vorgemerkter Ums.tze' => 'transactions_future'],
60             [qr'^Davon noch nicht verf.gbar' => 'balance_unavailable'],
61             ],
62             Sparcard => [[qr'Aktueller Kontostand' => 'balance'],],
63             Sparkonto => [[qr'Aktueller Kontostand' => 'balance'],],
64             Tagesgeldkonto => [[qr'Aktueller Kontostand' => 'balance'],],
65             "" => [
66             [qr'^Aktueller Kontostand' => 'balance'],
67             [qr'^Summe der Ums.tze in den n.chsten 14 Tagen' => 'transactions_future'],
68             [qr'^Davon noch nicht verf.gbar' => 'balance_unavailable'],
69             ],
70             );
71              
72             our %columns = (
73             qr'Buchungsdatum' => 'tradedate',
74             qr'Wertstellung' => 'valuedate',
75             qr'Umsatzart' => 'type',
76             qr'Buchungsdetails' => 'comment',
77             qr'Auftraggeber' => 'sender',
78             qr'Empf.nger' => 'receiver',
79             qr"Betrag \((?:\x{20AC}|\x{80})\)" => 'amount',
80             qr"Saldo \((?:\x{20AC}|\x{80})\)" => 'running_total',
81             );
82              
83             sub parse_date {
84 367     367 0 3612 my ($self,$date) = @_;
85 367 100       1011 $date =~ /^(\d{2})\.(\d{2})\.(\d{4})$/
86             or die "Unknown date format '$date'. A date must be in the format 'DD.MM.YYYY'\n";
87 364         1072 $3.$2.$1;
88             };
89              
90             sub parse_amount {
91 404     404 0 5812 my ($self,$amount) = @_;
92             # '¿ 5.314,05'
93 404 100       1665 die "String '$amount' does not look like a number"
94             unless $amount =~ /^(-?)(?:\s*\x{20AC}\s*|\s*\x{80}\s*|\s*\x{A4}\s*)?([0-9]{1,3}(?:\.\d{3})*,\d{2})(?:\s*\x{20AC}|\s*\x{80})?$/;
95 401   100     1245 $amount = ($1||'') . $2;
96 401         641 $amount =~ tr/.//d;
97 401         1006 $amount =~ s/,/./;
98 401         1004 $amount;
99             };
100              
101             sub slurp_file {
102 3     3 0 4 my ($self,$filename) = @_;
103 3         11 local $/ = undef;
104 3 50       106 open my $fh, "< $filename"
105             or croak "Couldn't read from file '$filename' : $!";
106 3         34 binmode $fh, ':encoding(UTF-8)';
107 3         220 <$fh>;
108             };
109              
110             sub parse_statement {
111 27     27 1 39966 my ($self,%args) = @_;
112              
113             # If $self is just a string, we want to make a new class out of us
114 27 100       449 $self = $self->new
115             unless ref $self;
116 27         519 my $filename = $args{file};
117 27         56 my $raw_statement = $args{content};
118 27 100       103 if ($filename) {
    100          
119 4         9 $raw_statement = $self->slurp_file($filename);
120             } elsif (! defined $raw_statement) {
121             croak "Need an account number if I have to retrieve the statement online"
122 4 100       165 unless $args{number};
123             croak "Need a password if I have to retrieve the statement online"
124 3 100       96 unless exists $args{password};
125 2   66     7 my $login = $args{login} || $args{number};
126              
127 2         8 require Finance::Bank::Postbank_de;
128 2         10 return Finance::Bank::Postbank_de->new( login => $login, password => $args{password}, past_days => $args{past_days} )->get_account_statement;
129             };
130              
131 22 100       238 croak "Don't know what to do with empty content"
132             unless $raw_statement;
133              
134 21         558 my @lines = split /\r?\n/, $raw_statement;
135 21 100       177 croak "No valid account statement: '$lines[0]'"
136             unless $lines[0] =~ /^Umsatzauskunft;$/;
137 20         42 shift @lines;
138              
139 20         43 my $account_type = '';
140             #my $account_type = $1;
141             #if( ! exists $tags{ $account_type }) {
142             # $account_type =~ s!([^\x00-\x7f])!sprintf '%08x', ord($1)!ge;
143             # croak "Unknown account type '$account_type' (" . (join ",",keys %tags) . ")"
144             # unless exists $tags{$account_type};
145             #};
146             #$self->account_type($account_type);
147              
148             # Name: PETRA PFIFFIG
149             #for my $tag (@{ $tags{ $self->account_type }||[] }) {
150 20         36 my $sep = ";";
151 20 100       88 if( $lines[0] =~ /([\t;])/) {
152 19         48 $sep = $1;
153             };
154 20 50       35 for my $tag (@{ $tags{ $account_type }||[] }) {
  20         87  
155 73 100       1518 $lines[0] =~ /^\Q$tag\E$sep(.*?)$sep?$/
156             or croak "Field '$tag' not found in account statement ($lines[0])";
157 69         157 my $method = lc($tag);
158 69         125 my $value = $1;
159              
160             # special check for special fields:
161             croak "Wrong/mixed account $method: Got '$value', expected '" . $self->$method . "'"
162 69 100 100     447 if (exists $safety_check{$method} and defined $self->$method and $self->$method ne $value);
      100        
163              
164 68         186 $self->$method($value);
165 68         145 shift @lines;
166             };
167              
168              
169 15         87 while ($lines[0] !~ /^\s*$/) {
170 30         66 my $line = shift @lines;
171 30         53 my ($method,$balance);
172 30 50       50 for my $total (@{ $totals{ $account_type }||[] }) {
  30         122  
173 90         200 my ($re,$possible_method) = @$total;
174 90 100       2304 if ($line =~ /$re$sep\s*(?:(?:(\S+)\s*(?:\x{20AC}|\x{80}))|(null))$sep$/) {
175 30         67 $method = $possible_method;
176 30   33     98 $balance = $1 || $2;
177 30 50       123 if ($balance =~ /^(-?[0-9.,]+)\s*$/) {
    0          
178 30         99 $self->$method( ['????????',$self->parse_amount($balance)]);
179             } elsif ('null' eq $balance) {
180 0         0 $self->$method( ['????????',$self->parse_amount("0,00")]);
181             } else {
182 0         0 die "Invalid number '$balance' found for $method in '$line'";
183             };
184             };
185             };
186 30 50       155 if (! $method) {
187 0         0 $account_type =~ s!([^\x00-\x7f])!sprintf '%08x', ord($1)!ge;
  0         0  
188 0         0 $line =~ s!([^\x00-\x7f])!sprintf '%08x', ord($1)!ge;
  0         0  
189 0         0 croak "No summary found in account '$account_type' statement ($line)";
190             };
191             };
192              
193 15 50       75 $lines[0] =~ m!^\s*$!
194             or croak "Expected an empty line after the account balances, got '$lines[0]'";
195 15         30 shift @lines;
196              
197             # Now skip over the transactions in the future
198 15         61 while( $lines[0] !~ /^gebuchte Ums.tze/ ) {
199 105         189 shift @lines;
200             };
201 15         29 shift @lines;
202              
203             # Now parse the lines for each cashflow :
204 15 50       121 $lines[0] =~ /^Buchungsdatum${sep}Wertstellung${sep}Umsatzart/
205             or croak "Couldn't find start of transactions ($lines[0])";
206              
207 15         33 my (@fields);
208             COLUMN:
209 15         113 for my $col (split /$sep/, $lines[0]) {
210 120         392 for my $target (keys %columns) {
211 540 100       7739 if ($col =~ m!^["']?$target["']?$!) {
212 120         306 push @fields, $columns{$target};
213 120         307 next COLUMN;
214             };
215             };
216 0         0 die "Unknown column '$col' in '$lines[0]'";
217             };
218 15         44 shift @lines;
219              
220 15         121 my (%convert) = (
221             tradedate => \&parse_date,
222             valuedate => \&parse_date,
223             amount => \&parse_amount,
224             running_total => \&parse_amount,
225             );
226              
227 15         38 my @transactions;
228             my $line;
229 15         43 for $line (@lines) {
230 180 50       489 next if $line =~ /^\s*$/;
231 180         738 my (@row) = split /$sep/, $line;
232 180 50       358 scalar @row == scalar @fields
233             or die "Malformed cashflow ($line): Expected ".scalar(@fields)." entries, got ".scalar(@row);
234              
235 180         314 for (@row) {
236 1440 50       2702 $_ = $1
237             if /^\s*["']\s*(.*?)\s*["']\s*$/;
238             };
239              
240 180         224 my (%rec);
241 180         735 @rec{@fields} = @row;
242 180         403 for (keys %convert) {
243 720         1183 $rec{$_} = $convert{$_}->($self,$rec{$_});
244             };
245              
246 180         466 push @transactions, \%rec;
247             };
248              
249             # Filter the transactions
250 15         62 $self->{transactions} = \@transactions;
251              
252 15         163 $self
253             };
254              
255             sub transactions {
256 84     84 1 86599 my ($self,%args) = @_;
257              
258 84         155 my ($start_date,$end_date);
259 84 100       164 if (exists $args{on}) {
260              
261             croak "Options 'since'+'upto' and 'on' are incompatible"
262 33 100 100     258 if (exists $args{since} and exists $args{upto});
263             croak "Options 'since' and 'on' are incompatible"
264 32 100       129 if (exists $args{since});
265             croak "Options 'upto' and 'on' are incompatible"
266 31 100       116 if (exists $args{upto});
267             $args{on} = strftime('%Y%m%d',localtime())
268 30 100       200 if ($args{on} eq 'today');
269 30 50       125 $args{on} =~ /^\d{8}$/ or croak "Argument {on => '$args{on}'} dosen't look like a date to me.";
270              
271 30         46 $start_date = $args{on} -1;
272 30         45 $end_date = $args{on};
273             } else {
274 51   100     123 $start_date = $args{since} || "00000000";
275 51   100     136 $end_date = $args{upto} || "99999999";
276 51 100       663 $start_date =~ /^\d{8}$/ or croak "Argument {since => '$start_date'} dosen't look like a date to me.";
277 44 100       557 $end_date =~ /^\d{8}$/ or croak "Argument {upto => '$end_date'} dosen't look like a date to me.";
278 37 100       226 $start_date < $end_date or croak "The 'since' argument must be less than the 'upto' argument";
279             };
280              
281             # Filter the transactions
282 65 100       78 grep { $_->{tradedate} > $start_date and $_->{tradedate} <= $end_date } @{$self->{transactions}};
  780         1751  
  65         131  
283             };
284              
285             sub value_dates {
286 1     1 1 7 my ($self) = @_;
287 1         2 my %dates;
288 1         4 $dates{$_->{valuedate}} = 1 for $self->transactions();
289 1         11 sort keys %dates;
290             };
291              
292             sub trade_dates {
293 1     1 1 1179 my ($self) = @_;
294 1         2 my %dates;
295 1         3 $dates{$_->{tradedate}} = 1 for $self->transactions();
296 1         9 sort keys %dates;
297             };
298              
299             1;
300             __END__
301              
302             =encoding ISO8859-1
303              
304             =head1 NAME
305              
306             Finance::Bank::Postbank_de::Account - Postbank bank account class
307              
308             =head1 SYNOPSIS
309              
310             =for example begin
311              
312             use strict;
313             require Crypt::SSLeay; # It's a prerequisite
314             use Finance::Bank::Postbank_de::Account;
315             my $statement = Finance::Bank::Postbank_de::Account->parse_statement(
316             login => 'Petra.Pfiffig',
317             password => '123456789',
318             );
319             # Retrieve account data :
320             print "Balance : ",$statement->balance->[1]," EUR\n";
321              
322             # Output CSV for the transactions
323             for my $row ($statement->transactions) {
324             print join( ";", map { $row->{$_} } (qw( tradedate valuedate type comment receiver sender amount ))),"\n";
325             };
326              
327             =for example end
328              
329             =for example_testing
330             isa_ok($statement,"Finance::Bank::Postbank_de::Account");
331             my $expected = <<EOX;
332             Balance : 5314.05 EUR
333             .berweisung;111111/1000000000/37050198 FINANZKASSE 3991234 STEUERNUMMER 00703434;Finanzkasse K.ln-S.d;PETRA PFIFFIG;-328.75
334             .berweisung;111111/3299999999/20010020 .BERTRAG AUF SPARCARD 3299999999;Petra Pfiffig;PETRA PFIFFIG;-228.61
335             Gutschrift;BEZ.GE PERS.NR. 70600170/01 ARBEITGEBER U. CO;PETRA PFIFFIG;Petra Pfiffig;2780.70
336             .berweisung;DA 1000001;Verlagshaus Scribere GmbH;PETRA PFIFFIG;-31.50
337             Scheckeinreichung;EINGANG VORBEHALTEN GUTBUCHUNG 12345;PETRA PFIFFIG;Ein Fremder;1830.00
338             Lastschrift;MIETE 600+250 EUR OBJ22/328 SCHULSTR.7, 12345 MEINHEIM;Eigenheim KG;PETRA PFIFFIG;-850.00
339             Inh. Scheck;;2000123456789;PETRA PFIFFIG;-75.00
340             Lastschrift;TEILNEHMERNR 1234567 RUNDFUNK 0103-1203;GEZ;PETRA PFIFFIG;-84.75
341             Lastschrift;RECHNUNG 03121999;Telefon AG Köln;PETRA PFIFFIG;-125.80
342             Lastschrift;STROMKOSTEN KD.NR.1462347 JAHRESABRECHNUNG;Stadtwerke Musterstadt;PETRA PFIFFIG;-580.06
343             Gutschrift;KINDERGELD KINDERGELD-NR. 1462347;PETRA PFIFFIG;Arbeitsamt Bonn;154.00
344             EOX
345             for ($::_STDOUT_,$expected) {
346             s!\r\n!!gsm;
347             # Strip out all date references ...
348             s/^\d{8};\d{8};//gm;
349             s![\x80-\xff]!.!gsm;
350             };
351             is_deeply([split /\n/, $::_STDOUT_],[split /\n/, $expected],"Retrieved the correct data")
352             or do {
353             diag "--- Expected";
354             diag $expected;
355             diag "--- Got";
356             diag $::_STDOUT_;
357             };
358              
359             =head1 DESCRIPTION
360              
361             This module provides a rudimentary interface to the Postbank online banking system at
362             https://banking.postbank.de/. You will need either Crypt::SSLeay or IO::Socket::SSL
363             installed for HTTPS support to work with LWP.
364              
365             The interface was cooked up by me without taking a look at the other Finance::Bank
366             modules. If you have any proposals for a change, they are welcome !
367              
368             =head1 WARNING
369              
370             This is code for online banking, and that means your money, and that means BE CAREFUL. You are encouraged, nay, expected, to audit the source of this module yourself to reassure yourself that I am not doing anything untoward with your banking data. This software is useful to me, but is provided under NO GUARANTEE, explicit or implied.
371              
372             =head1 WARNUNG
373              
374             Dieser Code beschaeftigt sich mit Online Banking, das heisst, hier geht es um Dein Geld und das bedeutet SEI VORSICHTIG ! Ich gehe
375             davon aus, dass Du den Quellcode persoenlich anschaust, um Dich zu vergewissern, dass ich nichts unrechtes mit Deinen Bankdaten
376             anfange. Diese Software finde ich persoenlich nuetzlich, aber ich stelle sie OHNE JEDE GARANTIE zur Verfuegung, weder eine
377             ausdrueckliche noch eine implizierte Garantie.
378              
379             =head1 METHODS
380              
381             =head2 new
382              
383             Creates a new object. It takes three named parameters :
384              
385             =over 4
386              
387             =item number => '9999999999'
388              
389             This is the number of the account. If you don't know it (for example, you
390             are reading in an account statement from disk), leave it undef.
391              
392             =back
393              
394             =head2 $account->parse_statement %ARGS
395              
396             Parses an account statement and returns it as a hash reference. The account statement
397             can be passed in via two named parameters. If no parameter is given, the current statement
398             is fetched via the website through a call to C<get_account_statement> (is this so?).
399              
400             Parameters :
401              
402             =over 4
403              
404             =item file => $filename
405              
406             Parses the file C<$filename> instead of downloading data from the web.
407              
408             =item content => $string
409              
410             Parses the content of C<$string> instead of downloading data from the web.
411              
412             =back
413              
414             =head2 $account->iban
415              
416             Returns the IBAN for the account as a string. Later, a move to L<Business::IBAN> is
417             planned. The IBAN is a unique identifier for every account, that identifies the country,
418             bank and account with that bank.
419              
420             =head2 $account->transactions %ARGS
421              
422             Delivers you all transactions within a statement. The transactions may be filtered
423             by date by specifying the parameters 'since', 'upto' or 'on'. The values are, as always,
424             8-digit strings denoting YYYYMMDD dates.
425              
426             Parameters :
427              
428             =over 4
429              
430             =item since => $date
431              
432             Removes all transactions that happened on or before $date. $date must
433             be in the format YYYYMMDD. If the line is missing, C<since =E<gt> '00000000'>
434             is assumed.
435              
436             =item upto => $date
437              
438             Removes all transactions that happened after $date. $date must
439             be in the format YYYYMMDD. If the line is missing, C<upto =E<gt> '99999999'>
440             is assumed.
441              
442             =item on => $date
443              
444             Removes all transactions that happened on a date that is not C<eq> to $date. $date must
445             be in the format YYYYMMDD. $date may also be the special string 'today', which will
446             be converted to a YYYYMMDD string corresponding to todays date.
447              
448             =back
449              
450             =head2 $account->value_dates
451              
452             C<value_dates> is a convenience method that returns all value dates on the account statement.
453              
454             =cut
455              
456             =head2 $account->trade_dates
457              
458             C<trade_dates> is a convenience method that returns all trade dates on the account statement.
459              
460             =cut
461              
462             =head2 Converting a daily download to a sequence
463              
464             =for example begin
465              
466             #!/usr/bin/perl -w
467             use strict;
468              
469             use Finance::Bank::Postbank_de::Account;
470             use Tie::File;
471             use List::Sliding::Changes qw(find_new_elements);
472             use FindBin;
473             use MIME::Lite;
474              
475             my $filename = "$FindBin::Bin/statement.txt";
476             tie my @statement, 'Tie::File', $filename
477             or die "Couldn't tie to '$filename' : $!";
478              
479             my @transactions;
480              
481             # See what has happened since we last polled
482             my $retrieved_statement = Finance::Bank::Postbank_de::Account->parse_statement(
483             number => '9999999999',
484             password => '11111',
485             );
486              
487             # Output CSV for the transactions
488             for my $row (reverse @{$retrieved_statement->transactions()}) {
489             push @transactions, join( ";", map { $row->{$_} } (qw( tradedate valuedate type comment receiver sender amount )));
490             };
491              
492             # Find out what we did not already communicate
493             my (@new) = find_new_elements(\@statement,\@transactions);
494             if (@new) {
495             my ($body) = "<html><body><table>";
496             my ($date,$balance) = @{$retrieved_statement->balance};
497             $body .= "<b>Balance ($date) :</b> $balance<br>";
498             $body .= "<tr><th>";
499             $body .= join( "</th><th>", qw( tradedate valuedate type comment receiver sender amount )). "</th></tr>";
500             for my $line (@{[@new]}) {
501             $line =~ s!;!</td><td>!g;
502             $body .= "<tr><td>$line</td></tr>\n";
503             };
504             $body .= "</body></html>";
505             MIME::Lite->new(
506             From =>'update.pl',
507             To =>'you',
508             Subject =>"Account update $date",
509             Type =>'text/html',
510             Encoding =>'base64',
511             Data => $body,
512             )->send;
513             };
514              
515             # And update our log with what we have seen
516             push @statement, @new;
517              
518             =for example end
519              
520             =head1 AUTHOR
521              
522             Max Maischein, E<lt>corion@cpan.orgE<gt>
523              
524             =head1 SEE ALSO
525              
526             L<perl>, L<Finance::Bank::Postbank_de>.
527              
528             =cut