71513: Card tokenization [project branch merge]
authorJonathan Prykop <jonathan@freeside.biz>
Wed, 28 Dec 2016 20:18:29 +0000 (14:18 -0600)
committerJonathan Prykop <jonathan@freeside.biz>
Wed, 28 Dec 2016 20:18:29 +0000 (14:18 -0600)
1  2 
FS/FS/Conf.pm
FS/FS/cust_main.pm
FS/FS/payinfo_Mixin.pm
httemplate/edit/agent_payment_gateway.html

diff --combined FS/FS/Conf.pm
@@@ -669,7 -669,6 +669,7 @@@ my %batch_gateway_options = 
      );
      map { $_->gatewaynum, $_->label } @gateways;
    },
 +  'per_agent' => 1,
  );
  
  my %invoice_mode_options = (
@@@ -1566,14 -1565,6 +1566,14 @@@ and customer address. Include units.'
    },
  
    { 
 +    'key'         => 'invoice_omit_due_date',
 +    'section'     => 'invoice_balances',
 +    'description' => 'Omit the "Please pay by (date)" from invoices.',
 +    'type'        => 'checkbox',
 +    'per_agent'   => 1,
 +  },
 +
 +  { 
      'key'         => 'invoice_sections',
      'section'     => 'invoicing',
      'description' => 'Split invoice into sections and label according to package category when enabled.',
  
    {
      'key'         => 'selfservice-payment_gateway',
-     'section'     => 'self-service',
-     'description' => 'Force the use of this payment gateway for self-service.',
+     'section'     => 'deprecated',
+     'description' => '(no longer supported) Force the use of this payment gateway for self-service.',
      %payment_gateway_options,
    },
  
    },
  
    {
 +    'key'         => 'selfservice-password_reset_hours',
 +    'section'     => 'self-service',
 +    'description' => 'Numbers of hours an email password reset is valid.  Defaults to 24.',
 +    'type'        => 'text',
 +  },
 +
 +  {
      'key'         => 'selfservice-password_reset_msgnum',
      'section'     => 'self-service',
      'description' => 'Template to use for password reset emails.',
diff --combined FS/FS/cust_main.pm
@@@ -2128,7 -2128,7 +2128,7 @@@ sub check_payinfo_cardtype 
    my $payinfo = $self->payinfo;
    $payinfo =~ s/\D//g;
  
-   return '' if $payinfo =~ /^99\d{14}$/; #token
+   return '' if $self->tokenized($payinfo); #token
  
    my %bop_card_types = map { $_=>1 } values %{ card_types() };
    my $cardtype = cardtype($payinfo);
@@@ -4679,6 -4679,10 +4679,10 @@@ CHEK onl
  
  CHEK only
  
+ =item saved_cust_payby
+ scalar reference, for returning saved object
  =back
  
  =cut
@@@ -4875,6 -4879,9 +4879,9 @@@ PAYBYLOOP
      return $error;
    }
  
+   ${$opt{'saved_cust_payby'}} = $new
+     if $opt{'saved_cust_payby'};
    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
    '';
  
