File Coverage

blib/lib/Finance/Bank/ID/BCA.pm
Criterion Covered Total %
statement 74 223 33.1
branch 29 102 28.4
condition 11 26 42.3
subroutine 7 24 29.1
pod 5 7 71.4
total 126 382 32.9


line stmt bran cond sub pod time code
1             package Finance::Bank::ID::BCA;
2              
3             our $DATE = '2019-05-10'; # DATE
4             our $VERSION = '0.500'; # VERSION
5              
6 1     1   526153 use 5.010001;
  1         8  
7 1     1   406 use Moo;
  1         7234  
  1         3  
8              
9 1     1   1521 use HTTP::UserAgentStr::Util::ByNickname qw(newest_firefox);
  1         640  
  1         3234  
10              
11             extends 'Finance::Bank::ID::Base';
12              
13             has _variant => (is => 'rw'); # bisnis or perorangan
14              
15             sub BUILD {
16 1     1 0 3 my ($self, $args) = @_;
17              
18 1 50       10 $self->site("https://ibank.klikbca.com") unless $self->site;
19 1 50       7 $self->https_host("ibank.klikbca.com") unless $self->https_host;
20 1 50       8 $self->_set_default_mech unless $self->mech;
21 1         7 $self->mech->agent(newest_firefox());
22             }
23              
24             sub _req {
25 0     0   0 my ($self, @args) = @_;
26              
27             # 2012-03-12 - KlikBCA server since a few week ago rejects TE request
28             # header, so we do not send them.
29 0         0 local @LWP::Protocol::http::EXTRA_SOCK_OPTS =
30             @LWP::Protocol::http::EXTRA_SOCK_OPTS;
31 0         0 push(@LWP::Protocol::http::EXTRA_SOCK_OPTS, SendTE => 0);
32             #$log->tracef("EXTRA_SOCK_OPTS=%s", \@LWP::Protocol::http::EXTRA_SOCK_OPTS);
33              
34 0         0 $self->SUPER::_req(@args);
35             }
36              
37             sub login {
38 0     0 1 0 my ($self) = @_;
39 0         0 my $s = $self->site;
40              
41 0 0       0 return 1 if $self->logged_in;
42 0 0       0 die "400 Username not supplied" unless $self->username;
43 0 0       0 die "400 Password not supplied" unless $self->password;
44              
45 0         0 $self->logger->debug('Logging in ...');
46 0         0 $self->_req(get => [$s], {id=>'login_form'});
47             $self->_req(submit_form => [
48             form_number => 1,
49             fields => {'value(user_id)'=>$self->username,
50             'value(pswd)'=>$self->password,
51             },
52             button => 'value(Submit)',
53             ],
54             {
55             id => 'login',
56             after_request => sub {
57 0     0   0 my ($mech) = @_;
58 0 0       0 $mech->content =~ /var err='(.+?)'/ and return $1;
59 0 0       0 $mech->content =~ /=logout"/ and return;
60 0         0 "unknown login result page";
61             },
62 0         0 });
63 0         0 $self->logged_in(1);
64 0         0 $self->_req(get => ["$s/authentication.do?value(actions)=welcome"],
65             {id=>'welcome'});
66             #$self->_req(get => ["$s/nav_bar_indo/menu_nav.htm"], {id=>'navbar'}); # failed?
67             }
68              
69             sub logout {
70 0     0 1 0 my ($self) = @_;
71              
72 0 0       0 return 1 unless $self->logged_in;
73 0         0 $self->logger->debug('Logging out ...');
74 0         0 $self->_req(get => [$self->site . "/authentication.do?value(actions)=logout"],
75             {id=>'logout'});
76 0         0 $self->logged_in(0);
77             }
78              
79             sub _menu {
80 0     0   0 my ($self) = @_;
81 0         0 my $s = $self->site;
82 0         0 $self->_req(get => ["$s/nav_bar_indo/account_information_menu.htm"],
83             {id=>'accinfo_menu'});
84             }
85              
86             sub _menu_estatement {
87 0     0   0 my ($self) = @_;
88 0         0 my $s = $self->site;
89 0         0 $self->_req(get => ["$s/nav_bar_indo/estatement.htm"],
90             {id=>'estatement_menu'});
91             }
92              
93             sub list_accounts {
94 0     0 1 0 my ($self) = @_;
95 0         0 $self->login;
96 0         0 $self->logger->info("Listing accounts");
97 0         0 map { $_->{account} } $self->_check_balances;
  0         0  
98             }
99              
100             sub _check_balances {
101 0     0   0 my ($self) = @_;
102 0         0 my $s = $self->site;
103              
104 0         0 my $re = qr!
105             <tr>\s*
106             <td[^>]+>\s*<div[^>]+>\s*<font[^>]+>\s*(\d+)\s*</font>\s*</div>\s*</td>\s*
107             <td[^>]+>\s*<div[^>]+>\s*<font[^>]+>\s*([^<]*?)\s*</font>\s*</div>\s*</td>\s*
108             <td[^>]+>\s*<div[^>]+>\s*<font[^>]+>\s*([A-Z]+)\s*</font>\s*</div>\s*</td>\s*
109             <td[^>]+>\s*<div[^>]+>\s*<font[^>]+>\s*([0-9,.]+)\.(\d\d)\s*</font>\s*</div>\s*</td>
110             !x;
111              
112 0         0 $self->login;
113 0         0 $self->_menu;
114             $self->_req(post => ["$s/balanceinquiry.do"],
115             {
116             id => 'check_balance',
117             after_request => sub {
118 0     0   0 my ($mech) = @_;
119 0         0 my $errmsg = $self->_get_bca_errmsg;
120 0 0       0 return "BCA errmsg: $errmsg" if $errmsg;
121 0 0       0 $mech->content =~ $re or
122             return "can't find balances, maybe page layout changed?";
123 0         0 '';
124             },
125 0         0 });
126              
127 0         0 my @res;
128 0         0 my $content = $self->mech->content;
129 0         0 while ($content =~ m/$re/og) {
130 0         0 push @res, { account => $1,
131             account_type => $2,
132             currency => $3,
133             balance => $self->_stripD($4) + 0.01*$5,
134             };
135             }
136 0         0 @res;
137             }
138              
139             # parse error message from error page, often shown when we want to check
140             # statement or balance.
141             sub _get_bca_errmsg {
142 0     0   0 my $self = shift;
143 0         0 my $mech = $self->mech;
144 0         0 my $ct = $mech->content;
145 0 0       0 return $1 if $ct =~ m!^<font.+?red><b>(.+)</b></font>!m;
146             }
147              
148             sub check_balance {
149 0     0 1 0 my ($self, $account) = @_;
150 0         0 my @bals = $self->_check_balances;
151 0 0       0 return unless @bals;
152 0 0       0 return $bals[0]{balance} if !$account;
153 0         0 for (@bals) {
154 0 0       0 return $_->{balance} if $_->{account} eq $account;
155             }
156 0         0 return;
157             }
158              
159             sub get_statement {
160 0     0 1 0 require DateTime;
161              
162 0         0 my ($self, %args) = @_;
163 0         0 my $s = $self->site;
164 0         0 my $max_days = 31;
165              
166 0         0 $self->login;
167 0         0 $self->_menu;
168             $self->logger->info(
169             "Getting statement for ".
170 0 0       0 ($args{account} ? "account `$args{account}'" : "default account").
171             " ...");
172             $self->_req(post => ["$s/accountstmt.do?value(actions)=acct_stmt"],
173             {
174             id => 'get_statement_form',
175             after_request => sub {
176 0     0   0 my ($mech) = @_;
177 0         0 my $errmsg = $self->_get_bca_errmsg;
178 0 0       0 return "BCA errmsg: $errmsg" if $errmsg;
179 0 0       0 $mech->content =~ /<form/i or
180             return "no form found, maybe we got logged out?";
181 0         0 '';
182             },
183 0         0 });
184              
185 0         0 my $form = $self->mech->form_number(1);
186              
187             # in the site this is done by javascript onSubmit(), so we emulate it here
188 0         0 $form->action("$s/accountstmt.do?value(actions)=acctstmtview");
189              
190             # in the case of the current date being a saturday/sunday/holiday, end
191             # date will be forwarded 1 or more days from the current date by the site,
192             # so we need to know end date and optionally forward start date when needed,
193             # to avoid total number of days being > 31.
194              
195 0         0 my $today = DateTime->today;
196 0         0 my $max_dt = DateTime->new(day => $form->value("value(endDt)"),
197             month => $form->value("value(endMt)"),
198             year => $form->value("value(endYr)"));
199 0         0 my $cmp = DateTime->compare($today, $max_dt);
200 0         0 my $delta_days = $cmp * $today->subtract_datetime($max_dt, $today)->days;
201 0 0       0 if ($delta_days > 0) {
202 0         0 $self->logger->warn("Something weird is going on, end date is being ".
203             "set less than today's date by the site (".
204             $self->_fmtdate($max_dt)."). ".
205             "Please check your computer's date setting. ".
206             "Continuing anyway.");
207             }
208 0         0 my $min_dt = $max_dt->clone->subtract(days => ($max_days-1));
209              
210 0   0     0 my $end_dt = $args{end_date} || $max_dt;
211             my $start_dt = $args{start_date} ||
212 0   0     0 $end_dt->clone->subtract(days => (($args{days} || $max_days)-1));
213 0 0       0 if (DateTime->compare($start_dt, $min_dt) == -1) {
214 0         0 $self->logger->warn("Start date ".$self->_fmtdate($start_dt)." is less than ".
215             "minimum date ".$self->_fmtdate($min_dt).". Setting to ".
216             "minimum date instead.");
217 0         0 $start_dt = $min_dt;
218             }
219 0 0       0 if (DateTime->compare($start_dt, $max_dt) == 1) {
220 0         0 $self->logger->warn("Start date ".$self->_fmtdate($start_dt)." is greater than ".
221             "maximum date ".$self->_fmtdate($max_dt).". Setting to ".
222             "maximum date instead.");
223 0         0 $start_dt = $max_dt;
224             }
225 0 0       0 if (DateTime->compare($end_dt, $min_dt) == -1) {
226 0         0 $self->logger->warn("End date ".$self->_fmtdate($end_dt)." is less than ".
227             "minimum date ".$self->_fmtdate($min_dt).". Setting to ".
228             "minimum date instead.");
229 0         0 $end_dt = $min_dt;
230             }
231 0 0       0 if (DateTime->compare($end_dt, $max_dt) == 1) {
232 0         0 $self->logger->warn("End date ".$self->_fmtdate($end_dt)." is greater than ".
233             "maximum date ".$self->_fmtdate($max_dt).". Setting to ".
234             "maximum date instead.");
235 0         0 $end_dt = $max_dt;
236             }
237 0 0       0 if (DateTime->compare($start_dt, $end_dt) == 1) {
238 0         0 $self->logger->warn("Start date ".$self->_fmtdate($start_dt)." is greater than ".
239             "end date ".$self->_fmtdate($end_dt).". Setting to ".
240             "end date instead.");
241 0         0 $start_dt = $end_dt;
242             }
243              
244 0         0 my $select = $form->find_input("value(D1)");
245 0         0 my $d1 = $select->value;
246 0 0       0 if ($args{account}) {
247 0         0 my @d1 = $select->possible_values;
248 0         0 my @accts = $select->value_names;
249 0         0 for (0..$#accts) {
250 0 0       0 if ($args{account} eq $accts[$_]) {
251 0         0 $d1 = $d1[$_];
252 0         0 last;
253             }
254             }
255             }
256              
257             $self->_req(submit_form => [
258             form_number => 1,
259             fields => {
260             "value(D1)" => $d1,
261             "value(startDt)" => $start_dt->day,
262             "value(startMt)" => $start_dt->month,
263             "value(startYr)" => $start_dt->year,
264             "value(endDt)" => $end_dt->day,
265             "value(endMt)" => $end_dt->month,
266             "value(endYr)" => $end_dt->year,
267             },
268             ],
269             {
270             id => 'get_statement',
271             after_request => sub {
272 0     0   0 my ($mech) = @_;
273 0         0 my $errmsg = $self->_get_bca_errmsg;
274 0 0       0 return "BCA errmsg: $errmsg" if $errmsg;
275 0         0 '';
276             },
277 0         0 });
278 0   0     0 my $parse_opts = $args{parse_opts} // {};
279 0         0 my $resp = $self->parse_statement($self->mech->content, %$parse_opts);
280 0 0 0     0 return if !$resp || $resp->[0] != 200;
281 0         0 $resp->[2];
282             }
283              
284             sub _ps_detect {
285 7     7   17 my ($self, $page) = @_;
286 7 50       730 unless ($page =~ /(?:^\s*|&nbsp;)(?:INFORMASI REKENING - MUTASI REKENING|ACCOUNT INFORMATION - ACCOUNT STATEMENT)/mi) {
287 0         0 return "No KlikBCA statement page signature found";
288             }
289 7 100       201 $self->_variant($page =~ /^(?:Kode Mata Uang|Currency)/m ? 'bisnis' : 'perorangan');
290 7         31 "";
291             }
292              
293             sub _ps_get_metadata {
294 7     7   51 require DateTime;
295              
296 7         20 my ($self, $page, $stmt) = @_;
297              
298 7 50       801 unless ($page =~ /\s*(?:(?:Nomor|No\.) [Rr]ekening|Account Number)\s*(?:<[^>]+>\s*)*[:\t]\s*(?:<[^>]+>\s*)*([\d-]+)/m) {
299 0         0 return "can't get account number";
300             }
301 7         33 $stmt->{account} = $self->_stripD($1);
302 7         19 $stmt->{account} =~ s/\D+//g;
303              
304 7         11 my $adv1 = "probably the statement format changed, or input incomplete";
305              
306 7 50       833 unless ($page =~ m!(?:^\s*|>)(?:Periode|Period)\s*(?:<[^>]+>\s*)*[:\t]\s*(?:<[^>]+>\s*)*(\d\d)/(\d\d)/(\d\d\d\d) - (\d\d)/(\d\d)/(\d\d\d\d)!m) {
307 0         0 return "can't get statement period, $adv1";
308             }
309 7         66 $stmt->{start_date} = DateTime->new(day=>$1, month=>$2, year=>$3);
310 7         2050 $stmt->{end_date} = DateTime->new(day=>$4, month=>$5, year=>$6);
311              
312 7 50       2514 unless ($page =~ /(?:^|>)(?:(?:Kode )?Mata Uang|Currency)\s*(?:<[^>]+>\s*)*[:\t]\s*(?:<[^>]+>\s*)*(Rp|[A-Z]+)/m) {
313 0         0 return "can't get currency, $adv1";
314             }
315 7 100       34 $stmt->{currency} = ($1 eq 'Rp' ? 'IDR' : $1);
316              
317 7 50       65 unless ($page =~ /(?:^|>)(?:Nama|Name)\s*(?:<[^>]+>\s*)*[:\t]\s*(?:<[^>]+>\s*)*([^<\015\012]+)/m) {
318 0         0 return "can't get account holder, $adv1";
319             }
320 7         18 $stmt->{account_holder} = $1;
321              
322 7 50       3829 unless ($page =~ /(?:^|>)(?:Mutasi Kredit|Total Credits)\s*(?:<[^>]+>\s*)*[:\t]\s*(?:<[^>]+>\s*)*([0-9,.]+)\.(\d\d)(?:\s*\t\s*(\d+))?/m) {
323 0         0 return "can't get total credit, $adv1";
324             }
325 7         30 $stmt->{_total_credit_in_stmt} = $self->_stripD($1) + 0.01*$2;
326 7 100       25 $stmt->{_num_credit_tx_in_stmt} = $3 if $3;
327              
328 7 50       3646 unless ($page =~ /(?:^|>)(?:Mutasi Debet|Total Debits)\s*(?:<[^>]+>\s*)*[:\t]\s*(?:<[^>]+>\s*)*([0-9,.]+)\.(\d\d)(?:\s*\t\s*(\d+))?/m) {
329 0         0 return "can't get total credit, $adv1";
330             }
331 7         28 $stmt->{_total_debit_in_stmt} = $self->_stripD($1) + 0.01*$2;
332 7 100       21 $stmt->{_num_debit_tx_in_stmt} = $3 if $3;
333 7         26 "";
334             }
335              
336             sub _ps_get_transactions {
337 7     7   42 require DateTime;
338              
339 7         16 my ($self, $page, $stmt) = @_;
340              
341 7         13 my @e;
342             # text version
343 7         529 while ($page =~ m!^
344             (\d\d/\d\d|\s?PEND|\s?NEXT) # 1) date
345             (?:\s*\t\s*|\n)+
346             ((?:[^\t]|\n)*?) # 2) description
347             (?:\s*\t\s*|\n)+
348             (\d{4}) # 3) branch code
349             (?:\s*\t\s*|\n)+
350             ([0-9,]+)\.(\d\d) # 4+5) amount
351             (?:\s*\t?\s*|\n)+
352             (CR|DB) # 6)
353             (?:\s*\t\s*|\n)+
354             ([0-9,]+)\.(\d\d) # 7+8) balance
355             !mxg) {
356 59         1047 push @e, {date=>$1, desc=>$2, br=>$3, amt=>$4, amtf=>$5, crdb=>$6, bal=>$7, balf=>$8};
357             }
358 7 100       20 if (!@e) {
359             # HTML version
360 2         1439 while ($page =~ m!^
361             <tr>\s*
362             <td[^>]+>(?:\s*<[^>]+>\s*)* (\d\d/\d\d|\s?PEND|\s?NEXT) (?:\s*<[^>]+>\s*)*</td>\s*
363             <td[^>]+>(?:\s*<[^>]+>\s*)* ((?:[^\t]|\n)*?) (?:\s*<[^>]+>\s*)*</td>\s*
364             <td[^>]+>(?:\s*<[^>]+>\s*)* (\d{4}) (?:\s*<[^>]+>\s*)*</td>\s*
365             <td[^>]+>(?:\s*<[^>]+>\s*)* ([0-9,]+)\.(\d\d) (?:\s*<[^>]+>\s*)*</td>\s*
366             <td[^>]+>(?:\s*<[^>]+>\s*)* (CR|DB) (?:\s*<[^>]+>\s*)*</td>\s*
367             <td[^>]+>(?:\s*<[^>]+>\s*)* ([0-9,]+)\.(\d\d) (?:\s*<[^>]+>\s*)*</td>\s*
368             </tr>
369             !smxg) {
370 34         8895 push @e, {date=>$1, desc=>$2, br=>$3, amt=>$4, amtf=>$5, crdb=>$6, bal=>$7, balf=>$8};
371             }
372 2         7 for (@e) { $_->{desc} =~ s!<br ?/?>!\n!ig }
  34         131  
373             }
374              
375 7         27 my @tx;
376             my @skipped_tx;
377 7         0 my $last_date;
378 7         0 my $seq;
379 7         15 my $i = 0;
380 7         17 for my $e (@e) {
381 93         125 $i++;
382 93         125 my $tx = {};
383             #$tx->{stmt_start_date} = $stmt->{start_date};
384              
385 93 50       248 if ($e->{date} =~ /NEXT/) {
    50          
386 0         0 $tx->{date} = $stmt->{end_date};
387 0         0 $tx->{is_next} = 1;
388             } elsif ($e->{date} =~ /PEND/) {
389 0         0 $tx->{date} = $stmt->{end_date};
390 0         0 $tx->{is_pending} = 1;
391             } else {
392 93         281 my ($day, $mon) = split m!/!, $e->{date};
393             my $last_nonpend_date = DateTime->new(
394             year => ($mon < $stmt->{start_date}->month ?
395             $stmt->{end_date}->year :
396 93 50       249 $stmt->{start_date}->year),
397             month => $mon,
398             day => $day);
399 93         22850 $tx->{date} = $last_nonpend_date;
400 93         142 $tx->{is_pending} = 0;
401             }
402              
403 93         156 $tx->{description} = $e->{desc};
404              
405 93         159 $tx->{branch} = $e->{br};
406              
407 93 100       347 $tx->{amount} = ($e->{crdb} =~ /CR/ ? 1 : -1) * ($self->_stripD($e->{amt}) + 0.01*$e->{amtf});
408 93         186 $tx->{balance} = ($self->_stripD($e->{bal}) + 0.01*$e->{balf});
409              
410 93 100 100     295 if (!$last_date || DateTime->compare($last_date, $tx->{date})) {
411 33         1577 $seq = 1;
412 33         48 $last_date = $tx->{date};
413             } else {
414 60         3781 $seq++;
415             }
416 93         145 $tx->{seq} = $seq;
417              
418 93 50 100     341 if ($self->_variant eq 'perorangan' &&
      66        
419             $tx->{date}->dow =~ /6|7/ &&
420             $tx->{description} !~ /^(BIAYA ADM|BUNGA|(CR|DR) KOREKSI BUNGA|PAJAK BUNGA)\s*$/) {
421 0         0 return "check failed in tx#$i: In KlikBCA Perorangan, all ".
422             "transactions must not be in Sat/Sun except for Interest and ".
423             "Admin Fee: <$tx->{description}> ($tx->{date})";
424             # note: in Tahapan perorangan, BIAYA ADM is set on
425             # Fridays, but for Tapres (?) on last day of the month
426             }
427              
428 93 50 66     595 if ($self->_variant eq 'bisnis' &&
      33        
429             $tx->{date}->dow =~ /6|7/ &&
430             $tx->{description} !~ /^(BIAYA ADM|BUNGA|(CR|DR) KOREKSI BUNGA|PAJAK BUNGA)\s*$/) {
431 0         0 return "check failed in tx#$i: In KlikBCA Bisnis, all ".
432             "transactions must not be in Sat/Sun except for Interest and ".
433             "Admin Fee: <$tx->{description}> ($tx->{date})";
434             # note: in KlikBCA bisnis, BIAYA ADM is set on the last day of the
435             # month, regardless of whether it's Sat/Sun or not
436             }
437              
438 93         282 push @tx, $tx;
439             }
440 7         55 $stmt->{transactions} = \@tx;
441 7         14 $stmt->{skipped_transactions} = \@skipped_tx;
442 7         103 "";
443             }
444              
445             sub get_estatement {
446 0     0 0   my ($self, %args) = @_;
447 0           my $s = $self->site;
448              
449 0           $self->login;
450 0           $self->_menu;
451 0           $self->_menu_estatement;
452             $self->logger->info(
453             "Getting e-statement for ".
454 0 0         ($args{account} ? "account `$args{account}'" : "default account").
455             " ...");
456             $self->_req(post => ["$s/estatement.do?value(actions)=estmt"],
457             {
458             id => 'get_estatement_form',
459             after_request => sub {
460 0     0     my ($mech) = @_;
461 0           my $errmsg = $self->_get_bca_errmsg;
462 0 0         return "BCA errmsg: $errmsg" if $errmsg;
463 0 0         $mech->content =~ /<form/i or
464             return "no form found, maybe we got logged out?";
465 0           '';
466             },
467 0           });
468             $self->_req(submit_form => [
469             form_number => 1,
470             fields => {
471             "value(monthVal)" => $args{month},
472             "value(yearVal)" => $args{year},
473             },
474             ],
475             {
476             id => 'get_estatement',
477             after_request => sub {
478 0     0     my ($mech) = @_;
479 0           my $errmsg = $self->_get_bca_errmsg;
480 0 0         return "BCA errmsg: $errmsg" if $errmsg;
481 0           '';
482             },
483 0           });
484             return [
485 0           $self->mech->response->code,
486             $self->mech->response->code,
487             $self->mech->content];
488             }
489              
490             1;
491             # ABSTRACT: Check your BCA accounts from Perl
492              
493             __END__
494              
495             =pod
496              
497             =encoding UTF-8
498              
499             =head1 NAME
500              
501             Finance::Bank::ID::BCA - Check your BCA accounts from Perl
502              
503             =head1 VERSION
504              
505             This document describes version 0.500 of Finance::Bank::ID::BCA (from Perl distribution Finance-Bank-ID-BCA), released on 2019-05-10.
506              
507             =head1 SYNOPSIS
508              
509             If you just want to download banking statements, and you use Linux/Unix, you
510             might want to use the L<download-bca> script instead of having to deal with this
511             library directly.
512              
513             If you want to use the library in your Perl application:
514              
515             use Finance::Bank::ID::BCA;
516              
517             # FBI::BCA uses Log::ger. to show logs to, for example, screen:
518             use Log::ger::Output 'Screen';
519              
520             my $ibank = Finance::Bank::ID::BCA->new(
521             username => 'ABCDEFGH1234', # opt if only using parse_statement()
522             password => '123456', # idem
523             verify_https => 1, # default is 0
524             #https_ca_dir => '/etc/ssl/certs', # default is already /etc/ssl/certs
525             );
526              
527             eval {
528             $ibank->login(); # dies on error
529              
530             my @accts = $ibank->list_accounts();
531              
532             my $bal = $ibank->check_balance($acct); # $acct is optional
533              
534             my $stmt = $ibank->get_statement(
535             account => ..., # opt, default account will be used if undef
536             days => 31, # opt
537             start_date => DateTime->new(year=>2009, month=>10, day=>6),
538             # opt, takes precedence over 'days'
539             end_date => DateTime->today, # opt, takes precedence over 'days'
540             );
541              
542             print "Transactions: ";
543             for my $tx (@{ $stmt->{transactions} }) {
544             print "$tx->{date} $tx->{amount} $tx->{description}\n";
545             }
546             };
547             warn if $@;
548              
549             # remember to call this, otherwise you will have trouble logging in again
550             # for some time
551             $ibank->logout();
552              
553             Utility routines:
554              
555             # parse HTML statement directly
556             my $res = $ibank->parse_statement($html);
557              
558             =head1 DESCRIPTION
559              
560             This module provide a rudimentary interface to the web-based online banking
561             interface of the Indonesian B<Bank Central Asia> (BCA) at
562             https://ibank.klikbca.com. You will need either L<Crypt::SSLeay> or
563             L<IO::Socket::SSL> installed for HTTPS support to work (and strictly
564             Crypt::SSLeay to enable certificate verification). L<WWW::Mechanize> is required
565             but you can supply your own mech-like object.
566              
567             This module can only login to the retail/personal version of the site (KlikBCA
568             perorangan) and not the corporate/business version (KlikBCA bisnis) as the later
569             requires VPN and token input on login. But this module can parse statement page
570             from both versions.
571              
572             This module has been tested with the following savings products: Tahapan (IDR),
573             BCA Dolar (USD).
574              
575             Warning: This module is neither offical nor is it tested to be 100% safe!
576             Because of the nature of web-robots, everything may break from one day to the
577             other when the underlying web interface changes.
578              
579             =head1 WARNING
580              
581             This warning is from Simon Cozens' C<Finance::Bank::LloydsTSB>, and seems just
582             as apt here.
583              
584             This is code for B<online banking>, and that means B<your money>, and that means
585             B<BE CAREFUL>. You are encouraged, nay, expected, to audit the source of this
586             module yourself to reassure yourself that I am not doing anything untoward with
587             your banking data. This software is useful to me, but is provided under B<NO
588             GUARANTEE>, explicit or implied.
589              
590             =head1 ERROR HANDLING AND DEBUGGING
591              
592             Most methods die() when encountering errors, so you can use eval() to trap them.
593              
594             Full response headers and bodies are dumped to a separate logger. See
595             documentation on C<new()> below and the sample script in examples/ subdirectory
596             in the distribution.
597              
598             =head1 ATTRIBUTES
599              
600             =head1 METHODS
601              
602             =for Pod::Coverage (BUILD|get_estatement)
603              
604             =head2 new(%args)
605              
606             Create a new instance. %args keys:
607              
608             =over 4
609              
610             =item * username
611              
612             Optional if you are just using utility methods like C<parse_statement()> and not
613             C<login()> etc.
614              
615             =item * password
616              
617             Optional if you are just using utility methods like C<parse_statement()> and not
618             C<login()> etc.
619              
620             =item * mech
621              
622             Optional. A L<WWW::Mechanize>-like object. By default this module instantiate a
623             new L<Finance::BankUtils::ID::Mechanize> (a WWW::Mechanize subclass) object to
624             retrieve web pages, but if you want to use a custom/different one, you are
625             allowed to do so here. Use cases include: you want to retry and increase timeout
626             due to slow/unreliable network connection (using
627             L<WWW::Mechanize::Plugin::Retry>), you want to slow things down using
628             L<WWW::Mechanize::Sleepy>, you want to use IE engine using
629             L<Win32::IE::Mechanize>, etc.
630              
631             =item * verify_https
632              
633             Optional. If you are using the default mech object (see previous option), you
634             can set this option to 1 to enable SSL certificate verification (recommended for
635             security). Default is 0.
636              
637             SSL verification will require a CA bundle directory, default is /etc/ssl/certs.
638             Adjust B<https_ca_dir> option if your CA bundle is not located in that
639             directory.
640              
641             =item * https_ca_dir
642              
643             Optional. Default is /etc/ssl/certs. Used to set HTTPS_CA_DIR environment
644             variable for enabling certificate checking in Crypt::SSLeay. Only used if
645             B<verify_https> is on.
646              
647             =item * logger
648              
649             Optional. You can supply any object that responds to trace(), debug(), info(),
650             warn(), error(), or fatal() here. If not specified, this module will use a
651             default logger.
652              
653             =item * logger_dump
654              
655             Optional. This is just like C<logger> but this module will log contents of
656             response here instead of to C<logger> for debugging purposes. You can configure
657             something like L<Log::ger::Output::DirWriteRotate> to save web pages more
658             conveniently as separate files. If unspecified, the default logger is used (same
659             as C<logger>).
660              
661             =back
662              
663             =head2 login()
664              
665             Login to the net banking site. You actually do not have to do this explicitly as
666             login() is called by other methods like C<check_balance()> or
667             C<get_statement()>.
668              
669             If login is successful, C<logged_in> will be set to true and subsequent calls to
670             C<login()> will become a no-op until C<logout()> is called.
671              
672             Dies on failure.
673              
674             =head2 logout()
675              
676             Logout from the net banking site. You need to call this at the end of your
677             program, otherwise the site will prevent you from re-logging in for some time
678             (e.g. 10 minutes).
679              
680             If logout is successful, C<logged_in> will be set to false and subsequent calls
681             to C<logout()> will become a no-op until C<login()> is called.
682              
683             Dies on failure.
684              
685             =head2 list_accounts()
686              
687             Return an array containing all account numbers that are associated with the
688             current net banking login.
689              
690             =head2 check_balance([$account])
691              
692             Return balance for specified account, or the default account if C<$account> is
693             not specified.
694              
695             =head2 get_statement(%args) => $stmt
696              
697             Get account statement. %args keys:
698              
699             =over 4
700              
701             =item * account
702              
703             Optional. Select the account to get statement of. If not specified, will use the
704             already selected account.
705              
706             =item * days
707              
708             Optional. Number of days between 1 and 31. If days is 1, then start date and end
709             date will be the same. Default is 31.
710              
711             =item * start_date
712              
713             Optional. Default is end_date - days.
714              
715             =item * end_date
716              
717             Optional. Default is today (or some 1+ days from today if today is a
718             Saturday/Sunday/holiday, depending on the default value set by the site's form).
719              
720             =back
721              
722             See parse_statement() on structure of $stmt.
723              
724             =head2 parse_statement($html, %opts) => $res
725              
726             Given the HTML text of the account statement results page, parse it into
727             structured data:
728              
729             $stmt = {
730             start_date => $start_dt, # a DateTime object
731             end_date => $end_dt, # a DateTime object
732             account_holder => STRING,
733             account => STRING, # account number
734             currency => STRING, # 3-digit currency code
735             transactions => [
736             # first transaction
737             {
738             date => $dt, # a DateTime obj, book date ("tanggal pembukuan")
739             seq => INT, # a number >= 1 which marks the sequence of
740             # transactions for the day
741             amount => REAL, # a real number, positive means credit (deposit),
742             # negative means debit (withdrawal)
743             description => STRING,
744             is_pending => BOOL,
745             branch => STRING, # a 4-digit branch/ATM code
746             balance => REAL,
747             },
748             # second transaction
749             ...
750             ]
751             }
752              
753             Returns:
754              
755             [$status, $err_details, $stmt]
756              
757             C<$status> is 200 if successful or some other 3-digit code if parsing failed.
758             C<$stmt> is the result (structure as above, or undef if parsing failed).
759              
760             Options:
761              
762             =over 4
763              
764             =item * return_datetime_obj => BOOL
765              
766             Default is true. If set to false, the method will return dates as strings with
767             this format: 'YYYY-MM-DD HH::mm::SS' (produced by DateTime->dmy . ' ' .
768             DateTime->hms). This is to make it easy to pass the data structure into YAML,
769             JSON, MySQL, etc. Nevertheless, internally DateTime objects are still used.
770              
771             =back
772              
773             Additional notes:
774              
775             The method can also handle some copy-pasted text from the GUI browser, but this
776             is no longer documented or guaranteed to keep working.
777              
778             =head1 HOMEPAGE
779              
780             Please visit the project's homepage at L<https://metacpan.org/release/Finance-Bank-ID-BCA>.
781              
782             =head1 SOURCE
783              
784             Source repository is at L<https://github.com/perlancar/perl-Finance-Bank-ID-BCA>.
785              
786             =head1 BUGS
787              
788             Please report any bugs or feature requests on the bugtracker website L<https://rt.cpan.org/Public/Dist/Display.html?Name=Finance-Bank-ID-BCA>
789              
790             When submitting a bug or request, please include a test-file or a
791             patch to an existing test-file that illustrates the bug or desired
792             feature.
793              
794             =head1 AUTHOR
795              
796             perlancar <perlancar@cpan.org>
797              
798             =head1 COPYRIGHT AND LICENSE
799              
800             This software is copyright (c) 2019, 2017, 2015, 2014, 2013, 2012, 2011, 2010 by perlancar@cpan.org.
801              
802             This is free software; you can redistribute it and/or modify it under
803             the same terms as the Perl 5 programming language system itself.
804              
805             =cut