The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
=head1 NAME

Finance::Bank::IE::PTSB - Finance::Bank interface for Permanent TSB (Ireland)

=head1 DESCRIPTION

This module implements the Finance::Bank 'API' for Permanent TSB
(Ireland)'s Open24 online banking service.

=over

=cut
package Finance::Bank::IE::PTSB;

use base qw( Finance::Bank::IE );

our $VERSION = "0.28";

use warnings;
use strict;

use Carp;
use File::Path;

use constant BASEURL => 'https://www.open24.ie/online/';

my %pages = (
             login => {
                       url => 'https://www.open24.ie/online/login.aspx',
                       sentinel => 'LOGIN STEP 1 OF 2',
                      },
             login2 => {
                        url => 'https://www.open24.ie/online/Login2.aspx',
                        sentinel => 'LOGIN STEP 2 OF 2',
                       },
             accounts => {
                          url => 'https://www.open24.ie/online/Account.aspx',
                          sentinel => 'CLICK ACCOUNT NAME FOR A MINI STATEMENT',
                         },
             accountdetails => {
                                url => 'https://www.open24.ie/online/StateMini.aspx?ref=0',
                                sentinel => 'MINI STATEMENT',
                               },
             payandtransfer => {
                                url => "https://www.open24.ie/online/PayAndTransfer.aspx",
                                sentinel => "Payments & Transfers",
                               },
    );

sub _pages {
    return \%pages;
}

sub _submit_first_login_page {
    my $self = shift;
    my $confref = shift||$self->cached_config();

    $self->_add_event_fields();

    return
      $self->_agent()->submit_form( fields => {
                                               txtLogin => $confref->{user},
                                               txtPassword => $confref->{password},
                                               '__EVENTTARGET' => 'lbtnContinue',
                                               '__EVENTARGUMENT' => '',
                                              }
                                  );
}