@@@ -5777,92 -5784,13 +5784,94 @@@ sub _upgrade_data { #class metho
  
    $class->_upgrade_otaker(%opts);
  
 +  # turn on encryption as part of regular upgrade, so all new records are immediately encrypted
 +  # existing records will be encrypted in queueable_upgrade (below)
 +  unless ($conf->exists('encryptionpublickey') || $conf->exists('encryptionprivatekey')) {
 +    eval "use FS::Setup";
 +    die $@ if $@;
 +    FS::Setup::enable_encryption();
 +  }
 +
  }
  
  sub queueable_upgrade {
    my $class = shift;
 +
 +  ### encryption gets turned on in _upgrade_data, above
 +
 +  eval "use FS::upgrade_journal";
 +  die $@ if $@;
 +
 +  # prior to 2013 (commit f16665c9) payinfo was stored in history if not encrypted,
 +  # clear that out before encrypting/tokenizing anything else
 +  if (!FS::upgrade_journal->is_done('clear_payinfo_history')) {
 +    foreach my $table ('cust_payby','cust_pay_pending','cust_pay','cust_pay_void','cust_refund') {
 +      my $sql = 'UPDATE h_'.$table.' SET payinfo = NULL WHERE payinfo IS NOT NULL';
 +      my $sth = dbh->prepare($sql) or die dbh->errstr;
 +      $sth->execute or die $sth->errstr;
 +    }
 +    FS::upgrade_journal->set_done('clear_payinfo_history');
 +  }
 +
 +  # encrypt old records
 +  if ($conf->exists('encryption') && !FS::upgrade_journal->is_done('encryption_check')) {
 +
 +    # allow replacement of closed cust_pay/cust_refund records
 +    local $FS::payinfo_Mixin::allow_closed_replace = 1;
 +
 +    # because it looks like nothing's changing
 +    local $FS::Record::no_update_diff = 1;
 +
 +    # commit everything immediately
 +    local $FS::UID::AutoCommit = 1;
 +
 +    # encrypt what's there
 +    foreach my $table ('cust_payby','cust_pay_pending','cust_pay','cust_pay_void','cust_refund') {
 +      my $tclass = 'FS::'.$table;
 +      my $lastrecnum = 0;
 +      my @recnums = ();
 +      while (my $recnum = _upgrade_next_recnum(dbh,$table,\$lastrecnum,\@recnums)) {
 +        my $record = $tclass->by_key($recnum);
 +        next unless $record; # small chance it's been deleted, that's ok
 +        next unless grep { $record->payby eq $_ } @FS::Record::encrypt_payby;
 +        # window for possible conflict is practically nonexistant,
 +        #   but just in case...
 +        $record = $record->select_for_update;
 +        my $error = $record->replace;
 +        die $error if $error;
 +      }
 +    }
 +
 +    FS::upgrade_journal->set_done('encryption_check');
 +  }
 +
++  # now that everything's encrypted, tokenize...
+   FS::cust_main::Billing_Realtime::token_check(@_);
  }
  
 +# not entirely false laziness w/ Billing_Realtime::_token_check_next_recnum
 +# cust_payby might get deleted while this runs
 +# not a method!
 +sub _upgrade_next_recnum {
 +  my ($dbh,$table,$lastrecnum,$recnums) = @_;
 +  my $recnum = shift @$recnums;
 +  return $recnum if $recnum;
 +  my $tclass = 'FS::'.$table;
 +  my $sql = 'SELECT '.$tclass->primary_key.
 +            ' FROM '.$table.
 +            ' WHERE '.$tclass->primary_key.' > '.$$lastrecnum.
 +            ' ORDER BY '.$tclass->primary_key.' LIMIT 500';;
 +  my $sth = $dbh->prepare($sql) or die $dbh->errstr;
 +  $sth->execute() or die $sth->errstr;
 +  my @recnums;
 +  while (my $rec = $sth->fetchrow_hashref) {
 +    push @$recnums, $rec->{$tclass->primary_key};
 +  }
 +  $sth->finish();
 +  $$lastrecnum = $$recnums[-1];
 +  return shift @$recnums;
 +}
 +
  =back
  
  =head1 BUGS
diff --combined FS/FS/payinfo_Mixin.pm
@@@ -8,6 -8,7 +8,7 @@@ use FS::UID qw(driver_name)
  use FS::Cursor;
  use Time::Local qw(timelocal);
  
+ # allow_closed_replace only relevant to cust_pay/cust_refund, for upgrade tokenizing
  use vars qw( $ignore_masked_payinfo $allow_closed_replace );
  
  =head1 NAME
@@@ -67,8 -68,9 +68,9 @@@ sub payinfo 
    my($self,$payinfo) = @_;
  
    if ( defined($payinfo) ) {
+     $self->paymask($self->mask_payinfo) unless $self->getfield('paymask') || $self->tokenized; #make sure old mask is set
      $self->setfield('payinfo', $payinfo);
-     $self->paymask($self->mask_payinfo) unless $payinfo =~ /^99\d{14}$/; #token
+     $self->paymask($self->mask_payinfo) unless $self->tokenized($payinfo); #remask unless tokenizing
    } else {
      $self->getfield('payinfo');
    }
