File Coverage

blib/lib/Finance/Bank/ID/Mandiri.pm
Criterion Covered Total %
statement 216 332 65.0
branch 97 176 55.1
condition 23 40 57.5
subroutine 17 30 56.6
pod 5 6 83.3
total 358 584 61.3


line stmt bran cond sub pod time code
1             package Finance::Bank::ID::Mandiri;
2              
3 1     1   629308 use 5.010001;
  1         10  
4 1     1   6 use strict;
  1         2  
  1         19  
5 1     1   5 use warnings;
  1         2  
  1         24  
6 1     1   598 use HTTP::Headers;
  1         4257  
  1         40  
7 1     1   505 use Parse::Number::EN qw(parse_number_en);
  1         365  
  1         58  
8              
9 1     1   550 use Moo;
  1         7310  
  1         5  
10             extends 'Finance::Bank::ID::Base';
11              
12             has _variant => (is => 'rw');
13             has _re_tx => (is => 'rw');
14              
15             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
16             our $DATE = '2021-08-26'; # DATE
17             our $DIST = 'Finance-Bank-ID-Mandiri'; # DIST
18             our $VERSION = '0.383'; # VERSION
19              
20             my $re_acc = qr/(?:\d{13})/;
21             my $re_currency = qr/(?:\w{3})/;
22             my $re_money = qr/(?:\d+(?:\.\d\d?)?)/;
23             my $re_moneymin = qr/(?:-?\d+(?:\.\d\d?)?)/; # allow negative
24             my $re_money2 = qr/(?:[\d,]*(?:\.\d\d?)?)/; # allow starts with ., e.g. .00. formatted thousand=, decimal=.
25             my $re_date1 = qr!(?:\d{2}/\d{2}/\d{4})!; # 25/12/2010
26             my $re_txcode = qr!(?:\d{3,4})!;
27              
28             # original version when support first added
29             our $re_mcm_v201009 = qr!^(?<acc>$re_acc);(?<currency>$re_currency);
30             (?<date_d>\d\d)/(?<date_m>\d\d)/(?<date_y>\d\d\d\d)
31             (?<txcode>$re_txcode);
32             (?<desc1>[^;]+);(?<desc2>.*?);
33             (?<amount>$re_money)(?<amount_dbmarker>DR)?;
34             (?<bal>$re_money)(?<bal_dbmarker>DR)?$!mx;
35              
36             # what's new: third line argument
37             our $re_mcm_v201103 = qr!^(?<acc>$re_acc);(?<currency>$re_currency);
38             (?<date_d>\d\d)/(?<date_m>\d\d)/(?<date_y>\d\d\d\d)
39             (?<txcode>$re_txcode);
40             (?<desc1>[^;]+);(?<desc2>[^;]*);(?:(?<desc3>.*?);)?
41             (?<amount>$re_money)(?<amount_dbmarker>DR)?;
42             (?<bal>$re_money)(?<bal_dbmarker>DR)?$!mx;
43              
44             # what's new: txcode moved to 3rd column, credit & debit amount split into 2
45             # fields
46             our $re_mcm_v201107 = qr!^(?<acc>$re_acc);(?<currency>$re_currency);
47             (?<txcode>$re_txcode);
48             (?<date_d>\d\d)/(?<date_m>\d\d)/(?<date_y>\d\d\d\d);
49             (?<desc1>[^;]+);(?<desc2>[^;]*);(?:(?<desc3>.*?);)?
50             (?<amount_db>$re_money);
51             (?<amount_cr>$re_money);
52             (?<bal>$re_moneymin)!mx; # maybe? no more DR marker
53              
54             # what's different: a CSV (comma as field separator), a header field,
55             # no more currency field, two dates.
56             # header: Account No,Date,Val. Date,Transaction Code,Description,Description,Reference No.,Debit,Credit,
57             our $re_mcm_v201901 = qr!^(?<acc>$re_acc),
58             (?<date_d>\d\d)/(?<date_m>\d\d)/(?<date_y>\d\d),
59             (?<vdate_d>\d\d)/(?<vdate_m>\d\d)/(?<vdate_y>\d\d),
60             (?<txcode>$re_txcode),
61             "(?<desc1>[^"]*)","(?<desc2>[^"]*)",
62             (?<reference_no>[^,]*),
63             "(?<amount_db>$re_money2)",
64             "(?<amount_cr>$re_money2)",
65             !mx;
66              
67              
68             sub _make_readonly_inputs_rw {
69 0     0   0 my ($self, @forms) = @_;
70 0         0 for my $f (@forms) {
71 0         0 for my $i (@{ $f->{inputs} }) {
  0         0  
72 0 0       0 $i->{readonly} = 0 if $i->{readonly};
73             }
74             }
75             }
76              
77             sub BUILD {
78 1     1 0 4854 my ($self, $args) = @_;
79              
80 1 50       12 $self->site("https://ib.bankmandiri.co.id") unless $self->site;
81 1 50       14 $self->https_host("ib.bankmandiri.co.id") unless $self->https_host;
82             }
83              
84             sub login {
85 0     0 1 0 my ($self) = @_;
86              
87 0 0       0 return 1 if $self->logged_in;
88 0 0       0 die "400 Username not supplied" unless $self->username;
89 0 0       0 die "400 Password not supplied" unless $self->password;
90              
91 0         0 $self->logger->debug('Logging in ...');
92             $self->_req(get => [$self->site . "/retail/Login.do?action=form&lang=in_ID"],
93             {
94             id => 'login_form',
95             after_request => sub {
96 0     0   0 my ($mech) = @_;
97 0 0       0 $mech->content =~ /LoginForm/ or return "no login form";
98 0         0 "";
99             },
100 0         0 });
101 0         0 $self->mech->set_visible(
102             $self->username,
103             $self->password,
104             [image=>"x"]);
105             $self->_req(submit => [],
106             {
107             id => 'login',
108             after_request => sub {
109 0     0   0 my ($mech) = @_;
110 0 0       0 $mech->content =~ m!<font class="errorMessage">(.+?)</font>! and return $1;
111 0 0       0 $mech->content =~ /<frame\s.+Welcome/ and return; # success
112 0 0       0 $mech->content =~ m!<font class="alert">(\w.+?)</font>! and return $1;
113 0 0       0 $mech->content =~ /LoginForm/ and
114             return "submit failed, still getting login form, probably problem with image button";
115 0         0 "unknown login result page";
116             },
117 0         0 });
118             $self->_req(get => [$self->site . "/retail/Welcome.do?action=result"],
119             {
120             id => 'welcome',
121             after_request => sub {
122 0     0   0 my ($mech) = @_;
123 0 0       0 $mech->content !~ /SELAMAT DATANG/ and
124             return "failed getting welcome screen";
125 0         0 "";
126             },
127 0         0 });
128 0         0 $self->logged_in(1);
129             }
130              
131             sub logout {
132 0     0 1 0 my ($self) = @_;
133              
134 0 0       0 return 1 unless $self->logged_in;
135 0         0 $self->logger->debug('Logging out ...');
136 0         0 $self->_req(get => [$self->site . "/retail/Logout.do?action=result"],
137             {id => 'logout'});
138 0         0 $self->logged_in(0);
139             }
140              
141             sub _parse_accounts {
142 0     0   0 my ($self, $retrieve) = @_;
143 0         0 $self->login;
144 0         0 $self->logger->debug("Parsing accounts from transaction history form page ...");
145 0 0       0 $self->_req(get => [$self->site . "/retail/TrxHistoryInq.do?action=form"],
146             {id => 'txhist_form-parse_accounts'}) if $retrieve;
147 0         0 my $ct = $self->mech->content;
148 0 0       0 $ct =~ /(HISTORI TRANSAKSI|MUTASI REKENING)/ or
149             die "failed getting transaction history form page";
150 0 0       0 $ct =~ m!<select name="fromAccountID">(.+?)</select>!si or
151             die "failed getting the list of accounts select box (fromAccountID)";
152 0         0 my $opts = $1;
153 0         0 my $accts = {};
154 0         0 while ($opts =~ /<option value="(\d+)">(\d+)/g) {
155 0         0 $accts->{$2} = $1;
156             }
157 0         0 $accts;
158             }
159              
160             # if $account is not supplied, will choose the first id
161             sub _get_an_account_id {
162 0     0   0 my ($self, $account, $retrieve) = @_;
163 0         0 my $accts = $self->_parse_accounts($retrieve);
164 0         0 for (keys %$accts) {
165 0 0 0     0 if (!$account || $_ eq $account) {
166 0         0 return $accts->{$_};
167             }
168             }
169 0         0 die "cannot find any account ID";
170             }
171              
172             sub list_accounts {
173 0     0 1 0 my ($self) = @_;
174 0         0 keys %{ $self->_parse_accounts(1) };
  0         0  
175             }
176              
177             sub check_balance {
178 0     0 1 0 my ($self, $account) = @_;
179 0         0 my $s = $self->site;
180              
181 0         0 $self->login;
182 0         0 my $acctid = $self->_get_an_account_id($account, 1);
183 0         0 my $bal;
184             $self->_req(get => ["$s/retail/AccountDetail.do?action=result&ACCOUNTID=$acctid"],
185             {
186             id => "check_balance",
187             after_request => sub {
188 0     0   0 my ($mech) = @_;
189 0 0       0 $mech->content =~ m!>Informasi Saldo(?:<[^>]+>\s*)*:\s*(?:<[^>]+>\s*)*(?:Rp\.)&nbsp;([0-9.]+),(\d+)\s*<!s
190             or return "cannot grep balance in result page";
191 0         0 $bal = $self->_stripD($1)+0.01*$2;
192 0         0 "";
193             },
194 0         0 });
195 0         0 $bal;
196             }
197              
198             sub get_statement {
199 0     0 1 0 require DateTime;
200              
201 0         0 my ($self, %args) = @_;
202 0         0 my $s = $self->site;
203              
204 0         0 $self->login;
205              
206 0         0 $self->logger->debug('Getting statement ...');
207 0         0 my $mech = $self->mech;
208 0         0 $self->_req(get => ["$s/retail/TrxHistoryInq.do?action=form"],
209             {id=>"txhist_form-get_statement"});
210              
211 0         0 my $today = DateTime->today;
212 0   0     0 my $end_date = $args{end_date} || $today;
213 0         0 my $start_date = $args{start_date};
214 0 0       0 if (!$start_date) {
215 0 0       0 if (defined $args{days}) {
216 0         0 $start_date = $end_date->clone->subtract(days=>($args{days}-1));
217             $self->logger->debug(sprintf(
218             'Setting start_date to %04d-%02d-%02d (end_date - %d days)',
219             $start_date->year, $start_date->month, $start_date->day,
220 0         0 $args{days}));
221             } else {
222 0         0 $start_date = $end_date->clone->subtract(months=>1);
223 0         0 $self->logger->debug(sprintf(
224             'Setting start_date to %04d-%02d-%02d (end_date - 1mo)',
225             $start_date->year, $start_date->month, $start_date->day));
226             }
227             }
228              
229             $mech->set_fields(
230 0         0 fromAccountID => $self->_get_an_account_id($args{account}, 0),
231             fromDay => $start_date->day,
232             fromMonth => $start_date->month,
233             fromYear => $start_date->year,
234             toDay => $end_date->day,
235             toMonth => $end_date->month,
236             toYear => $end_date->year,
237             );
238              
239             # to shut up HTML::Form's read-only warning
240 0         0 $self->_make_readonly_inputs_rw($mech->forms);
241              
242 0         0 $mech->set_fields(action => "result");
243              
244             $self->_req(submit => [],
245             {
246             id => "get_statement",
247             after_request => sub {
248 0     0   0 my ($mech) = @_;
249 0 0       0 $mech->content =~ />Keterangan Transaksi</ and return "";
250 0 0       0 $mech->content =~ m!<font class="alert">(.+)</font>!
251             and return $1;
252 0         0 return "failed getting statement";
253             },
254 0         0 });
255              
256 0         0 my $resp = $self->parse_statement($self->mech->content);
257 0 0 0     0 return if !$resp || $resp->[0] != 200;
258 0         0 $resp->[2];
259             }
260              
261             sub _ps_detect {
262 7     7   153690 my ($self, $page) = @_;
263 7 100       528 if ($page =~ /(?:^|"header">)(HISTORI TRANSAKSI|MUTASI REKENING)/m) {
    100          
    100          
    100          
    50          
264 3         16 $self->_variant('ib');
265 3         10 return '';
266             } elsif ($page =~ /^CMS-Mandiri/ms) {
267 1         7 $self->_variant('cms');
268 1         3 return '';
269             #} elsif ($page =~ /$re_mcm_v201009/) {
270             # $self->_variant('mcm-v201009');
271             # $self->_re_tx($re_mcm_v201009);
272             # return '';
273             } elsif ($page =~ /$re_mcm_v201901/) {
274 1         5 $self->_variant('mcm-v201901');
275 1         4 $self->_re_tx($re_mcm_v201901);
276 1         4 return '';
277             } elsif ($page =~ /$re_mcm_v201103/) {
278 1         33 $self->_variant('mcm-v201103');
279 1         5 $self->_re_tx($re_mcm_v201103);
280 1         4 return '';
281             } elsif ($page =~ /$re_mcm_v201107/) {
282 1         6 $self->_variant('mcm-v201107');
283 1         4 $self->_re_tx($re_mcm_v201107);
284 1         3 return '';
285             } else {
286 0         0 return "No Mandiri statement page signature found";
287             }
288             }
289              
290             sub _ps_get_metadata {
291 7     7   42 my ($self, @args) = @_;
292 7 100       45 if ($self->_variant eq 'ib') {
    100          
    50          
293 3         9 $self->_ps_get_metadata_ib(@args);
294             } elsif ($self->_variant eq 'cms') {
295 1         6 $self->_ps_get_metadata_cms(@args);
296             } elsif ($self->_variant =~ /^mcm/) {
297 3         11 $self->_ps_get_metadata_mcm(@args);
298             } else {
299 0         0 return "internal bug: _variant not yet set";
300             }
301             }
302              
303             sub _ps_get_metadata_ib {
304 3     3   21 require DateTime;
305              
306 3         8 my ($self, $page, $stmt) = @_;
307              
308 3 50       45 unless ($page =~ /Tampilkan Berdasarkan(?:\s+|(?:<[^>]+>\s*)*):(?:\s+|(?:<[^>]+>\s*)*)Tanggal(?:\s+|(?:<[^>]+>\s*)*)Urutkan Berdasarkan(?:\s+|(?:<[^>]+>\s*)*):(?:\s+|(?:<[^>]+>\s*)*)Mulai dari yang kecil/s) {
309 0         0 return "currently only support descending order ('Mulai dari yang kecil')";
310             }
311              
312 3         6 my $adv1 = "maybe statement format changed or input incomplete";
313              
314 3 50       24 unless ($page =~ /(?:^|>)Nomor Rekening(?:\s+|(?:<[^>]+>\s*)*):(?:\s+|(?:<[^>]+>\s*)*)(\d+) (Rp\.|[A-Z]+)/m) {
315 0         0 return "can't get account number, $adv1";
316             }
317 3         14 $stmt->{account} = $1;
318 3 50       14 $stmt->{currency} = ($2 eq 'Rp.' ? 'IDR' : $2);
319              
320 3 50       18 my $empty_stmt = $page =~ />Tidak ditemukan catatan</ ? 1:0;
321              
322             # check completeness, because the latest transactions are displayed first
323 3 50 33     34 unless ($empty_stmt ||
324             $page =~ /(?:|>)Saldo Akhir(?:\s+|(?:<[^>]+>\s*)*):(?:\s+|(?:<[^>]+>\s*)*)\d/m) {
325 0         0 return "statement page probably truncated in the middle, try to input the whole page";
326             }
327              
328             # along with their common misspellings, these are not in DateTime::Locale
329 3         35 my %shortmon_id = (Jan=>1, Feb=>2, Peb=>2, Mar=>3, Apr=>4, Mei=>5, Jun=>6,
330             Jul=>7, Agu=>8, Agt=>8, Agus=>8, Agust=>8, Sep=>9,
331             Sept=>9, Okt=>10, Nov=>11, Nop=>11, Des=>12);
332 3         17 my %shortmon_en = (Jan=>1, Feb=>2, Mar=>3, Apr=>4, May=>5, Jun=>6,
333             Jul=>7, Aug=>8, Sep=>9, Oct=>10, Nov=>11, Dec=>12);
334 3         29 my %shortmon = (%shortmon_id, %shortmon_en);
335 3         22 my $shortmon_re = join "|", keys(%shortmon);
336 3         303 $shortmon_re = qr/(?:$shortmon_re)/;
337              
338 3 50       394 unless ($page =~ m!(?:^|>)Periode Transaksi(?:\s+|(?:<[^>]+>\s*)*):(?:\s+|(?:<[^>]+>\s*)*)(\d\d?) ($shortmon_re) (\d\d\d\d)\s*-\s*(\d\d?) ($shortmon_re) (\d\d\d\d)!m) {
339 0         0 return "can't get period, $adv1";
340             }
341 3 50       21 return "can't parse month name: $2" unless $shortmon{$2};
342 3 50       11 return "can't parse month name: $5" unless $shortmon{$5};
343 3         26 $stmt->{start_date} = DateTime->new(day=>$1, month=>$shortmon{$2}, year=>$3);
344 3         1133 $stmt->{end_date} = DateTime->new(day=>$4, month=>$shortmon{$5}, year=>$6);
345              
346             # for safety, but i forgot why
347 3         858 my $today = DateTime->today;
348 3 50       1738 if (DateTime->compare($stmt->{start_date}, $today) == 1) {
349 0         0 $stmt->{start_date} = $today;
350             }
351 3 50       727 if (DateTime->compare($stmt->{end_date}, $today) == 1) {
352 0         0 $stmt->{end_date} = $today;
353             }
354              
355 3 50       569 if ($empty_stmt) {
356 0         0 $stmt->{_total_credit_in_stmt} = 0;
357 0         0 $stmt->{_total_debit_in_stmt} = 0;
358             } else {
359 3 50       45 unless ($page =~ /(?:^|>)Total Kredit(?:\s+|(?:<[^>]+>\s*)*):(?:\s+|(?:<[^>]+>\s*)*)([0-9,.]+)[.,](\d\d)/m) {
360 0         0 return "can't get total credit, $adv1";
361             }
362 3         30 $stmt->{_total_credit_in_stmt} = $self->_stripD($1) + 0.01*$2;
363              
364 3 50       78 unless ($page =~ /(?:^|>)Total Debet(?:\s+|(?:<[^>]+>\s*)*):(?:\s+|(?:<[^>]+>\s*)*)([0-9,.]+)[.,](\d\d)/m) {
365 0         0 return "can't get total debit, $adv1";
366             }
367 3         9 $stmt->{_total_debit_in_stmt} = $self->_stripD($1) + 0.01*$2;
368             }
369              
370 3         66 "";
371             }
372              
373             sub _ps_get_metadata_cms {
374 1     1   8 require DateTime;
375              
376 1         4 my ($self, $page, $stmt) = @_;
377              
378 1 50       9 unless ($page =~ /^- End Of Statement -/m) {
379 0         0 return "statement page truncated in the middle, please input the whole page";
380             }
381              
382 1 50       10 unless ($page =~ /^Account No\s*:\s*(\d+)/m) {
383 0         0 return "can't get account number";
384             }
385 1         5 $stmt->{account} = $1;
386              
387 1 50       13 unless ($page =~ /^Account Name\s*:\s*(.+?)[\012\015]/m) {
388 0         0 return "can't get account holder";
389             }
390 1         4 $stmt->{account_holder} = $1;
391              
392 1 50       8 unless ($page =~ /^Currency\s*:\s*([A-Z]+)/m) {
393 0         0 return "can't get account holder";
394             }
395 1         3 $stmt->{currency} = $1;
396              
397 1         3 my $adv1 = "maybe statement format changed, or input incomplete";
398              
399 1 50       8 unless ($page =~ m!Period\s*:\s*(\d\d?)/(\d\d?)/(\d\d\d\d)\s*-\s*(\d\d?)/(\d\d?)/(\d\d\d\d)!m) {
400 0         0 return "can't get statement period, $adv1";
401             }
402 1         8 $stmt->{start_date} = DateTime->new(day=>$1, month=>$2, year=>$3);
403 1         312 $stmt->{end_date} = DateTime->new(day=>$4, month=>$5, year=>$6);
404              
405             # for safety, but i forgot why
406 1         285 my $today = DateTime->today;
407 1 50       539 if (DateTime->compare($stmt->{start_date}, $today) == 1) {
408 0         0 $stmt->{start_date} = $today;
409             }
410 1 50       260 if (DateTime->compare($stmt->{end_date}, $today) == 1) {
411 0         0 $stmt->{end_date} = $today;
412             }
413              
414             # Mandiri sucks, doesn't provide total credit/debit in statement
415 1         192 my $n = 0;
416 1         12 while ($page =~ m!^\d\d?/\d\d?\s!mg) { $n++ }
  3         11  
417 1         4 $stmt->{_num_tx_in_stmt} = $n;
418 1         7 "";
419             }
420              
421             sub _ps_get_metadata_mcm {
422 3     3   22 require DateTime;
423              
424 3         9 my ($self, $page, $stmt) = @_;
425              
426 3         7 my $re_tx = $self->_re_tx;
427              
428 3 50       36 $page =~ m!$re_tx!
429             or return "can't get account number & currency & date";
430 1     1   5534 $stmt->{account} = $+{acc};
  1         369  
  1         2206  
  3         30  
431 3   100     24 $stmt->{currency} = $+{currency} // "IDR"; # assume if not given
432             $stmt->{start_date} = DateTime->new(
433 3 100       46 day=>$+{date_d}, month=>$+{date_m}, year=>($+{date_y} < 100 ? 2000:0)+$+{date_y});
434              
435             # we'll just assume the first and last transaction date to be start and
436             # end date of statement, because the semicolon format doesn't include
437             # any other metadata.
438 3 50       1253 $page =~ m!.*$re_tx!s or return "can't get end date";
439             $stmt->{end_date} = DateTime->new(
440 3 100       51 day=>$+{date_d}, month=>$+{date_m}, year=>($+{date_y} < 100 ? 2000:0)+$+{date_y});
441              
442             # Mandiri sucks, doesn't provide total credit/debit in statement
443 3         888 my $n = 0;
444 3         18 while ($page =~ m!^\d{13}[;,]!mg) { $n++ }
  13         36  
445 3         7 $stmt->{_num_tx_in_stmt} = $n;
446 3         15 "";
447             }
448              
449             sub _ps_get_transactions {
450 7     7   44 my ($self, @args) = @_;
451 7 100       44 if ($self->_variant eq 'ib') {
    100          
    50          
452 3         10 $self->_ps_get_transactions_ib(@args);
453             } elsif ($self->_variant eq 'cms') {
454 1         5 $self->_ps_get_transactions_cms(@args);
455             } elsif ($self->_variant =~ /^mcm/) {
456 3         11 $self->_ps_get_transactions_mcm(@args);
457             } else {
458 0         0 return "internal bug: _variant not yet set";
459             }
460             }
461              
462             sub _ps_get_transactions_ib {
463 3     3   19 require DateTime;
464              
465 3         8 my ($self, $page, $stmt) = @_;
466              
467 3         6 my @tx;
468             my @skipped_tx;
469              
470 3 50       20 goto DONE if $page =~ m!>Tidak ditemukan catatan<!;
471              
472 3         5 my @e;
473             # text version
474 3         122 while ($page =~ m!^(\d\d)/(\d\d)/(\d\d\d\d)\s*\t\s*((?:[^\t]|\n)*?)\s*\t\s*([0-9.]+),(\d\d)\s*\t\s*([0-9.]+),(\d\d)!mg) {
475 4         60 push @e, {day=>$1, mon=>$2, year=>$3, desc=>$4, db=>$5, dbf=>$6, cr=>$7, crf=>$8};
476             }
477 3 100       11 if (!@e) {
478             # HTML version
479 1         129 while ($page =~ m!^\s+<tr[^>]*>\s*
480             <td[^>]+> (\d\d)/(\d\d)/(\d\d\d\d) \s* </td>\s*
481             <td[^>]+> ((?:[^\t]|\n)*?) </td>\s*
482             <td[^>]+> ([0-9.]+),(\d\d) </td>\s*
483             <td[^>]+> ([0-9.]+),(\d\d) </td>\s*
484             </tr>!smxg) {
485 2         124 push @e, {day=>$1, mon=>$2, year=>$3, desc=>$4, db=>$5, dbf=>$6, cr=>$7, crf=>$8};
486             }
487 1         4 for (@e) { $_->{desc} =~ s!<br ?/?>!\n!ig }
  2         25  
488             }
489              
490             # when they say "kecil ke besar" they actually mean showing the latest transactions first
491 3         7 @e = reverse @e;
492              
493 3         4 my $seq;
494 3         6 my $i = 0;
495 3         3 my $last_date;
496 3         7 for my $e (@e) {
497 6         8 $i++;
498 6         12 my $tx = {};
499 6         24 $tx->{date} = DateTime->new(day=>$e->{day}, month=>$e->{mon}, year=>$e->{year});
500 6         1811 $tx->{description} = $e->{desc};
501 6         19 my $db = $self->_stripD($e->{db}) + 0.01*$e->{dbf};
502 6         70 my $cr = $self->_stripD($e->{cr}) + 0.01*$e->{crf};
503 6 100       65 if ($db == 0) { $tx->{amount} = $cr }
  3 50       7  
504 3         8 elsif ($cr == 0) { $tx->{amount} = -$db }
505 0         0 else { return "check failed in tx#$i: debit and credit both exist" }
506              
507 6 100 66     21 if (!$last_date || DateTime->compare($last_date, $tx->{date})) {
508 3         7 $seq = 1;
509 3         6 $last_date = $tx->{date};
510             } else {
511 3         245 $seq++;
512             }
513 6         13 $tx->{seq} = $seq;
514              
515             # skip reversal pair (tx + tx') because tx' is just a correction
516             # reversal and the pair will be removed anyway by Mandiri in the next
517             # day's statement. currently can only handle pair in the same day and in
518             # succession.
519 6 50 66     39 if ($seq > 1 && $tx->{description} =~ /^Reversal \(Error Correction\)/ &&
      33        
520             $tx->{amount} == -$tx[-1]{amount}) {
521 0         0 push @skipped_tx, pop(@tx);
522 0         0 push @skipped_tx, $tx;
523 0         0 $seq -= 2;
524             } else {
525 6         19 push @tx, $tx;
526             }
527             }
528              
529             DONE:
530 3         9 $stmt->{transactions} = \@tx;
531 3         10 $stmt->{skipped_transactions} = \@skipped_tx;
532 3         22 "";
533             }
534              
535             sub _ps_get_transactions_cms {
536 1     1   7 require DateTime;
537              
538 1         4 my ($self, $page, $stmt) = @_;
539              
540 1 50       28 if ($page =~ /<br|<p/i) {
541 0         0 return "sorry, HTML version is not yet supported";
542             }
543              
544 1         3 my @e;
545             # text version
546 1         17 while ($page =~ m!^(\d\d?)/(\d\d?)\s+(\d\d?)/(\d\d?)\s+(.*?)\t(.*)\s+([0-9.]+),(\d\d) ([CD])\s+([0-9.]+),(\d\d) ([CD])!mg) {
547             # date (=tgl transaksi), value date (=tgl pembukuan?), description ("Setor Tunai"), description 2 ("DARI Andi Budi"), amount, balance
548 3         52 push @e, {daytx=>$1, montx=>$2, daybk=>$3, monbk=>$4, desc1=>$5, desc2=>$6,
549             amt=>$7, amtf=>$8, amtc=>$9, bal=>$10, balf=>11, balc=>12};
550             }
551              
552 1         5 my @tx;
553             my $seq;
554 1         0 my $last_date;
555 1         3 for my $e (@e) {
556 3         6 my $tx = {};
557             $tx->{tx_date} = DateTime->new(
558             day => $e->{daytx},
559             month => $e->{montx},
560             year => (($e->{montx} < $stmt->{start_date}->mon ||
561             $e->{montx} == $stmt->{start_date}->mon && $e->{daytx} == $stmt->{start_date}->day) ?
562             $stmt->{end_date}->year : $stmt->{start_date}->year)
563 3 100 66     15 );
564             $tx->{book_date} = DateTime->new(
565             day => $e->{daybk},
566             month => $e->{monbk},
567             year => (($e->{monbk} < $stmt->{start_date}->mon ||
568             $e->{monbk} == $stmt->{start_date}->mon && $e->{daybk} == $stmt->{start_date}->day) ?
569             $stmt->{end_date}->year : $stmt->{start_date}->year)
570 3 100 66     1017 );
571 3         897 $tx->{date} = $tx->{book_date};
572              
573 3 100       17 $tx->{amount} = ($e->{amtc} eq 'C' ? 1:-1) * $self->_stripD($e->{amt}) + 0.01 * $e->{amtf};
574 3 50       49 $tx->{balance} = ($e->{balc} eq 'C' ? 1:-1) * $self->_stripD($e->{bal}) + 0.01 * $e->{balf};
575 3         40 $tx->{description} = $e->{desc1} . "\n" . $e->{desc2};
576              
577 3 100 100     13 if (!$last_date || DateTime->compare($last_date, $tx->{date})) {
578 2         111 $seq = 1;
579 2         5 $last_date = $tx->{date};
580             } else {
581 1         79 $seq++;
582             }
583 3         8 $tx->{seq} = $seq;
584              
585 3         8 push @tx, $tx;
586             }
587 1         4 $stmt->{transactions} = \@tx;
588 1         11 "";
589             }
590              
591             sub _ps_get_transactions_mcm {
592 3     3   17 require DateTime;
593              
594 3         8 my ($self, $page, $stmt) = @_;
595              
596 3         7 my $re_tx = $self->_re_tx;
597              
598 3 100       12 my $skip_header = $self->_variant =~ /^mcm-v201901/ ? 1:0;
599 3 100       10 my $num_formatted = $self->_variant =~ /^mcm-v201901/ ? 1:0;
600              
601 3         5 my @rows;
602 3         6 my $i = 0;
603 3         40 for (split /\r?\n/, $page) {
604 14         24 $i++;
605 14 100 100     43 next if $skip_header && $i == 1;
606 13 50       45 next unless /\S/;
607 13 50       140 m!$re_tx! or die "Invalid data in line $i: '$_' doesn't match pattern".
608             " (variant = ".$self->_variant.")";
609             my $row = {
610             account => $+{acc},
611             currency => $+{currency} // "IDR", # assume if not given
612             txcode => $+{txcode},
613             day => $+{date_d},
614             month => $+{date_m},
615             year => ($+{date_y} < 100 ? 2000:0) + $+{date_y},
616             desc1 => $+{desc1},
617             desc2 => $+{desc2},
618 13 100 100     281 };
619 13 100       91 $row->{desc3} = $+{desc3} if defined($+{desc3});
620 13 100       53 if ($+{amount_cr}) {
621 9         30 my $cr = $+{amount_cr};
622 9         30 my $dr = $+{amount_db};
623 9 100       23 if ($num_formatted) {
624 5         15 $cr = parse_number_en(text => $cr);
625 5         206 $dr = parse_number_en(text => $dr);
626             } else {
627 4         9 $cr += 0;
628 4         10 $dr += 0;
629             }
630 9 100       100 $row->{amount} = $cr ? $cr : -$dr;
631             } else {
632 4 100       26 $row->{amount} = $+{amount} * ($+{amount_dbmarker} ? -1 : 1);
633             }
634 13 100       82 if (defined $+{bal}) {
635 8 50       45 $row->{balance} = $+{bal} * ($+{bal_dbmarker} ? -1 : 1);
636             }
637 13         39 push @rows, $row;
638             }
639              
640 3         11 my @tx;
641             my $seq;
642 3         0 my $last_date;
643 3         8 for my $row (@rows) {
644 13         34 my $tx = {};
645              
646             $row->{account} eq $stmt->{account} or
647 13 50       42 return "Can't handle multiple accounts in transactions yet";
648             $row->{currency} eq $stmt->{currency} or
649 13 50       31 return "Can't handle multiple currencies in transactions yet";
650              
651             $tx->{date} = DateTime->new(
652 13         80 day=>$row->{day}, month=>$row->{month}, year=>$row->{year});
653              
654 13         3764 $tx->{txcode} = $row->{txcode};
655              
656             $tx->{description} = $row->{desc1} .
657             ($row->{desc2} ? "\n" . $row->{desc2} : "") .
658 13 100       72 ($row->{desc3} ? "\n" . $row->{desc3} : "");
    100          
659              
660 13         29 $tx->{amount} = $row->{amount}+0;
661              
662 13 100 100     47 if (!$last_date || DateTime->compare($last_date, $tx->{date})) {
663 5         158 $seq = 1;
664 5         9 $last_date = $tx->{date};
665             } else {
666 8         640 $seq++;
667             }
668 13         38 $tx->{seq} = $seq;
669              
670 13         29 push @tx, $tx;
671             }
672 3         8 $stmt->{transactions} = \@tx;
673 3         29 "";
674             }
675              
676             1;
677             # ABSTRACT: Check your Bank Mandiri accounts from Perl
678              
679             __END__
680              
681             =pod
682              
683             =encoding UTF-8
684              
685             =head1 NAME
686              
687             Finance::Bank::ID::Mandiri - Check your Bank Mandiri accounts from Perl
688              
689             =head1 VERSION
690              
691             This document describes version 0.383 of Finance::Bank::ID::Mandiri (from Perl distribution Finance-Bank-ID-Mandiri), released on 2021-08-26.
692              
693             =head1 SYNOPSIS
694              
695             If you just want to download banking statements, and you use Linux/Unix, you
696             might want to use the L<download-mandiri> script instead of having to deal with
697             this library directly.
698              
699             If you want to use the library in your Perl application:
700              
701             use Finance::Bank::ID::Mandiri;
702              
703             # FBI::Mandiri uses Log::ger. to show logs, use something like:
704             use Log::ger::Output 'Screen';
705              
706             my $ibank = Finance::Bank::ID::Mandiri->new(
707             username => '....', # optional if you're only using parse_statement()
708             password => '....', # idem
709             verify_https => 1, # default is 0
710             #https_ca_dir => '/etc/ssl/certs', # default is already /etc/ssl/certs
711             );
712              
713             eval {
714             $ibank->login(); # dies on error
715              
716             my $accts = $ibank->list_accounts();
717              
718             my $bal = $ibank->check_balance($acct); # $acct is optional
719              
720             my $stmt = $ibank->get_statement(
721             account => ..., # opt, default account used if not undef
722             days => 30, # opt
723             start_date => DateTime->new(year=>2009, month=>10, day=>6),
724             # opt, takes precedence over 'days'
725             end_date => DateTime->today, # opt, takes precedence over 'days'
726             );
727              
728             print "Transactions: ";
729             for my $tx (@{ $stmt->{transactions} }) {
730             print "$tx->{date} $tx->{amount} $tx->{description}\n";
731             }
732             };
733             warn if $@;
734              
735             # remember to call this, otherwise you will have trouble logging in again
736             # for some time
737             $ibank->logout;
738              
739             Utility routines:
740              
741             # parse HTML statement directly
742             my $res = $ibank->parse_statement($html);
743              
744             =head1 DESCRIPTION
745              
746             This module provide a rudimentary interface to the web-based online banking
747             interface of the Indonesian B<Bank Mandiri> at https://ib.bankmandiri.co.id
748             (henceforth IB). You will need either L<Crypt::SSLeay> or L<IO::Socket::SSL>
749             installed for HTTPS support to work (and strictly L<Crypt::SSLeay> to enable
750             certificate verification). L<WWW::Mechanize> is required but you can supply your
751             own mech-like object.
752              
753             Aside from the above site for invididual accounts, there are also 2 other sites
754             for corporate accounts: https://cms.bankmandiri.co.id/ecbanking/ (henceforth
755             CMS) and https://mcm.bankmandiri.co.id/ (henceforth MCM). CMS is the older
756             version and as of the end of Sept, 2010 has been discontinued.
757              
758             This module currently can only login to IB and not CMS/MCM, but this module can
759             parse statement page from all 3 sites. For CMS version, only text version [copy
760             paste result] is currently supported and not HTML. For MCM, only semicolon
761             format is currently supported.
762              
763             Warning: This module is neither offical nor is it tested to be 100% safe!
764             Because of the nature of web-robots, everything may break from one day to the
765             other when the underlying web interface changes.
766              
767             =head1 WARNING
768              
769             This warning is from Simon Cozens' C<Finance::Bank::LloydsTSB>, and seems just
770             as apt here.
771              
772             This is code for B<online banking>, and that means B<your money>, and that means
773             B<BE CAREFUL>. You are encouraged, nay, expected, to audit the source of this
774             module yourself to reassure yourself that I am not doing anything untoward with
775             your banking data. This software is useful to me, but is provided under B<NO
776             GUARANTEE>, explicit or implied.
777              
778             =head1 ERROR HANDLING AND DEBUGGING
779              
780             Most methods die() when encountering errors, so you can use eval() to trap them.
781              
782             Full response headers and bodies are dumped to a separate logger. See
783             documentation on C<new()> below and the sample script in examples/ subdirectory
784             in the distribution.
785              
786             =head1 ATTRIBUTES
787              
788             =head1 METHODS
789              
790             =head2 new(%args)
791              
792             Create a new instance. %args keys:
793              
794             =over
795              
796             =item * username
797              
798             Optional if you are just using utility methods like C<parse_statement()> and not
799             C<login()> etc.
800              
801             =item * password
802              
803             Optional if you are just using utility methods like C<parse_statement()> and not
804             C<login()> etc.
805              
806             =item * mech
807              
808             Optional. A L<WWW::Mechanize>-like object. By default this module instantiate a
809             new L<Finance::BankUtils::ID::Mechanize> (a WWW::Mechanize subclass) object to
810             retrieve web pages, but if you want to use a custom/different one, you are
811             allowed to do so here. Use cases include: you want to retry and increase timeout
812             due to slow/unreliable network connection (using
813             L<WWW::Mechanize::Plugin::Retry>), you want to slow things down using
814             L<WWW::Mechanize::Sleepy>, you want to use IE engine using
815             L<Win32::IE::Mechanize>, etc.
816              
817             =item * verify_https
818              
819             Optional. If you are using the default mech object (see previous option), you
820             can set this option to 1 to enable SSL certificate verification (recommended for
821             security). Default is 0.
822              
823             SSL verification will require a CA bundle directory, default is /etc/ssl/certs.
824             Adjust B<https_ca_dir> option if your CA bundle is not located in that
825             directory.
826              
827             =item * https_ca_dir
828              
829             Optional. Default is /etc/ssl/certs. Used to set HTTPS_CA_DIR environment
830             variable for enabling certificate checking in Crypt::SSLeay. Only used if
831             B<verify_https> is on.
832              
833             =item * logger
834              
835             Optional. You can supply a L<Log::Any>-like object here. If not specified,
836             this module will use a default logger.
837              
838             =item * logger_dump
839              
840             Optional. You can supply a L<Log::Any>-like object here. This is just
841             like C<logger> but this module will log contents of response bodies
842             here for debugging purposes. You can use with something like
843             L<Log::Dispatch::Dir> to save web pages more conveniently as separate
844             files.
845              
846             =back
847              
848             =head2 login()
849              
850             Login to the net banking site. You actually do not have to do this explicitly as
851             login() is called by other methods like C<check_balance()> or
852             C<get_statement()>.
853              
854             If login is successful, C<logged_in> will be set to true and subsequent calls to
855             C<login()> will become a no-op until C<logout()> is called.
856              
857             Dies on failure.
858              
859             =head2 logout()
860              
861             Logout from the net banking site. You need to call this at the end of your
862             program, otherwise the site will prevent you from re-logging in for some time
863             (e.g. 10 minutes).
864              
865             If logout is successful, C<logged_in> will be set to false and subsequent calls
866             to C<logout()> will become a no-op until C<login()> is called.
867              
868             Dies on failure.
869              
870             =head2 list_accounts()
871              
872             =head2 check_balance([$acct])
873              
874             =head2 get_statement(%args) => $stmt
875              
876             Get account statement. %args keys:
877              
878             =over
879              
880             =item * account
881              
882             Optional. Select the account to get statement of. If not specified, will use the
883             already selected account.
884              
885             =item * days
886              
887             Optional. Number of days. If days is 1, then start date and end date will be the
888             same.
889              
890             =item * start_date
891              
892             Optional. Default is C<end_date> - 1 month, which seems to be the current limit
893             set by the bank (for example, if C<end_date> is 2013-03-08, then C<start_date>
894             will be set to 2013-02-08). If not set and C<days> is set, will be set to
895             C<end_date> - C<days>.
896              
897             =item * end_date
898              
899             Optional. Default is today (or some 1+ days from today if today is a
900             Saturday/Sunday/holiday, depending on the default value set by the site's form).
901              
902             =back
903              
904             =head2 parse_statement($html, %opts) => $res
905              
906             Given the HTML of the account statement results page, parse it into structured
907             data:
908              
909             $stmt = {
910             start_date => $start_dt, # a DateTime object
911             end_date => $end_dt, # a DateTime object
912             account_holder => STRING,
913             account => STRING, # account number
914             currency => STRING, # 3-digit currency code
915             transactions => [
916             # first transaction
917             {
918             date => $dt, # a DateTime object, book date ("tanggal pembukuan")
919             seq => INT, # a number >= 1 which marks the sequence of transactions for the day
920             amount => REAL, # a real number, positive means credit (deposit), negative means debit (withdrawal)
921             description => STRING,
922             branch => STRING, # 4-digit branch/ATM code, only for MCM
923             },
924             # second transaction
925             ...
926             ]
927             }
928              
929             Returns:
930              
931             [$status, $err_details, $stmt]
932              
933             C<$status> is 200 if successful or some other 3-letter code if parsing failed.
934             C<$stmt> is the result (structure as above, or undef if parsing failed).
935              
936             Options:
937              
938             =over 4
939              
940             =item * return_datetime_obj => BOOL
941              
942             Default is true. If set to false, the method will return dates as strings with
943             this format: 'YYYY-MM-DD HH::mm::SS' (produced by DateTime->dmy . ' ' .
944             DateTime->hms). This is to make it easy to pass the data structure into YAML,
945             JSON, MySQL, etc. Nevertheless, internally DateTime objects are still used.
946              
947             =back
948              
949             Additional notes:
950              
951             The method can also (or used to) handle copy-pasted text from the GUI browser,
952             but this is no longer documented or guaranteed to keep working.
953              
954             =head1 FAQ
955              
956             =head2 (2014) I'm getting error message: "Can't connect to ib.bankmandiri.co.id:443 at ..."
957              
958             Try upgrading your IO::Socket::SSL. It stalls with IO::Socket::SSL version 1.76,
959             but works with newer versions (e.g. 1.989).
960              
961             =head1 HOMEPAGE
962              
963             Please visit the project's homepage at L<https://metacpan.org/release/Finance-Bank-ID-Mandiri>.
964              
965             =head1 SOURCE
966              
967             Source repository is at L<https://github.com/perlancar/perl-Finance-Bank-ID-Mandiri>.
968              
969             =head1 AUTHOR
970              
971             perlancar <perlancar@cpan.org>
972              
973             =head1 CONTRIBUTORS
974              
975             =for stopwords Steven Haryanto (on PC)
976              
977             =over 4
978              
979             =item *
980              
981             Steven Haryanto (on PC) <stevenharyanto@gmail.com>
982              
983             =item *
984              
985             Steven Haryanto <steven@masterweb.net>
986              
987             =back
988              
989             =head1 CONTRIBUTING
990              
991              
992             To contribute, you can send patches by email/via RT, or send pull requests on
993             GitHub.
994              
995             Most of the time, you don't need to build the distribution yourself. You can
996             simply modify the code, then test via:
997              
998             % prove -l
999              
1000             If you want to build the distribution (e.g. to try to install it locally on your
1001             system), you can install L<Dist::Zilla>,
1002             L<Dist::Zilla::PluginBundle::Author::PERLANCAR>, and sometimes one or two other
1003             Dist::Zilla plugin and/or Pod::Weaver::Plugin. Any additional steps required
1004             beyond that are considered a bug and can be reported to me.
1005              
1006             =head1 COPYRIGHT AND LICENSE
1007              
1008             This software is copyright (c) 2021, 2019, 2017, 2016, 2015, 2014, 2013, 2012, 2011, 2010 by perlancar <perlancar@cpan.org>.
1009              
1010             This is free software; you can redistribute it and/or modify it under
1011             the same terms as the Perl 5 programming language system itself.
1012              
1013             =head1 BUGS
1014              
1015             Please report any bugs or feature requests on the bugtracker website L<https://rt.cpan.org/Public/Dist/Display.html?Name=Finance-Bank-ID-Mandiri>
1016              
1017             When submitting a bug or request, please include a test-file or a
1018             patch to an existing test-file that illustrates the bug or desired
1019             feature.
1020              
1021             =cut