sub _submit_second_login_page {
    my $self = shift;
    my $confref = shift;

    my @pins = grep /(Digit No. \d+)/, split( /[\r\n]+/, $self->_agent()->content );
    my %submit;
    my @secrets = split( //, $confref->{pin} );
    for my $pin ( @pins ) {
        my ( $digit, $field ) = $pin =~
          m{Digit No. (\d+).*input name="(.*?)"};
        my $secret = $secrets[$digit - 1];
        $submit{$field} = $secret;
    }
    $submit{'__EVENTTARGET'} = 'btnContinue';
    $submit{'__EVENTARGUMENT'} = '';
    $self->_add_event_fields();
    return $self->_agent()->submit_form( fields => \%submit );
}

=item * check_balance( [config] )

 Check the balances on all accounts. Optional config hashref.

=cut

sub check_balance {
    my $self = shift;
    my $confref = shift;

    $confref ||= $self->cached_config();
    my $res = $self->_get( $pages{accounts}->{url}, $confref );

    return unless $res;

    # find table class="statement"
    # first is headers (account name, number-ending-with, balance, available
    # each subsequent one is an account
    my @headers;
    my @accounts;
    my $parser = new HTML::TokeParser( \$res );
    while( my $tag = $parser->get_tag( "table" )) {
        next unless $self->_streq( $tag->[1]{class}, "statement" );

        my @account;
        while( $tag = $parser->get_tag( "th", "td", "/tr", "/table" )) {
            last if $tag->[0] eq "/table";
            if ( $tag->[0] =~ /^t[hd]$/ ) {
                my $closer = "/" . $tag->[0];
                my $text = $parser->get_trimmed_text( $closer );
                if ( $tag->[0] eq "th" ) {
                    push @headers, $text;
                } else {
                    push @account, $text;
                }
            } else { # ( $tag->[0] eq "/tr" ) {
                if ( @account ) {
                    push @accounts, [ @account ];
                    @account = ();
                }
            }
        }
    }

    # match headers to data
    my @return;
    for my $account ( @accounts ) {
        my %account;

        for my $header ( @headers ) {
            my $data = shift @{$account};

            if ( $header =~ /Account Name/ ) {
                $account{type} = $data;
                $account{nick} = $data;
            } elsif ( $header =~ /Account No\./ ) {
                $account{account_no} = $data;
            } elsif ( $header =~ /Account Balance \((\w+)\)/ ) {
                $account{currency} = $1;
                $account{balance} = $data;
            }
        }
        # prune stuff we can't identify
        next if !defined( $account{balance} );
        push @return, bless \%account, "Finance::Bank::IE::PTSB::Account";
    }

    return @return;
}

=item * account_details( $account [, config] )

 Return transaction details from the specified account

=cut

sub account_details {
    my $self = shift;
    my $wanted = shift;
    my $confref = shift;

    my @details;

    $confref ||= $self->cached_config();

    my $res = $self->_get( $pages{accounts}->{url}, $confref );

    return unless $res;
    return unless $wanted;

    # this is pretty brutal
    my @likely = grep {m{(StateMini.aspx\?ref=\d+).*?$wanted}} split( /[\r\n]/, $res );
    if ( scalar( @likely ) == 1 ) {
        my ( $url ) = $likely[0] =~ m/^.*(StateMini[^"]+)".*$/;
        # convert to an absolute URL so that the _get pagelookup works
        my $uri = new_abs URI( $url, $self->_agent()->response()->request->uri());
        $res = $self->_get( $uri->as_string(), $confref );

        # parse!
        # there's a header table which is untagged
        # and then there's this (tblTransactions):
        # <tr>
        #       <td class="Content" align="left" valign="middle" colspan="1" width="18%">DD/MM/YYYY</td><td class="Content" align="left" valign="middle" colspan="1" width="46%">DESC</td><td class="Content" align="right" valign="middle" colspan="1" width="18%">- AMT (withdrawal) or + AMT (deposit)</td><td class="Content" align="right" valign="middle" colspan="1" width="18%">BALANCE +/-</td>
        #   </tr>

        my $parser = new HTML::TokeParser( \$res );
        while( my $tag = $parser->get_tag( "table" )) {
            if ( $self->_streq( $tag->[1]{id}, "tblTransactions" )) {
                $self->_dprintf( "Found transaction table\n" );
                my @fields;
                while( my $tag = $parser->get_tag( "td", "/tr", "/table" )) {
                    if ( $tag->[0] eq "td" ) {
                        push @fields, $parser->get_trimmed_text( "/td" );
                    } elsif ( $tag->[0] eq "/tr" ) {
                        if ( @fields ) { # there are spurious blank lines
                            my ( $dr, $cr ) = ( 0, 0 );
                            if ( $fields[2] =~ /^-/ ) {
                                ( $dr = $fields[2] ) =~ s/^- //;
                            } else {
                                ( $cr = $fields[2] ) =~ s/^\+ //;
                            }

                            my ( $bal, $sign ) = $fields[3] =~ /^(.*) (.)$/;

                            push @details,
                            [
                             $fields[0],
                             $fields[1],
                             $dr,
                             $cr,
                             $sign.$bal,
                             ]
                             ;
                            @fields = ();
                        }
                    } else {
                        last;
                    }
                }
                last;
            }
        }

    } else {
        $self->_dprintf( "Found " . scalar(@likely) . " matches\n" );
        return;
    }

    unshift @details, [ 'Date', 'Desc', 'DR', 'CR', 'Balance' ];

    return @details;
}

=item * $self->_get_payments_page( account [, config ] )

 Get the third-party payments page for account

=cut

sub _get_payments_page {
    my $self = shift;
    my $account_from = shift;
    my $confref = shift;

    return unless $account_from;

    # allow passing in of account objects
    if ( ref $account_from eq "Finance::Bank::IE::PTSB::Account" ) {
        $account_from = $account_from->{nick};
    }

    $confref ||= $self->cached_config();
    my $res = $self->_get( $pages{accounts}->{url}, $confref );

    return unless $res;

    $self->_get( $pages{payandtransfer}->{url} )
      or return 0;
    $self->_save_page();

    if ( $self->_agent()->content() !~ /Payments &amp; Transfers/is ) {
        $self->_dprintf( "PayAndTransfer.aspx doesn't contain sentinel\n" );
        return 0;
    }

    return 1;
}

=item * $self->list_beneficiaries( account )

 List beneficiaries of C<account>

=cut
sub list_beneficiaries {
    my $self = shift;
    my $account_from = shift;
    my $confref = shift;

    return unless $self->_get_payments_page( $account_from, $confref );

    # follow link to 'manage accounts'
    $self->_add_event_fields();
    my $res = $self->_agent()->submit_form(
                                        fields => {
                                                   '__EVENTTARGET' => 'ctl00$cphBody$lbManageMyPayeeAccounts',
                                                   '__EVENTARGUMENT' => '',
                                                  }
                                       );

    $self->_save_page();
    return unless $res->is_success();

    my @beneficiaries;

    for my $ddlPaymentType ( 0..3 ) {
        if ( $ddlPaymentType > 0 ) {
            $self->_add_event_fields();
            $res = $self->_agent()->submit_form(
                                                fields => {
                                                           '__EVENTTARGET' => 'ctl00$cphBody$ddlPaymentType',
                                                           '__EVENTARGUMENT' => '',
                                                           'ctl00$cphBody$ddlPaymentType' => $ddlPaymentType,
                                                          }
                                               );
            $self->_save_page( "ddlPaymentType=$ddlPaymentType" );
            return unless $res->is_success();
        }

        my $page = $self->_agent()->content;
        my $parser = new HTML::TokeParser( \$page );

        my @beneficiary;
        my $found = 0;
        while ( my $tag = $parser->get_tag( "table" )) {
            if ( $self->_streq( $tag->[1]{class}, 'transfereeList' )) {
                $found = 1;
                last;
            }
        }

        # no transferees of this type
        if ( !$found ) {
            $self->_dprintf( "No category $ddlPaymentType beneficiaries found\n" );
            next;
        }

        while ( my $tag = $parser->get_tag( "td", "/tr", "/table" )) {
            last if ( $tag->[0] eq "/table" );
            if ( $tag->[0] eq "/tr" ) {
                if ( @beneficiary ) {
                    push @beneficiaries,
                      bless {
                             type => 'Beneficiary',
                             nick => $beneficiary[0],
                             lastused => $beneficiary[1],
                             ref => $beneficiary[2],
                             source => $beneficiary[3],
                             input => $beneficiary[4],
                             type => $ddlPaymentType,
                             account_no => 'hidden',
                             status => 'Active',
                            }, "Finance::Bank::IE::PTSB::Account";
                    @beneficiary = ();
                    $self->_dprintf( "Found beneficiary " . $beneficiaries[-1]->{nick} . "\n" );
                }
            } else {
                if ( $self->_streq( $tag->[1]{class}, 'transfereeListAction' )) {
                    $tag = $parser->get_tag( "input" );
                    push @beneficiary, $tag->[1]{id};
                } else {
                    push @beneficiary, $parser->get_trimmed_text( "/td" );
                }
            }
        }
    }

    \@beneficiaries;
}

=item * $self->add_beneficiary( $from_account, $to_account_details, $config )

 Add a beneficiary to $from_account.

=cut

sub add_beneficiary {
    my ( $self, $account_from, $to_account_no, $to_nsc, $to_ref, $to_nick,
         $confref ) =
      @_;

    return unless $to_nick;
    return unless $self->_get_payments_page( $account_from, $confref );

    # Create a new Third Party Transfer
    $self->_agent()->follow_link( text => 'Create a new Third Party Transfer' );
    $self->_save_page();

    return unless $self->_agent()->content() =~
      /CREATE A NEW THIRD PARTY TRANSFER/is;

    $self->_add_event_fields();
    $self->_agent()->submit_form(
                                 fields => {
                                            txtSortCode => $to_nsc,
                                            txtAccountCode => $to_account_no,
                                            txtBillRef => $to_ref,
                                            txtBillName => $to_nick,
                                            # if you have multiple accounts, ddlAccounts probably needs setting. Option value = NSC+Account_no!
                                            '__EVENTTARGET' => 'lbtnContinue',
                                            '__EVENTARGUMENT' => '',
                                           },
                                );
    $self->_save_page();

    return unless $self->_agent()->content() =~
      /CREATE A NEW THIRD PARTY TRANSFER.*STEP 2/si;

    $self->_add_event_fields();
    $self->_agent()->submit_form(
                                 fields => {
                                            'txtSMSCode' => '11111',
                                            '__EVENTTARGET' => 'lbtnContinue',
                                            '__EVENTARGUMENT' => '',
                                           },
                                );

    return unless $self->_agent()->content() =~
      /CREATE A NEW THIRD PARTY TRANSFER.*STEP 3/si;

    return 1;
}

=item * $receipt = $self->funds_transfer( $from, $to, $amt )

 Transfer C<$amt> from C<$from> to C<$to>. C<$from> has to match one
 of your account nicknames; C<$to> has to match a configured
 beneficiary.

=cut

sub funds_transfer {
    my $self = shift;
    my $account_from = shift;
    my $account_to = shift;
    my $amount = shift;
    my $confref = shift;

    # allow passing account object as destination. source is handled
    # by list_beneficiaries.
    if ( ref $account_to eq __PACKAGE__ . "::Account" ) {
        $account_to = $account_to->{nick};
    }

    $self->_dprintf( " funds_transfer: listing beneficiaries\n" );
    my $beneficiaries = $self->list_beneficiaries( $account_from, $confref );

    my $found;
    for my $beneficiary ( @{$beneficiaries}) {
        if ( $beneficiary->{ref} eq $account_to or
             $beneficiary->{nick} eq $account_to ) {
            if ( $found ) {
                $found = "ambiguous";
                last;
            }
            $found = $beneficiary;
        }
    }

    my $receipt;
    if ( !$found ) {
        $self->_dprintf( "no beneficiaries found for specified account" );
    } elsif ( ref $found ne "Finance::Bank::IE::PTSB::Account" ) {
        $self->_dprintf( "multiple matching beneficiaries found" );
    } else {
        if ( $found->{status} ne "Active" ) {
            $self->_dprintf( "found an account but it's not active" );
        } else {
            $self->_agent()->submit_form( fields => {
                                                     grpTransOther => $found->{input},
                                                     txtAmount => $amount,
                                                     '__EVENTTARGET' => 'lbtnPay',
                                                     '__EVENTARGUMENT' => '',
                                                    },
                                        );
            $self->_save_page();

            $receipt = $self->_agent()->content();

            if ( $receipt =~ /lbtnConfirm/s ) {
                $self->_agent()->submit_form( fields => {
                                                         '__EVENTTARGET' => 'lbtnConfirm',
                                                         '__EVENTARGUMENT' => '',
                                                        });
                $self->_save_page();
                $receipt = $self->_agent()->content();
                if ( $receipt !~ /successfully processed/si ) {
                    $self->_dprintf( "did not get transaction confirmation" );
                    $receipt = undef;
                }
            } else {
                $self->_dprintf( "did not get confirmation request page" );
                $receipt = undef;
            }
        }
    }

    return $receipt;
}

=item * $scrubbed = $self->_scrub_page( $content )

 Scrub the supplied content for PII.

=cut
sub _scrub_page {
    my ( $self, $content ) = @_;

    # This would be nicer with XPath. Alas, I tried XML::Xpath and the
    # world ended - it wanted to fetch a DTD from the web, which
    # didn't exist, and it took an awfully long time to figure that
    # out.
    my $output = "";
    my $parser = new HTML::TokeParser( \$content );
    while( my $token = $parser->get_token()) {
        my $token_string = $token->[-1];
        if ( $token->[0] eq 'T' ) {
            $token_string = $token->[1];
            if ( $token_string =~ /Your last successful logon/ ) {
                $token_string = "Your last successful logon was on 01 January 1970 at 00:00";
            }
        }

        if ( $token->[0] eq 'S' ) {
            # ASP state
            if ( $token->[1] eq 'input' ) {
                if ( $self->_streq( $token->[2]{name}, "__VIEWSTATE" ) or
                     $self->_streq( $token->[2]{name}, "__EVENTVALIDATION" ) or
                     $self->_streq( $token->[2]{name}, "PtsbCifNumber" ) or
                     $self->_streq( $token->[2]{name}, "PtsbBranchNumber" )
                   ) {
                    $token->[2]{value} = "";
                    $token_string = $self->_rebuild_tag( $token );
                }
            }

            if ( $token->[1] eq 'select' ) {
                if ( $self->_streq( $token->[2]{name}, 'ctl00$cphBody$ddlDestinationAccount' ) or
                   ( $self->_streq( $token->[2]{name}, 'ctl00$cphBody$ddlSourceAccount' ))) {
                    while ( my $account_token = $parser->get_token()) {
                        my $string = $account_token->[0] eq 'T' ? $account_token->[1] : $account_token->[-1];
                        if ( $account_token->[0] eq 'S' and $account_token->[1] eq 'option' ) {
                            my $val = $account_token->[2]{value};
                            if ( $val ne 'Please select' and $val !~ /^\d+$/ ) {
                                $account_token->[2]{value} = "0";
                                $string = $self->_rebuild_tag( $account_token );
                                $val = '0';
                            }
                            if ( $val ne 'Please select' ) {
                                $string .= "Account Type $val - 9999";
                            } else {
                                $string .= $parser->get_trimmed_text();
                            }
                            my $tag = $parser->get_tag( "/option" );
                            $string .= $tag->[-1];

                        }
                        $token_string .= $string;
                        last if ( $account_token->[0] eq 'E' and $account_token->[1] eq 'select' );
                    }
                }
            }

            if ( $token->[1] eq 'table' and $self->_streq( $token->[2]{class}, "transfereeList" )) {
                my $col = 0;
                while ( my $account_token = $parser->get_token()) {
                    my $string = $account_token->[0] eq 'T' ? $account_token->[1] : $account_token->[-1];
                    if ( $account_token->[1] eq 'td' and $account_token->[0] eq 'S' ) {
                        my $replacement = "";
                        if ( $col == 0 ) {
                            $replacement = "Recipient";
                        } elsif ( $col == 1 ) {
                            $replacement = "01/01/1970";
                        } elsif ( $col == 2 ) {
                            $replacement = "Reference";
                        } elsif ( $col == 3 ) {
                            $replacement = "Source A/C";
                        }
                        $string .= $replacement;
                        $parser->get_text();
                        $col++;
                    }
                    if ( $account_token->[0] eq 'E' and $account_token->[1] eq 'tr' ) {
                        $col = 0;
                    }
                    $token_string .= $string;
                    last if ( $account_token->[0] eq 'E' and $account_token->[1] eq 'table' );
                }
            }

            if ( $token->[1] eq 'span' ) {
                if ( $self->_streq( $token->[2]{id}, "lblTransferTo" )) {
                    $token_string .= "Recipient";
                    $parser->get_text();
                } elsif ( $self->_streq( $token->[2]{id}, "lblTranRef" )) {
                    $token_string .= "Reference";
                    $parser->get_text();
                } elsif ( $self->_streq( $token->[2]{id}, "lblFrAcc" )) {
                    $token_string .= "Source A/C";
                    $parser->get_text();
                } elsif ( $self->_streq( $token->[2]{id}, "lblAmount" )) {
                    $token_string .= "9999";
                    $parser->get_text();
                }
            }

            # should possibly do this within table class="statement"
            if ( $token->[1] eq 'a' and
                 defined( $token->[2]{href}) and
                 $token->[2]{href} =~ /^StateMini.aspx/ ) {

                # winging it a little here.
                my @replacements = ( "Account Type",
                                     9999, # account number
                                     99999.99, # balance
                                     99999.99, # balance
                                   );

                while ( my $account_token = $parser->get_token()) {
                    my $string = $account_token->[0] eq 'T' ? $account_token->[1] : $account_token->[-1];
                    if ( $account_token->[0] eq 'E' and $account_token->[1] eq 'tr' ) {
                        $token_string .= $string;
                        last;
                    }
                    if ( $account_token->[0] eq 'T' and $string !~ /^\s+$/ ) {
                        $string = shift @replacements;
                    }
                    if ( $account_token->[0] eq 'S' and
                         defined( $account_token->[2]{title} )) {
                        $account_token->[2]{title} = "";
                        $string = $self->_rebuild_tag( $account_token );
                    }
                    $token_string .= $string;
                }
            }
        }

        $output .= $token_string;
    }

    return $output;
}

sub _add_event_fields {
    my $self = shift;

    # these get added by javascript on the page
    my $form = $self->_agent()->current_form();
    for my $name qw( __EVENTTARGET __EVENTARGUMENT ) {
        if ( my $input = $form->find_input( $name )) {
            $input->readonly( 0 );
        } else {
            $input = new HTML::Form::Input( type => 'text',
                                            name => $name );
            $input->add_to_form( $form );
        }
    }
}

=back

=cut

package Finance::Bank::IE::PTSB::Account;

no strict;
sub AUTOLOAD { my $self=shift; $AUTOLOAD =~ s/.*:://; $self->{$AUTOLOAD} }

1;