@@@ -129,7 -131,7 +131,7 @@@ sub mask_payinfo 
    # Check to see if it's encrypted...
    if ( ref($self) && $self->is_encrypted($payinfo) ) {
      return 'N/A';
-   } elsif ( $payinfo =~ /^99\d{14}$/ || $payinfo eq 'N/A' ) { #token
+   } elsif ( $self->tokenized($payinfo) || $payinfo eq 'N/A' ) { #token
      return 'N/A (tokenized)'; #?
    } else { # if not, mask it...
  
@@@ -197,7 -199,7 +199,7 @@@ sub payinfo_check 
  
      my $payinfo = $self->payinfo;
      my $cardtype = cardtype($payinfo);
-     $cardtype = 'Tokenized' if $payinfo =~ /^99\d{14}$/;
+     $cardtype = 'Tokenized' if $self->tokenized;
      $self->set('paycardtype', $cardtype);
  
      if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
          validate($self->payinfo) or return "Illegal credit card number";
          return "Unknown card type" if $cardtype eq "Unknown";
        } else {
-         $self->payinfo('N/A'); #???
+         $self->payinfo('N/A'); #??? re-masks card
        }
      }
    } else {
      }
    }
  
+   return '';
  }
  
  =item payby_payinfo_pretty [ LOCALE ]
@@@ -304,12 -307,13 +307,12 @@@ sub payinfo_used 
    my $payinfo = shift || $self->payinfo;
    my %hash = (
      'custnum' => $self->custnum,
 -    'payby'   => 'CARD',
 +    'payby'   => $self->payby,
    );
  
    return 1
    if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
 -  || qsearch('cust_pay', 
 -    { %hash, 'paymask' => $self->mask_payinfo('CARD', $payinfo) }  )
 +  || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo } )
    ;
  
    return 0;
@@@ -451,6 -455,21 +454,21 @@@ sub process_set_cardtype 
    }
  }
  
+ =item tokenized [ PAYINFO ]
+ Returns true if object payinfo is tokenized
+ Optionally, an arbitrary payby and payinfo can be passed.
+ =cut
+ sub tokenized {
+   my $self = shift;
+   my $payinfo = scalar(@_) ? shift : $self->payinfo;
+   return 0 unless $payinfo; #avoid uninitialized value error
+   $payinfo =~ /^99\d{14}$/;
+ }
  =back
  
  =head1 BUGS
@@@ -1,6 -1,6 +1,6 @@@
  <% include("/elements/header.html","$action payment gateway override for ". $agent->agent,  menubar(
    #'View all payment gateways' => $p. 'browse/payment_gateway.html',
-   'View all agents' => $p. 'browse/agent.html',
+   'View all agents' => $p. 'browse/agent.cgi',
  )) %>
  
  <% include('/elements/error.html') %>
@@@ -12,8 -12,6 +12,8 @@@ Use gateway <SELECT NAME="gatewaynum"
  % foreach my $payment_gateway (
  %      qsearch('payment_gateway', { 'disabled' => '' } )
  %    ) {
 +%   # don't let these be selected as agent overrides; there's a different mechanism
 +%   next if $payment_gateway->gateway_namespace eq 'Business::BatchPayment';
  %
  
    <OPTION VALUE="<% $payment_gateway->gatewaynum %>"><% $payment_gateway->gateway_module %> (<% $payment_gateway->gateway_username %>)
  </SELECT>
  <BR><BR>
  
- for <SELECT NAME="cardtype" MULTIPLE>
- % foreach my $cardtype (
- %  "",
- %  "VISA card",
- %  "MasterCard",
- %  "Discover card",
- %  "American Express card",
- %  "Diner's Club/Carte Blanche",
- %  "enRoute",
- %  "JCB",
- %  "BankCard",
- %  "Switch",
- %  "Solo",
- %  'ACH',
- %  'PayPal',
- %) { 
-   <OPTION VALUE="<% $cardtype %>"><% $cardtype || '(Default fallback)' %>
- % } 
- </SELECT>
- <BR><BR>
- (optional) when invoice contains only items of taxclass <INPUT TYPE="text" NAME="taxclass">
- <BR><BR>
  <INPUT TYPE="submit" VALUE="Add gateway override">
  </FORM>