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   5508 use 5.006;
  14         54  
3 14     14   79 use strict;
  14         26  
  14         295  
4 14     14   73 use warnings;
  14         28  
  14         419  
5 14     14   75 use Carp qw(croak);
  14         28  
  14         709  
6 14     14   2515 use POSIX qw(strftime);
  14         32230  
  14         99  
7 14     14   10494 use Moo 2;
  14         57169  
  14         87  
8              
9             our $VERSION = '0.55';
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   12486 { no warnings 'once';
  14         34  
  14         33551  
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 4552 my ($self,$date) = @_;
85 367 100       1321 $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         1336 $3.$2.$1;
88             };
89              
90             sub parse_amount {
91 404     404 0 7194 my ($self,$amount) = @_;
92             # '¿ 5.314,05'
93 404 100       2066 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     1495 $amount = ($1||'') . $2;
96 401         789 $amount =~ tr/.//d;
97 401         1199 $amount =~ s/,/./;
98 401         1257 $amount;
99             };
100              
101             sub slurp_file {
102 3     3 0 21 my ($self,$filename) = @_;
103 3         14 local $/ = undef;
104 3 50       146 open my $fh, "< $filename"
105             or croak "Couldn't read from file '$filename' : $!";
106 3         43 binmode $fh, ':encoding(UTF-8)';
107 3         268 <$fh>;
108             };
109              
110             sub parse_statement {
111 27     27 1 48438 my ($self,%args) = @_;
112              
113             # If $self is just a string, we want to make a new class out of us
114 27 100       606 $self = $self->new
115             unless ref $self;
116 27         631 my $filename = $args{file};
117 27         72 my $raw_statement = $args{content};
118 27 100       142 if ($filename) {
    100          
119 4         12 $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       172 unless $args{number};
123             croak "Need a password if I have to retrieve the statement online"
124 3 100       115 unless exists $args{password};
125 2   66     9 my $login = $args{login} || $args{number};
126              
127 2         11 require Finance::Bank::Postbank_de;
128 2         12 return Finance::Bank::Postbank_de->new( login => $login, password => $args{password}, past_days => $args{past_days} )->get_account_statement;
129             };
130              
131 22 100       326 croak "Don't know what to do with empty content"
132             unless $raw_statement;
133              
134 21         695 my @lines = split /\r?\n/, $raw_statement;
135 21 100       213 croak "No valid account statement: '$lines[0]'"
136             unless $lines[0] =~ /^Umsatzauskunft;$/;
137 20         54 shift @lines;
138              
139 20         47 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         47 my $sep = ";";
151 20 100       98 if( $lines[0] =~ /([\t;])/) {
152 19         66 $sep = $1;
153             };
154 20 50       46 for my $tag (@{ $tags{ $account_type }||[] }) {
  20         115  
155 73 100       1954 $lines[0] =~ /^\Q$tag\E$sep(.*?)$sep?$/
156             or croak "Field '$tag' not found in account statement ($lines[0])";
157 69         204 my $method = lc($tag);
158 69         150 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     593 if (exists $safety_check{$method} and defined $self->$method and $self->$method ne $value);
      100        
163              
164 68         246 $self->$method($value);
165 68         173 shift @lines;
166             };
167              
168              
169 15         95 while ($lines[0] !~ /^\s*$/) {
170 30         82 my $line = shift @lines;
171 30         72 my ($method,$balance);
172 30 50       60 for my $total (@{ $totals{ $account_type }||[] }) {
  30         130  
173 90         244 my ($re,$possible_method) = @$total;
174 90 100       2943 if ($line =~ /$re$sep\s*(?:(?:(\S+)\s*(?:\x{20AC}|\x{80}))|(null))$sep$/) {
175 30         87 $method = $possible_method;
176 30   33     116 $balance = $1 || $2;
177 30 50       158 if ($balance =~ /^(-?[0-9.,]+)\s*$/) {
    0          
178 30         127 $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       210 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       89 $lines[0] =~ m!^\s*$!
194             or croak "Expected an empty line after the account balances, got '$lines[0]'";
195 15         38 shift @lines;
196              
197             # Now skip over the transactions in the future
198 15         74 while( $lines[0] !~ /^gebuchte Ums.tze/ ) {
199 105         235 shift @lines;
200             };
201 15         40 shift @lines;
202              
203             # Now parse the lines for each cashflow :
204 15 50       155 $lines[0] =~ /^Buchungsdatum${sep}Wertstellung${sep}Umsatzart/
205             or croak "Couldn't find start of transactions ($lines[0])";
206              
207 15         40 my (@fields);
208             COLUMN:
209 15         139 for my $col (split /$sep/, $lines[0]) {
210 120         435 for my $target (keys %columns) {
211 540 100       10129 if ($col =~ m!^["']?$target["']?$!) {
212 120         366 push @fields, $columns{$target};
213 120         366 next COLUMN;
214             };
215             };
216 0         0 die "Unknown column '$col' in '$lines[0]'";
217             };
218 15         48 shift @lines;
219              
220 15         130 my (%convert) = (
221             tradedate => \&parse_date,
222             valuedate => \&parse_date,
223             amount => \&parse_amount,
224             running_total => \&parse_amount,
225             );
226              
227 15         46 my @transactions;
228             my $line;
229 15         46 for $line (@lines) {
230 180 50       554 next if $line =~ /^\s*$/;
231 180         896 my (@row) = split /$sep/, $line;
232 180 50       451 scalar @row == scalar @fields
233             or die "Malformed cashflow ($line): Expected ".scalar(@fields)." entries, got ".scalar(@row);
234              
235 180         346 for (@row) {
236 1440 50       3533 $_ = $1
237             if /^\s*["']\s*(.*?)\s*["']\s*$/;
238             };
239              
240 180         287 my (%rec);
241 180         926 @rec{@fields} = @row;
242 180         464 for (keys %convert) {
243 720         1405 $rec{$_} = $convert{$_}->($self,$rec{$_});
244             };
245              
246 180         562 push @transactions, \%rec;
247             };
248              
249             # Filter the transactions
250 15         78 $self->{transactions} = \@transactions;
251              
252 15         218 $self
253             };
254              
255             sub transactions {
256 84     84 1 107640 my ($self,%args) = @_;
257              
258 84         172 my ($start_date,$end_date);
259 84 100       210 if (exists $args{on}) {
260              
261             croak "Options 'since'+'upto' and 'on' are incompatible"
262 33 100 100     290 if (exists $args{since} and exists $args{upto});
263             croak "Options 'since' and 'on' are incompatible"
264 32 100       171 if (exists $args{since});
265             croak "Options 'upto' and 'on' are incompatible"
266 31 100       154 if (exists $args{upto});
267             $args{on} = strftime('%Y%m%d',localtime())
268 30 100       190 if ($args{on} eq 'today');
269 30 50       147 $args{on} =~ /^\d{8}$/ or croak "Argument {on => '$args{on}'} dosen't look like a date to me.";
270              
271 30         61 $start_date = $args{on} -1;
272 30         56 $end_date = $args{on};
273             } else {
274 51   100     166 $start_date = $args{since} || "00000000";
275 51   100     168 $end_date = $args{upto} || "99999999";
276 51 100       822 $start_date =~ /^\d{8}$/ or croak "Argument {since => '$start_date'} dosen't look like a date to me.";
277 44 100       812 $end_date =~ /^\d{8}$/ or croak "Argument {upto => '$end_date'} dosen't look like a date to me.";
278 37 100       281 $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       106 grep { $_->{tradedate} > $start_date and $_->{tradedate} <= $end_date } @{$self->{transactions}};
  780         2208  
  65         152  
283             };
284              
285             sub value_dates {
286 1     1 1 8 my ($self) = @_;
287 1         2 my %dates;
288 1         3 $dates{$_->{valuedate}} = 1 for $self->transactions();
289 1         12 sort keys %dates;
290             };
291              
292             sub trade_dates {
293 1     1 1 1359 my ($self) = @_;
294 1         2 my %dates;
295 1         4 $dates{$_->{tradedate}} = 1 for $self->transactions();
296 1         12 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