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   5258 use 5.006;
  14         50  
3 14     14   71 use strict;
  14         33  
  14         289  
4 14     14   64 use warnings;
  14         29  
  14         438  
5 14     14   79 use Carp qw(croak);
  14         29  
  14         756  
6 14     14   2479 use POSIX qw(strftime);
  14         31150  
  14         75  
7 14     14   10572 use Moo 2;
  14         56641  
  14         85  
8              
9             our $VERSION = '0.57';
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   12589 { no warnings 'once';
  14         30  
  14         33743  
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 4480 my ($self,$date) = @_;
85 367 100       1294 $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         1339 $3.$2.$1;
88             };
89              
90             sub parse_amount {
91 404     404 0 7091 my ($self,$amount) = @_;
92             # '¿ 5.314,05'
93 404 100       2036 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     1509 $amount = ($1||'') . $2;
96 401         821 $amount =~ tr/.//d;
97 401         1281 $amount =~ s/,/./;
98 401         1302 $amount;
99             };
100              
101             sub slurp_file {
102 3     3 0 6 my ($self,$filename) = @_;
103 3         13 local $/ = undef;
104 3 50       154 open my $fh, "< $filename"
105             or croak "Couldn't read from file '$filename' : $!";
106 3         51 binmode $fh, ':encoding(UTF-8)';
107 3         288 <$fh>;
108             };
109              
110             sub parse_statement {
111 27     27 1 47813 my ($self,%args) = @_;
112              
113             # If $self is just a string, we want to make a new class out of us
114 27 100       626 $self = $self->new
115             unless ref $self;
116 27         678 my $filename = $args{file};
117 27         74 my $raw_statement = $args{content};
118 27 100       125 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       204 unless $args{number};
123             croak "Need a password if I have to retrieve the statement online"
124 3 100       122 unless exists $args{password};
125 2   66     16 my $login = $args{login} || $args{number};
126              
127 2         10 require Finance::Bank::Postbank_de;
128 2         13 return Finance::Bank::Postbank_de->new( login => $login, password => $args{password}, past_days => $args{past_days} )->get_account_statement;
129             };
130              
131 22 100       342 croak "Don't know what to do with empty content"
132             unless $raw_statement;
133              
134 21         814 my @lines = split /\r?\n/, $raw_statement;
135 21 100       234 croak "No valid account statement: '$lines[0]'"
136             unless $lines[0] =~ /^Umsatzauskunft;$/;
137 20         76 shift @lines;
138              
139 20         56 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         66 my $sep = ";";
151 20 100       96 if( $lines[0] =~ /([\t;])/) {
152 19         62 $sep = $1;
153             };
154 20 50       44 for my $tag (@{ $tags{ $account_type }||[] }) {
  20         119  
155 73 100       2145 $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         204 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     605 if (exists $safety_check{$method} and defined $self->$method and $self->$method ne $value);
      100        
163              
164 68         259 $self->$method($value);
165 68         189 shift @lines;
166             };
167              
168              
169 15         122 while ($lines[0] !~ /^\s*$/) {
170 30         77 my $line = shift @lines;
171 30         67 my ($method,$balance);
172 30 50       61 for my $total (@{ $totals{ $account_type }||[] }) {
  30         138  
173 90         265 my ($re,$possible_method) = @$total;
174 90 100       3170 if ($line =~ /$re$sep\s*(?:(?:(\S+)\s*(?:\x{20AC}|\x{80}))|(null))$sep$/) {
175 30         95 $method = $possible_method;
176 30   33     131 $balance = $1 || $2;
177 30 50       166 if ($balance =~ /^(-?[0-9.,]+)\s*$/) {
    0          
178 30         145 $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       126 $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         82 while( $lines[0] !~ /^gebuchte Ums.tze/ ) {
199 105         256 shift @lines;
200             };
201 15         37 shift @lines;
202              
203             # Now parse the lines for each cashflow :
204 15 50       156 $lines[0] =~ /^Buchungsdatum${sep}Wertstellung${sep}Umsatzart/
205             or croak "Couldn't find start of transactions ($lines[0])";
206              
207 15         90 my (@fields);
208             COLUMN:
209 15         149 for my $col (split /$sep/, $lines[0]) {
210 120         441 for my $target (keys %columns) {
211 540 100       10655 if ($col =~ m!^["']?$target["']?$!) {
212 120         467 push @fields, $columns{$target};
213 120         392 next COLUMN;
214             };
215             };
216 0         0 die "Unknown column '$col' in '$lines[0]'";
217             };
218 15         60 shift @lines;
219              
220 15         140 my (%convert) = (
221             tradedate => \&parse_date,
222             valuedate => \&parse_date,
223             amount => \&parse_amount,
224             running_total => \&parse_amount,
225             );
226              
227 15         41 my @transactions;
228             my $line;
229 15         47 for $line (@lines) {
230 180 50       590 next if $line =~ /^\s*$/;
231 180         897 my (@row) = split /$sep/, $line;
232 180 50       450 scalar @row == scalar @fields
233             or die "Malformed cashflow ($line): Expected ".scalar(@fields)." entries, got ".scalar(@row);
234              
235 180         363 for (@row) {
236 1440 50       3448 $_ = $1
237             if /^\s*["']\s*(.*?)\s*["']\s*$/;
238             };
239              
240 180         276 my (%rec);
241 180         998 @rec{@fields} = @row;
242 180         507 for (keys %convert) {
243 720         1533 $rec{$_} = $convert{$_}->($self,$rec{$_});
244             };
245              
246 180         591 push @transactions, \%rec;
247             };
248              
249             # Filter the transactions
250 15         92 $self->{transactions} = \@transactions;
251              
252 15         225 $self
253             };
254              
255             sub transactions {
256 84     84 1 108799 my ($self,%args) = @_;
257              
258 84         179 my ($start_date,$end_date);
259 84 100       202 if (exists $args{on}) {
260              
261             croak "Options 'since'+'upto' and 'on' are incompatible"
262 33 100 100     299 if (exists $args{since} and exists $args{upto});
263             croak "Options 'since' and 'on' are incompatible"
264 32 100       152 if (exists $args{since});
265             croak "Options 'upto' and 'on' are incompatible"
266 31 100       146 if (exists $args{upto});
267             $args{on} = strftime('%Y%m%d',localtime())
268 30 100       183 if ($args{on} eq 'today');
269 30 50       145 $args{on} =~ /^\d{8}$/ or croak "Argument {on => '$args{on}'} dosen't look like a date to me.";
270              
271 30         58 $start_date = $args{on} -1;
272 30         52 $end_date = $args{on};
273             } else {
274 51   100     151 $start_date = $args{since} || "00000000";
275 51   100     166 $end_date = $args{upto} || "99999999";
276 51 100       820 $start_date =~ /^\d{8}$/ or croak "Argument {since => '$start_date'} dosen't look like a date to me.";
277 44 100       715 $end_date =~ /^\d{8}$/ or croak "Argument {upto => '$end_date'} dosen't look like a date to me.";
278 37 100       276 $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       95 grep { $_->{tradedate} > $start_date and $_->{tradedate} <= $end_date } @{$self->{transactions}};
  780         2182  
  65         177  
283             };
284              
285             sub value_dates {
286 1     1 1 7 my ($self) = @_;
287 1         2 my %dates;
288 1         3 $dates{$_->{valuedate}} = 1 for $self->transactions();
289 1         22 sort keys %dates;
290             };
291              
292             sub trade_dates {
293 1     1 1 1319 my ($self) = @_;
294 1         1 my %dates;
295 1         3 $dates{$_->{tradedate}} = 1 for $self->transactions();
296 1         12 sort keys %dates;
297             };
298              
299             1;
300             __END__