File Coverage

blib/lib/Finance/Bank/ID/BPRKS.pm
Criterion Covered Total %
statement 76 201 37.8
branch 21 90 23.3
condition 2 14 14.2
subroutine 12 24 50.0
pod 5 6 83.3
total 116 335 34.6


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