package Finance::Bank::Generic; use strict; use version; our $VERSION = qv('0.01'); use Date::Format; use JSON::XS (); use File::stat; use Perl6::Slurp; use URI::Escape; use WWW::Mechanize; use HTML::TableExtract; my $json_parser = JSON::XS->new->latin1(); # constructor # attributes: # card_number - 16 digits # password sub new { my ($class, $args) = @_; # unless ( $args->{card_number} && $args->{password} && $args->{cache_path} ) { # return; # } # copy args into self my $self = { %$args }; # sanitize bank_id, allow only alphanum and dash $self->{bank_id} =~ s/[^\w-]//g; # cache_expiry of 0 (zero) means the cache never expires; useful for testing $self->{cache_expiry} = defined $self->{cache_expiry} ? $self->{cache_expiry} : 10; $self->{mech} = WWW::Mechanize->new(); bless $self, $class; $self->{config} = $self->_config(); $self->_validate_config() or return undef; return $self; } sub _config { die "_config method must be implemented"; } sub _validate_config { my ($self) = @_; my $valid = 1; my @required = qw(LOGIN_FORM_URL ACCOUNTS_URL FIELD_CARD_NUMBER FIELD_PASSWORD TABLE_HEADERS.summary TABLE_HEADERS.details DETAIL_FORM_ACCOUNT_FIELD); foreach my $required ( @required ) { my $defined; my ($key, $subkey) = split /\./, $required; if ( $key && $subkey ) { $defined = defined $self->{config}->{$key}->{$subkey}; } else { $defined = defined $self->{config}->{$required}; } unless ( $defined ) { warn "required config key missing: $required"; $valid = 0; } } return $valid; } sub classname { my ($self) = @_; my $classname = ref $self; $classname =~ s/::/-/g; return $classname; } # return a list of accounts. # each item in the list is a hashref containing account number, account name, # available balance and posted balance sub accounts { my ($self) = @_; my $cache_key = $self->get_cache_key_for_accounts(); my $cached_accounts = $self->get_cache($cache_key); if ( defined $cached_accounts ) { $self->{accounts} = $cached_accounts; } else { my $accounts = $self->_accounts(); $self->set_cache($cache_key, $accounts); $self->{accounts} = $accounts; } return $self->{accounts}; } sub _accounts { my ($self) = @_; $self->_login() or return undef; # go the accounts link $self->{mech}->get($self->{config}->{ACCOUNTS_URL}); # need keep_html because account_id is embedded in a link my $te = HTML::TableExtract->new( headers => $self->{config}->{TABLE_HEADERS}->{summary}, keep_html => 1, strip_html_on_match => 1 ); $te->parse($self->_content()); unless ( $te->first_table_found() ) { warn $self->{bank_id} . ": no tables were matched for headers:" . join(', ', @{$self->{config}->{TABLE_HEADERS}->{summary}}); return []; } my @accounts = (); foreach my $row ( $te->rows() ) { my $account = $self->_parse_summary_row($row); if ( $account ) { $account->{name_esc} = uri_escape($account->{name}); push @accounts, $account; } } return \@accounts; } # given an account name, return transactions as an arrayref of hashrefs. sub details { my ($self, $account_name) = @_; my $classname = $self->classname(); my $account_name_safe = $account_name; $account_name_safe =~ s/\s/_/g; my $cache_key = "details_${account_name_safe}_${classname}_".$self->{bank_id}; my $details = $self->get_cache($cache_key); unless ( defined $details ) { $details = $self->_details($account_name); $self->set_cache($cache_key, $details); } $self->{details} = $details; return $self->{details}; } sub _details { my ($self, $account_name) = @_; if ( !$self->{accounts} || !scalar @{ $self->{accounts} } ) { $self->accounts(); } my $wanted_account; # find account based on account name foreach my $account ( @{ $self->{accounts} } ) { if ( $account->{name} eq $account_name ) { $wanted_account = $account; last; } } # check if we are on the summary page, if not, get there unless ( $self->{mech}->form_with_fields($self->{config}->{DETAIL_FORM_ACCOUNT_FIELD}) ) { $self->_login() or return undef; } if ( my $action = $self->{config}->{DETAIL_FORM_ACTION} ) { $self->_update_form_action($action); } $self->{mech}->submit_form(with_fields => { $self->{config}->{DETAIL_FORM_ACCOUNT_FIELD} => $wanted_account->{account_id} }); my $te = HTML::TableExtract->new( headers => $self->{config}->{TABLE_HEADERS}->{details} ); $te->parse($self->_content()); unless ( $te->first_table_found() ) { warn $self->{bank_id} . ": no tables were matched for headers:" . join(', ', @{$self->{config}->{TABLE_HEADERS}->{details}}); return []; } my @details = (); foreach my $row ( $te->rows() ) { my $details = $self->_parse_details_row($row); if ( $details ) { push @details, $details; } } return \@details; } sub _content { my ($self) = @_; return $self->{mech}->content(); } sub _login { my ($self) = @_; $self->{mech}->get($self->{config}->{LOGIN_FORM_URL}); my $fields = { $self->{config}->{FIELD_CARD_NUMBER} => $self->{card_number}, $self->{config}->{FIELD_PASSWORD} => $self->{password}, }; $self->{mech}->submit_form( with_fields => $fields ); unless ( $self->{mech}->success() ) { warn $self->{bank_id} . ": login did not succeed, status=".$self->{mech}->status(); return undef; } return 1; # true } # modify form action before submitting it. CIBC needs this. sub _update_form_action { my ($self, $action) = @_; my $content = $self->_content(); $content = $self->_replace_form_action($content, $action); $self->{mech}->update_html($content); } sub _replace_form_action { my ($self, $content, $action) = @_; $content =~ s/(form.+?action=").*?(")/${1}${action}${2}/i; return $content; } sub get_cache { my ($self, $key) = @_; return undef unless $self->{cache_path}; my $dump = undef; my $file = $self->{cache_path}."/$key"; my $cache_age = $self->get_cache_age($key); if ( -e $file and defined $self->{cache_expiry} and defined $cache_age ) { if ( $self->{cache_expiry} == 0 or $cache_age < $self->{cache_expiry} ) { $dump = slurp $file; $dump = $json_parser->decode($dump) } } return $dump; } sub set_cache { my ($self, $key, $value) = @_; return undef unless $self->{cache_path}; my $file = $self->{cache_path}."/$key"; if ( open my $fh, ">$file" ) { my $dump = $json_parser->encode($value); print $fh $dump; close $fh; } else { } } sub get_cache_mtime { my ($self, $key) = @_; my $mtime; my $file = $self->{cache_path}."/$key"; if ( -e $file ) { my $st = stat($file); $mtime = $st->mtime(); } return $mtime; } sub get_cache_age { my ($self, $key) = @_; my $mtime = $self->get_cache_mtime($key); my $age; if ( $mtime ) { my $now = time; $age = ($now - $mtime)/60; # in minutes } return $age; } sub get_cache_mtime_for_accounts { my ($self) = @_; my $mtime = $self->get_cache_mtime($self->get_cache_key_for_accounts()); my @lt = localtime($mtime); $mtime = strftime('%Y-%m-%d %T', @lt), return $mtime; } sub get_cache_key_for_accounts { my ($self) = @_; my $classname = $self->classname(); my $cache_key = "accounts_${classname}_".$self->{bank_id}; return $cache_key; } 1; __END__ =head1 NAME Finance::Bank::Generic - abstract base class for banks =head1 DESCRIPTION Finance::Bank::Generic provides common methods for a Finance::Bank class. Here are some things this class provides for you: - authentication: bank card number and password are assumed - caching - get accounts - get transactions =head1 SUBCLASSING To subclass you need to implement the following methods: =head2 _config return a hashref with the following keys: LOGIN_FORM_URL - the url to the login form ACCOUNTS_URL - the url to the listing of accounts FIELD_CARD_NUMBER - the form field name of the card number FIELD_PASSWORD - the form field name of the password TABLE_HEADERS - the headers to parse for each of account summary and account details { summary => [ ], details => [ ] } DETAIL_FORM_ACCOUNT_FIELD - DETAIL_FORM_ACTION - the form action to use to get to the detail page (optional, currently used by CIBC) =head2 _parse_summary_row given a row, return a hashref of key value pairs for that row =head2 _parse_details_row given a row, return a hashref of key value pairs for that row For more details see Finance::Bank::CIBC or Finance::Bank::PC =head1 AUTHOR Ilia Lobsanov (ilial@cpan.org)