[freeside-commits] branch FREESIDE_3_BRANCH_71513 created. e208512d4fef58a7dadf6a46e10bed24f5d777cb

Jonathan Prykop jonathan at 420.am
Mon Dec 5 13:47:58 PST 2016


The branch, FREESIDE_3_BRANCH_71513 has been created
        at  e208512d4fef58a7dadf6a46e10bed24f5d777cb (commit)

- Log -----------------------------------------------------------------
commit e208512d4fef58a7dadf6a46e10bed24f5d777cb
Author: Jonathan Prykop <jonathan at freeside.biz>
Date:   Mon Dec 5 15:45:57 2016 -0600

    71513: Card tokenization [v3 backport]

diff --git a/FS-Test/bin/freeside-test-stop b/FS-Test/bin/freeside-test-stop
index 5e221a8..ad355c3 100755
--- a/FS-Test/bin/freeside-test-stop
+++ b/FS-Test/bin/freeside-test-stop
@@ -22,7 +22,7 @@ if (sudo grep -q '^test:' /usr/local/etc/freeside/htpasswd); then
   oldhtpasswd=$( cd /usr/local/etc/freeside; \
                  ls |grep -P 'htpasswd_\d{8}' | \
                  sort -nr |head -1 )
-  if [ -f $oldhtpasswd ]; then
+  if [ -f /usr/local/etc/freeside/$oldhtpasswd ]; then
     echo "Renaming $oldhtpasswd to htpasswd."
     sudo mv /usr/local/etc/freeside/$oldhtpasswd \
       /usr/local/etc/freeside/htpasswd
diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm
index 3d01c0d..386a063 100644
--- a/FS/FS/ClientAPI/MyAccount.pm
+++ b/FS/FS/ClientAPI/MyAccount.pm
@@ -373,20 +373,12 @@ sub payment_gateway {
   my $conf = new FS::Conf;
   my $cust_main = shift;
   my $cust_payby = shift;
-  my $gatewaynum = $conf->config('selfservice-payment_gateway');
-  if ( $gatewaynum ) {
-    my $pg = qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
-    die "configured gatewaynum $gatewaynum not found!" if !$pg;
-    return $pg;
-  }
-  else {
-    return '' if ! FS::payby->realtime($cust_payby);
-    my $pg = $cust_main->agent->payment_gateway(
-      'method'  => FS::payby->payby2bop($cust_payby),
-      'nofatal' => 1
-    );
-    return $pg;
-  }
+  return '' if ! FS::payby->realtime($cust_payby);
+  my $pg = $cust_main->agent->payment_gateway(
+    'method'  => FS::payby->payby2bop($cust_payby),
+    'nofatal' => 1
+  );
+  return $pg;
 }
 
 sub access_info {
@@ -1030,7 +1022,7 @@ sub validate_payment {
     validate($payinfo)
       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
     return { 'error' => gettext('unknown_card_type') }
-      if $payinfo !~ /^99\d{14}$/ && cardtype($payinfo) eq "Unknown";
+      if !$cust_main->tokenized($payinfo) && cardtype($payinfo) eq "Unknown";
 
     if ( length($p->{'paycvv'}) && $p->{'paycvv'} !~ /^\s*$/ ) {
       if ( cardtype($payinfo) eq 'American Express card' ) {
diff --git a/FS/FS/ClientAPI/Signup.pm b/FS/FS/ClientAPI/Signup.pm
index 0a9813f..08da337 100644
--- a/FS/FS/ClientAPI/Signup.pm
+++ b/FS/FS/ClientAPI/Signup.pm
@@ -346,20 +346,11 @@ sub signup_info {
     my @paybys = @{ $signup_info->{'payby'} };
     $signup_info->{'hide_payment_fields'} = [];
 
-    my $gatewaynum = $conf->config('selfservice-payment_gateway');
-    my $force_gateway;
-    if ( $gatewaynum ) {
-      $force_gateway = qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
-      warn "using forced gateway #$gatewaynum - " .
-        $force_gateway->gateway_username . '@' . $force_gateway->gateway_module
-        if $DEBUG > 1;
-      die "configured gatewaynum $gatewaynum not found!" if !$force_gateway;
-    }
     foreach my $payby (@paybys) {
       warn "$me checking $payby payment fields\n" if $DEBUG > 1;
       my $hide = 0;
       if ( FS::payby->realtime($payby) ) {
-        my $gateway = $force_gateway || 
+        my $gateway = 
           $agent->payment_gateway( 'method'  => FS::payby->payby2bop($payby),
                                    'nofatal' => 1,
                                  );
@@ -622,17 +613,9 @@ sub new_customer {
     return { 'error' => "Unknown reseller" }
       unless $agent;
 
-    my $gw;
-    my $gatewaynum = $conf->config('selfservice-payment_gateway');
-    if ( $gatewaynum ) {
-      $gw = qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
-      die "configured gatewaynum $gatewaynum not found!" if !$gw;
-    }
-    else {
-      $gw = $agent->payment_gateway( 'method'  => FS::payby->payby2bop($payby),
-                                     'nofatal' => 1,
+    my $gw = $agent->payment_gateway( 'method'  => FS::payby->payby2bop($payby),
+                                      'nofatal' => 1,
                                     );
-    }
 
     $cust_main->payby('BILL')   # MCRD better?  no, that's for something else
       if $gw && $gw->gateway_namespace eq 'Business::OnlineThirdPartyPayment';
@@ -1124,36 +1107,28 @@ sub capture_payment {
 
   my $conf = new FS::Conf;
 
-  my $payment_gateway;
-  if ( my $gwnum = $conf->config('selfservice-payment_gateway') ) {
-    $payment_gateway = qsearchs('payment_gateway', { 'gatewaynum' => $gwnum })
-      or die "configured gatewaynum $gwnum not found!";
-  }
-  else {
-    my $url = $packet->{url};
-
-    $payment_gateway = qsearchs('payment_gateway', 
+  my $url = $packet->{url};
+  my $payment_gateway = qsearchs('payment_gateway', 
         { 'gateway_callback_url' => popurl(0, $url) } 
       );
-    if (!$payment_gateway) { 
-
-      my ( $processor, $login, $password, $action, @bop_options ) =
-        $conf->config('business-onlinepayment');
-      $action ||= 'normal authorization';
-      pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
-      die "No real-time processor is enabled - ".
-          "did you set the business-onlinepayment configuration value?\n"
-        unless $processor;
-
-      $payment_gateway = new FS::payment_gateway( {
-        gateway_namespace => $conf->config('business-onlinepayment-namespace'),
-        gateway_module    => $processor,
-        gateway_username  => $login,
-        gateway_password  => $password,
-        gateway_action    => $action,
-        options   => [ ( @bop_options ) ],
-      });
-    }
+  if (!$payment_gateway) { 
+
+    my ( $processor, $login, $password, $action, @bop_options ) =
+      $conf->config('business-onlinepayment');
+    $action ||= 'normal authorization';
+    pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
+    die "No real-time processor is enabled - ".
+        "did you set the business-onlinepayment configuration value?\n"
+      unless $processor;
+
+    $payment_gateway = new FS::payment_gateway( {
+      gateway_namespace => $conf->config('business-onlinepayment-namespace'),
+      gateway_module    => $processor,
+      gateway_username  => $login,
+      gateway_password  => $password,
+      gateway_action    => $action,
+      options   => [ ( @bop_options ) ],
+    });
   }
  
   die "No real-time third party processor is enabled - ".
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index 0d45ace..6ee7e3d 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -2349,8 +2349,8 @@ and customer address. Include units.',
 
   {
     '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,
   },
 
diff --git a/FS/FS/Cron/cleanup.pm b/FS/FS/Cron/cleanup.pm
index 6ec4013..9d0c067 100644
--- a/FS/FS/Cron/cleanup.pm
+++ b/FS/FS/Cron/cleanup.pm
@@ -8,12 +8,26 @@ use FS::Record qw( qsearch );
 
 # start janitor jobs
 sub cleanup {
-# fix locations that are missing coordinates
+  my %opt = @_;
+
+  # fix locations that are missing coordinates
   my $job = FS::queue->new({
       'job'     => 'FS::cust_location::process_set_coord',
       'status'  => 'new'
   });
   $job->insert('_JOB');
+
+  # check card number tokenization
+  $job = FS::queue->new({
+      'job'     => 'FS::cust_main::Billing_Realtime::token_check',
+      'status'  => 'new'
+  });
+  $job->insert(
+    %opt,
+    'queue' => 1,
+    'daily' => 1,
+  );
+
 }
 
 sub cleanup_before_backup {
diff --git a/FS/FS/Cron/tax_rate_update.pm b/FS/FS/Cron/tax_rate_update.pm
index b6ac63c..fec696f 100755
--- a/FS/FS/Cron/tax_rate_update.pm
+++ b/FS/FS/Cron/tax_rate_update.pm
@@ -31,7 +31,7 @@ sub tax_rate_update {
   my %opt = @_;
 
   my $oldAutoCommit = $FS::UID::AutoCommit;
-  $FS::UID::AutoCommit = 0;
+  local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   my $conf = FS::Conf->new;
diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm
index d06b7d8..506ff15 100644
--- a/FS/FS/Upgrade.pm
+++ b/FS/FS/Upgrade.pm
@@ -46,6 +46,10 @@ sub upgrade_config {
 
   my $conf = new FS::Conf;
 
+  # to simplify tokenization upgrades
+  die "Conf selfservice-payment_gateway no longer supported"
+    if $conf->config('selfservice-payment_gateway');
+
   $conf->touch('payment_receipt')
     if $conf->exists('payment_receipt_email')
     || $conf->config('payment_receipt_msgnum');
@@ -332,7 +336,11 @@ sub upgrade_data {
     #fix whitespace - before cust_main
     'cust_location' => [],
 
-    #cust_main (remove paycvv from history)
+    # need before cust_main tokenization upgrade,
+    # blocks tokenization upgrade if deprecated features still in use
+    'agent_payment_gateway' => [],
+
+    #cust_main (tokenizes cards, remove paycvv from history, locations, cust_payby, etc)
     'cust_main' => [],
 
     #msgcat
diff --git a/FS/FS/agent.pm b/FS/FS/agent.pm
index d6171c6..aad3f1c 100644
--- a/FS/FS/agent.pm
+++ b/FS/FS/agent.pm
@@ -9,6 +9,7 @@ use FS::cust_main;
 use FS::cust_pkg;
 use FS::agent_type;
 use FS::reg_code;
+use FS::payment_gateway;
 use FS::TicketSystem;
 use FS::Conf;
 
@@ -227,51 +228,42 @@ sub ticketing_queue {
 
 Returns a payment gateway object (see L<FS::payment_gateway>) for this agent.
 
-Currently available options are I<nofatal>, I<invnum>, I<method>, 
-I<payinfo>, and I<thirdparty>.
+Currently available options are I<nofatal>, I<method>, I<thirdparty> and I<conf>.
 
 If I<nofatal> is set, and no gateway is available, then the empty string
 will be returned instead of throwing a fatal exception.
 
-If I<invnum> is set to the number of an invoice (see L<FS::cust_bill>) then
-an attempt will be made to select a gateway suited for the taxes paid on 
-the invoice.
+The I<method> option can be used to influence the choice
+as well.  Presently only CHEK/ECHECK and PAYPAL methods are meaningful.
 
-The I<method> and I<payinfo> options can be used to influence the choice
-as well.  Presently only 'CC', 'ECHECK', and 'PAYPAL' methods are meaningful.
+If I<method> is CHEK/ECHECK and the default gateway is being returned,
+the business-onlinepayment-ach gateway will be returned if available.
 
-When the I<method> is 'CC' then the card number in I<payinfo> can direct
-this routine to route to a gateway suited for that type of card.
+If I<thirdparty> is set and the I<method> is PAYPAL, the defined paypal
+gateway will be returned.
 
-If I<thirdparty> is set, the defined self-service payment gateway will 
-be returned.
+Exisisting I<$conf> may be passed for efficiency.
 
 =cut
 
+# opts invnum/payinfo for cardtype/taxclass overrides no longer supported
+# any future overrides added here need to be reconciled with the tokenization process
+
 sub payment_gateway {
   my ( $self, %options ) = @_;
   
-  my $conf = new FS::Conf;
+  $options{'conf'} ||= new FS::Conf;
+  my $conf = $options{'conf'};
 
   if ( $options{thirdparty} ) {
-    # still a kludge, but it gets the job done
-    # and the 'cardtype' semantics don't really apply to thirdparty
-    # gateways because we have to choose a gateway without ever 
-    # seeing the card number
-    my $gatewaynum =
-      $conf->config('selfservice-payment_gateway', $self->agentnum);
-    my $gateway;
-    $gateway = FS::payment_gateway->by_key($gatewaynum) if $gatewaynum;
-    return $gateway if $gateway;
-
-    # a little less kludgey than the above, and allows PayPal to coexist 
-    # with credit card gateways
+
+    # allows PayPal to coexist with credit card gateways
     my $is_paypal = { op => '!=', value => 'PayPal' };
     if ( uc($options{method}) eq 'PAYPAL' ) {
       $is_paypal = 'PayPal';
     }
 
-    $gateway = qsearchs({
+    my $gateway = qsearchs({
         table     => 'payment_gateway',
         addl_from => ' JOIN agent_payment_gateway USING (gatewaynum) ',
         hashref   => {
@@ -291,105 +283,12 @@ sub payment_gateway {
     }
   }
 
-  my $taxclass = '';
-  if ( $options{invnum} ) {
-
-    my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{invnum} } );
-    die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
-
-    my @part_pkg =
-      map  { $_->part_pkg }
-      grep { $_ }
-      map  { $_->cust_pkg }
-      $cust_bill->cust_bill_pkg;
-
-    my @taxclasses = map $_->taxclass, @part_pkg;
-
-    $taxclass = $taxclasses[0]
-      unless grep { $taxclasses[0] ne $_ } @taxclasses; #unless there are
-                                                        #different taxclasses
-  }
-
-  #look for an agent gateway override first
-  my $cardtype = '';
-  if ( $options{method} ) {
-    if ( $options{method} eq 'CC' && $options{payinfo} ) {
-      $cardtype = cardtype($options{payinfo});
-    } elsif ( $options{method} eq 'ECHECK' ) {
-      $cardtype = 'ACH';
-    } else {
-      $cardtype = $options{method}
-    }
-  }
-
-  my $override =
-       qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => $cardtype,
-                                           taxclass => $taxclass,       } )
-    || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => '',
-                                           taxclass => $taxclass,       } )
-    || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => $cardtype,
-                                           taxclass => '',              } )
-    || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => '',
-                                           taxclass => '',              } );
-
-  my $payment_gateway;
-  if ( $override ) { #use a payment gateway override
-
-    $payment_gateway = $override->payment_gateway;
-
-    $payment_gateway->gateway_namespace('Business::OnlinePayment')
-      unless $payment_gateway->gateway_namespace;
-
-  } else { #use the standard settings from the config
-
-    # the standard settings from the config could be moved to a null agent
-    # agent_payment_gateway referenced payment_gateway
-
-    unless ( $conf->exists('business-onlinepayment') ) {
-      if ( $options{'nofatal'} ) {
-        return '';
-      } else {
-        die "Real-time processing not enabled\n";
-      }
-    }
-
-    #load up config
-    my $bop_config = 'business-onlinepayment';
-    $bop_config .= '-ach'
-      if ( $options{method}
-           && $options{method} =~ /^(ECHECK|CHEK)$/
-           && $conf->exists($bop_config. '-ach')
-         );
-    my ( $processor, $login, $password, $action, @bop_options ) =
-      $conf->config($bop_config);
-    $action ||= 'normal authorization';
-    pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
-    die "No real-time processor is enabled - ".
-        "did you set the business-onlinepayment configuration value?\n"
-      unless $processor;
-
-    $payment_gateway = new FS::payment_gateway;
-
-    $payment_gateway->gateway_namespace( $conf->config('business-onlinepayment-namespace') ||
-                                 'Business::OnlinePayment');
-    $payment_gateway->gateway_module($processor);
-    $payment_gateway->gateway_username($login);
-    $payment_gateway->gateway_password($password);
-    $payment_gateway->gateway_action($action);
-    $payment_gateway->set('options', [ @bop_options ]);
-
-  }
-
-  unless ( $payment_gateway->gateway_namespace ) {
-    $payment_gateway->gateway_namespace(
-      scalar($conf->config('business-onlinepayment-namespace'))
-      || 'Business::OnlinePayment'
-    );
-  }
+  my $override = qsearchs('agent_payment_gateway', { agentnum => $self->agentnum } );
+ 
+  my $payment_gateway = FS::payment_gateway->by_key_or_default(
+    gatewaynum => $override ? $override->gatewaynum : '',
+    %options,
+  );
 
   $payment_gateway;
 }
diff --git a/FS/FS/agent_payment_gateway.pm b/FS/FS/agent_payment_gateway.pm
index bd99d0c..d189b88 100644
--- a/FS/FS/agent_payment_gateway.pm
+++ b/FS/FS/agent_payment_gateway.pm
@@ -115,6 +115,21 @@ sub check {
   $self->SUPER::check;
 }
 
+sub _upgrade_data {
+  # to simplify tokenization upgrades
+  die "Agent taxclass override no longer supported"
+    if qsearch({
+      'table' => 'agent_payment_gateway',
+      'extra_sql' => ' WHERE taxclass IS NOT NULL AND taxclass != \'\'',
+    });
+  die "Agent cardtype override no longer supported"
+    if qsearch({
+      'table' => 'agent_payment_gateway',
+      'extra_sql' => ' WHERE cardtype IS NOT NULL AND cardtype != \'\'',
+    });
+  return '';
+}
+
 =item payment_gateway
 
 =cut
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 0165bc4..33dab90 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -463,10 +463,11 @@ sub insert {
     if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
 
   my $error =  $self->check_payinfo_cardtype
+            || $self->check             # needed now for tokenize
+            || $self->realtime_tokenize # needs to happen before initial insert
             || $self->SUPER::insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
-    #return "inserting cust_main record (transaction rolled back): $error";
     return $error;
   }
 
@@ -1555,24 +1556,26 @@ sub replace {
     my $error = $self->check_payinfo_cardtype;
     return $error if $error;
 
-    if ( $conf->exists('business-onlinepayment-verification') ) {
-      #need to standardize paydate for this, false laziness with check
-      my( $m, $y );
-      if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
-        ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
-      } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
-        ( $m, $y ) = ( $2, "19$1" );
-      } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
-        ( $m, $y ) = ( $3, "20$2" );
-      } else {
-        return "Illegal expiration date: ". $self->paydate;
-      }
-      $m = sprintf('%02d',$m);
-      $self->paydate("$y-$m-01");
+    #need to standardize paydate for this, false laziness with check
+    my( $m, $y );
+    if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
+      ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
+    } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
+      ( $m, $y ) = ( $2, "19$1" );
+    } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
+      ( $m, $y ) = ( $3, "20$2" );
+    } else {
+      return "Illegal expiration date: ". $self->paydate;
+    }
+    $m = sprintf('%02d',$m);
+    $self->paydate("$y-$m-01");
 
+    if ( $conf->exists('business-onlinepayment-verification') ) {
       $error = $self->realtime_verify_bop({ 'method'=>'CC' });
-      return $error if $error;
+    } else {
+      $error = $self->realtime_tokenize;
     }
+    return $error if $error;
   }
 
   return "Invoicing locale is required"
@@ -1712,7 +1715,7 @@ sub replace {
 
   if ( $self->payby =~ /^(CARD|CHEK|LECB)$/
        && ( ( $self->get('payinfo') ne $old->get('payinfo')
-              && $self->get('payinfo') !~ /^99\d{14}$/ 
+              && !$self->tokenized
             )
             || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
           )
@@ -1969,7 +1972,7 @@ sub check {
       or return gettext('invalid_card'); # . ": ". $self->payinfo;
 
     my $cardtype = cardtype($payinfo);
-    $cardtype = 'Tokenized' if $self->payinfo =~ /^99\d{14}$/; # token
+    $cardtype = 'Tokenized' if $self->tokenized; # token
 
     return gettext('unknown_card_type') if $cardtype eq 'Unknown';
 
@@ -2185,7 +2188,7 @@ sub check_payinfo_cardtype {
   my $payinfo = $self->payinfo;
   $payinfo =~ s/\D//g;
 
-  if ( $payinfo =~ /^99\d{14}$/ ) {
+  if ( $self->tokenized($payinfo) ) {
     $self->set('paycardtype', 'Tokenized');
     return '';
   }
@@ -5736,6 +5739,11 @@ sub _upgrade_data { #class method
 
 }
 
+sub queueable_upgrade {
+  my $class = shift;
+  FS::cust_main::Billing_Realtime::token_check(@_);
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm
index bc98b88..40e7097 100644
--- a/FS/FS/cust_main/Billing_Realtime.pm
+++ b/FS/FS/cust_main/Billing_Realtime.pm
@@ -14,6 +14,7 @@ use FS::cust_pay_pending;
 use FS::cust_bill_pay;
 use FS::cust_refund;
 use FS::banned_pay;
+use FS::payment_gateway;
 
 $realtime_bop_decline_quiet = 0;
 
@@ -188,17 +189,10 @@ sub _bop_recurring_billing {
 
 }
 
+#can run safely as class method if opt payment_gateway already exists
 sub _payment_gateway {
   my ($self, $options) = @_;
 
-  if ( $options->{'selfservice'} ) {
-    my $gatewaynum = FS::Conf->new->config('selfservice-payment_gateway');
-    if ( $gatewaynum ) {
-      return $options->{payment_gateway} ||= 
-          qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
-    }
-  }
-
   if ( $options->{'fake_gatewaynum'} ) {
 	$options->{payment_gateway} =
 	    qsearchs('payment_gateway',
@@ -212,8 +206,9 @@ sub _payment_gateway {
   $options->{payment_gateway};
 }
 
+# not a method!!!
 sub _bop_auth {
-  my ($self, $options) = @_;
+  my ($options) = @_;
 
   (
     'login'    => $options->{payment_gateway}->gateway_username,
@@ -221,8 +216,9 @@ sub _bop_auth {
   );
 }
 
+### not a method!
 sub _bop_options {
-  my ($self, $options) = @_;
+  my ($options) = @_;
 
   $options->{payment_gateway}->gatewaynum
     ? $options->{payment_gateway}->options
@@ -260,11 +256,13 @@ sub _bop_defaults {
   $options->{payname} = $self->payname unless exists( $options->{payname} );
 }
 
+# can be called as class method,
+# but can't load default fields as class method
 sub _bop_content {
   my ($self, $options) = @_;
   my %content = ();
 
-  my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip;
+  my $payip = exists($options->{'payip'}) ? $options->{'payip'} : (ref($self) ? $self->payip : '');
   $content{customer_ip} = $payip if length($payip);
 
   $content{invoice_number} = $options->{'invnum'}
@@ -280,43 +278,45 @@ sub _bop_content {
       /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
       or return "Illegal payname $payname";
     ($payfirst, $paylast) = ($1, $2);
-  } else {
+  } elsif (ref($self)) { # can't set payname if called as class method
     $payfirst = $self->getfield('first');
     $paylast = $self->getfield('last');
     $payname = "$payfirst $paylast";
   }
 
-  $content{last_name} = $paylast;
-  $content{first_name} = $payfirst;
+  $content{last_name} = $paylast if $paylast;
+  $content{first_name} = $payfirst if $payfirst;
 
-  $content{name} = $payname;
+  $content{name} = $payname if $payname;
 
   $content{address} = exists($options->{'address1'})
                         ? $options->{'address1'}
-                        : $self->address1;
+                        : (ref($self) ? $self->address1 : '');
   my $address2 = exists($options->{'address2'})
                    ? $options->{'address2'}
-                   : $self->address2;
+                   : (ref($self) ? $self->address2 : '');
   $content{address} .= ", ". $address2 if length($address2);
 
   $content{city} = exists($options->{city})
                      ? $options->{city}
-                     : $self->city;
+                     : (ref($self) ? $self->city : '');
   $content{state} = exists($options->{state})
                       ? $options->{state}
-                      : $self->state;
+                      : (ref($self) ? $self->state : '');
   $content{zip} = exists($options->{zip})
                     ? $options->{'zip'}
-                    : $self->zip;
+                    : (ref($self) ? $self->zip : '');
   $content{country} = exists($options->{country})
                         ? $options->{country}
-                        : $self->country;
+                        : (ref($self) ? $self->country : '');
 
   #3.0 is a good a time as any to get rid of this... add a config to pass it
   # if anyone still needs it
   #$content{referer} = 'http://cleanwhisker.420.am/';
 
-  $content{phone} = $self->daytime || $self->night;
+  # can't set phone if called as class method
+  $content{phone} = $self->daytime || $self->night
+    if ref($self);
 
   my $currency =    $conf->exists('business-onlinepayment-currency')
                  && $conf->config('business-onlinepayment-currency');
@@ -325,6 +325,22 @@ sub _bop_content {
   \%content;
 }
 
+# updates payinfo option & cust_main with token from transaction
+# can be called as a class method
+sub _tokenize_card {
+  my ($self,$transaction,$options) = @_;
+  if ( $transaction->can('card_token') 
+       and $transaction->card_token 
+       and !FS::payinfo_Mixin->tokenized($options->{'payinfo'})
+  ) {
+    $self->payinfo($transaction->card_token)
+      if ref($self) && $self->payinfo eq $options->{'payinfo'};
+    $options->{'payinfo'} = $transaction->card_token;
+    return $transaction->card_token;
+  }
+  return '';
+}
+
 my %bop_method2payby = (
   'CC'     => 'CARD',
   'ECHECK' => 'CHEK',
@@ -335,6 +351,8 @@ sub realtime_bop {
   my $self = shift;
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+  my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_bop');
  
   my %options = ();
   if (ref($_[0]) eq 'HASH') {
@@ -385,6 +403,19 @@ sub realtime_bop {
 
   $self->_bop_defaults(\%options);
 
+  # possibly run a separate transaction to tokenize card number,
+  #   so that we never store tokenized card info in cust_pay_pending
+  if (($options{method} eq 'CC') && !$self->tokenized($options{'payinfo'})) {
+    my $save_token = ( $options{'payinfo'} eq $self->payinfo ) ? 1 : 0; 
+    my $token_error = $self->realtime_tokenize(\%options);
+    return $token_error if $token_error;
+    if ( $save_token && $self->tokenized($options{'payinfo'}) ) {
+      $self->payinfo($options{'payinfo'});
+      $token_error = $self->replace;
+      return $token_error if $token_error;
+    }
+  }
+
   ###
   # set trans_is_recur based on invnum if there is one
   ###
@@ -628,12 +659,12 @@ sub realtime_bop {
     split( /\s*\,\s*/, $payment_gateway->gateway_action );
 
   my $transaction = new $namespace( $payment_gateway->gateway_module,
-                                    $self->_bop_options(\%options),
+                                    _bop_options(\%options),
                                   );
 
   $transaction->content(
     'type'           => $options{method},
-    $self->_bop_auth(\%options),          
+    _bop_auth(\%options),          
     'action'         => $action1,
     'description'    => $options{'description'},
     'amount'         => $options{amount},
@@ -688,14 +719,14 @@ sub realtime_bop {
 
     my $capture =
       new Business::OnlinePayment( $payment_gateway->gateway_module,
-                                   $self->_bop_options(\%options),
+                                   _bop_options(\%options),
                                  );
 
     my %capture = (
       %content,
       type           => $options{method},
       action         => $action2,
-      $self->_bop_auth(\%options),          
+      _bop_auth(\%options),          
       order_number   => $ordernum,
       amount         => $options{amount},
       authorization  => $auth,
@@ -736,6 +767,8 @@ sub realtime_bop {
   ) {
     my $error = $self->remove_cvv;
     if ( $error ) {
+      $log->critical('Error removing cvv for cust '.$self->custnum.': '.$error);
+      #not returning error, should at least attempt to handle results of an otherwise valid transaction
       warn "WARNING: error removing cvv: $error\n";
     }
   }
@@ -744,17 +777,19 @@ sub realtime_bop {
   # Tokenize
   ###
 
-
+  # This block will only run if the B::OP module supports card_token but not the Tokenize transaction;
+  #   if that never happens, we should get rid of it (as it has the potential to store real card numbers on error)
   if ( $transaction->can('card_token') && $transaction->card_token ) {
-
     if ( $options{'payinfo'} eq $self->payinfo ) {
       $self->payinfo($transaction->card_token);
       my $error = $self->replace;
       if ( $error ) {
+        $log->critical('Error storing token for cust '.$self->custnum.': '.$error);
+        #not returning error, should at least attempt to handle results of an otherwise valid transaction
+        #this leaves real card number in cust_main, but can't do much else if cust_main won't replace
         warn "WARNING: error storing token: $error, but proceeding anyway\n";
       }
     }
-
   }
 
   ###
@@ -792,9 +827,7 @@ sub fake_bop {
      'paid'     => $options{amount},
      '_date'    => '',
      'payby'    => $bop_method2payby{$options{method}},
-     #'payinfo'  => $payinfo,
      'payinfo'  => '4111111111111111',
-     #'paydate'  => $paydate,
      'paydate'  => '2012-05-01',
      'processor'      => 'FakeProcessor',
      'auth'           => '54',
@@ -854,7 +887,7 @@ sub _realtime_bop_result {
     or return "no payment gateway in arguments to _realtime_bop_result";
 
   $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
-  my $cpp_captured_err = $cust_pay_pending->replace;
+  my $cpp_captured_err = $cust_pay_pending->replace; #also saves post-transaction tokenization, if that happens
   return $cpp_captured_err if $cpp_captured_err;
 
   if ( $transaction->is_success() ) {
@@ -1237,14 +1270,14 @@ sub realtime_botpp_capture {
 
   my $transaction =
     new Business::OnlineThirdPartyPayment( $payment_gateway->gateway_module,
-                                           $self->_bop_options(\%options),
+                                           _bop_options(\%options),
                                          );
 
   $transaction->reference({ %options }); 
 
   $transaction->content(
     'type'           => $method,
-    $self->_bop_auth(\%options),
+    _bop_auth(\%options),
     'action'         => 'Post Authorization',
     'description'    => $options{'description'},
     'amount'         => $cust_pay_pending->paid,
@@ -1455,10 +1488,12 @@ sub realtime_refund_bop {
       @bop_options = $payment_gateway->gatewaynum
                        ? $payment_gateway->options
                        : @{ $payment_gateway->get('options') };
+      my %bop_options = @bop_options;
 
       return "processor of payment $options{'paynum'} $processor does not".
              " match default processor $conf_processor"
-        unless $processor eq $conf_processor;
+        unless ($processor eq $conf_processor)
+            || (($conf_processor eq 'CardFortress') && ($processor eq $bop_options{'gateway'}));
 
     }
 
@@ -1467,9 +1502,7 @@ sub realtime_refund_bop {
            # like a normal transaction 
  
     my $payment_gateway =
-      $self->agent->payment_gateway( 'method'  => $options{method},
-                                     #'payinfo' => $payinfo,
-                                   );
+      $self->agent->payment_gateway( 'method'  => $options{method} );
     my( $processor, $login, $password, $namespace ) =
       map { my $method = "gateway_$_"; $payment_gateway->$method }
         qw( module username password namespace );
@@ -1601,15 +1634,18 @@ sub realtime_refund_bop {
     if length($payip);
 
   my $payinfo = '';
+  my $paymask = ''; # for refund record
   if ( $options{method} eq 'CC' ) {
 
     if ( $cust_pay ) {
       $content{card_number} = $payinfo = $cust_pay->payinfo;
+      $paymask = $cust_pay->paymask;
       (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
         =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
         ($content{expiration} = "$2/$1");  # where available
     } else {
       $content{card_number} = $payinfo = $self->payinfo;
+      $paymask = $self->paymask;
       (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
         =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
       $content{expiration} = "$2/$1";
@@ -1676,6 +1712,7 @@ sub realtime_refund_bop {
     '_date'    => '',
     'payby'    => $bop_method2payby{$options{method}},
     'payinfo'  => $payinfo,
+    'paymask'  => $paymask,
     'reasonnum'   => $reason->reasonnum,
     'gatewaynum'    => $gatewaynum, # may be null
     'processor'     => $processor,
@@ -1745,6 +1782,15 @@ sub realtime_verify_bop {
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
+  # possibly run a separate transaction to tokenize card number,
+  #   so that we never store tokenized card info in cust_pay_pending
+  if (($options{method} eq 'CC') && !$self->tokenized($options{'payinfo'})) {
+    my $token_error = $self->realtime_tokenize(\%options);
+    return $token_error if $token_error;
+    #important that we not replace cust_main here,
+    #because cust_main->replace uses realtime_verify_bop!
+  }
+
   ###
   # select a gateway
   ###
@@ -1820,17 +1866,16 @@ sub realtime_verify_bop {
       $content{issue_number} = $payissue if $payissue;
 
     } elsif ( $options{method} eq 'ECHECK' ){
-
-      #nop for checks (though it shouldn't be called...)
-
+      #cannot verify, move along (though it shouldn't be called...)
+      return '';
     } else {
-      die "unknown method ". $options{method};
+      return "unknown method ". $options{method};
     }
-
   } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
-    #move along
+    #cannot verify, move along
+    return '';
   } else {
-    die "unknown namespace $namespace";
+    return "unknown namespace $namespace";
   }
 
   ###
@@ -1839,6 +1884,7 @@ sub realtime_verify_bop {
 
   my $error;
   my $transaction; #need this back so we can do _tokenize_card
+
   # don't mutex the customer here, because they might be uncommitted. and
   # this is only verification. it doesn't matter if they have other
   # unfinished verifications.
@@ -1851,12 +1897,10 @@ sub realtime_verify_bop {
     'payinfo'           => $options{payinfo} || $self->payinfo,
     'paymask'           => $options{paymask} || $self->paymask,
     'paydate'           => $paydate,
-    #'recurring_billing' => $content{recurring_billing},
     'pkgnum'            => $options{'pkgnum'},
     'status'            => 'new',
     'gatewaynum'        => $payment_gateway->gatewaynum || '',
     'session_id'        => $options{session_id} || '',
-    #'jobnum'            => $options{depend_jobnum} || '',
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
@@ -1888,21 +1932,18 @@ sub realtime_verify_bop {
     warn Dumper($cust_pay_pending) if $DEBUG > 2;
 
     $transaction = new $namespace( $payment_gateway->gateway_module,
-                                   $self->_bop_options(\%options),
+                                   _bop_options(\%options),
                                     );
 
     $transaction->content(
       'type'           => 'CC',
-      $self->_bop_auth(\%options),          
+      _bop_auth(\%options),          
       'action'         => 'Authorization Only',
       'description'    => $options{'description'},
       'amount'         => '1.00',
-      #'invoice_number' => $options{'invnum'},
       'customer_id'    => $self->custnum,
       %$bop_content,
       'reference'      => $cust_pay_pending->paypendingnum, #for now
-      'callback_url'   => $payment_gateway->gateway_callback_url,
-      'cancel_url'     => $payment_gateway->gateway_cancel_url,
       'email'          => $email,
       %content, #after
     );
@@ -1939,11 +1980,11 @@ sub realtime_verify_bop {
                      : '';
 
       my $reverse = new $namespace( $payment_gateway->gateway_module,
-                                    $self->_bop_options(\%options),
+                                    _bop_options(\%options),
                                   );
 
       $reverse->content( 'action'        => 'Reverse Authorization',
-                         $self->_bop_auth(\%options),          
+                         _bop_auth(\%options),          
 
                          # B:OP
                          'amount'        => '1.00',
@@ -2112,23 +2153,26 @@ sub realtime_verify_bop {
   }
 
   ###
-  # Tokenize
+  # remove paycvv here?  need to find out if a reversed auth
+  #   counts as an initial transaction for paycvv retention requirements
   ###
 
-  if ( $transaction->can('card_token') && $transaction->card_token ) {
-
-    if ( $options{'payinfo'} eq $self->payinfo ) {
-      $self->payinfo($transaction->card_token);
-      my $error = $self->replace;
-      if ( $error ) {
-        my $warning = "WARNING: error storing token: $error, but proceeding anyway\n";
-        $log->warning($warning);
-        warn $warning;
-      }
-    }
+  ###
+  # Tokenize
+  ###
 
+  # This block will only run if the B::OP module supports card_token but not the Tokenize transaction;
+  #   if that never happens, we should get rid of it (as it has the potential to store real card numbers on error)
+  if (my $card_token = $self->_tokenize_card($transaction,\%options)) {
+    $cust_pay_pending->payinfo($card_token);
+    my $cpp_token_err = $cust_pay_pending->replace;
+    #this leaves real card number in cust_pay_pending, but can't do much else if cpp won't replace
+    return $cpp_token_err if $cpp_token_err;
+    #important that we not replace cust_main here,
+    #because cust_main->replace uses realtime_verify_bop!
   }
 
+
   ###
   # result handling
   ###
@@ -2139,12 +2183,569 @@ sub realtime_verify_bop {
 
 }
 
+=item realtime_tokenize [ OPTION => VALUE ... ]
+
+If possible and necessary, runs a tokenize transaction.
+In order to be possible, a credit card 
+and a Business::OnlinePayment gateway capable
+of Tokenize transactions must be configured for this user.
+Is only necessary if payinfo is not yet tokenized.
+
+Returns the empty string if the authorization was sucessful
+or was not possible/necessary (thus allowing this to be safely called with
+non-tokenizable records/gateways, without having to perform separate tests),
+or an error message otherwise.
+
+Customer object payinfo will be tokenized if possible, but that change will not be
+updated in database (must be inserted/replaced afterwards.)
+
+Otherwise, options I<method>, I<payinfo> and other cust_payby fields
+may be passed.  If options are passed as a hashref, I<payinfo>
+will be updated as appropriate in the passed hashref.  Customer
+object will only be updated if passed payinfo matches customer payinfo.
+
+Can be run as a class method if option I<payment_gateway> is passed,
+but default customer info can't be set in that case.  This
+is really only intended for tokenizing old records on upgrade.
+
+=cut
+
+# careful--might be run as a class method
+sub realtime_tokenize {
+  my $self = shift;
+
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+  my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_tokenize');
+
+  my %options = ();
+  my $outoptions; #for returning payinfo
+  if (ref($_[0]) eq 'HASH') {
+    %options = %{$_[0]};
+    $outoptions = $_[0];
+  } else {
+    %options = @_;
+    $outoptions = \%options;
+  }
+
+  # set fields from passed cust_main
+  unless ($options{'payinfo'}) {
+    $options{'method'}  = FS::payby->payby2bop( $self->payby );
+    $options{$_} = $self->$_() 
+      for qw( payinfo paycvv paymask paystart_month paystart_year paydate
+              payissue payname paystate paytype payip );
+    $outoptions->{'payinfo'} = $options{'payinfo'};
+  }
+  return '' unless $options{method} eq 'CC';
+  return '' if FS::payinfo_Mixin->tokenized($options{payinfo}); #already tokenized
+
+  ###
+  # select a gateway
+  ###
+
+  $options{'nofatal'} = 1;
+  my $payment_gateway =  $self->_payment_gateway( \%options );
+  return '' unless $payment_gateway;
+  my $namespace = $payment_gateway->gateway_namespace;
+  return '' unless $namespace eq 'Business::OnlinePayment';
+
+  eval "use $namespace";  
+  return $@ if $@;
+
+  ###
+  # check for tokenize ability
+  ###
+
+  my $transaction = new $namespace( $payment_gateway->gateway_module,
+                                    _bop_options(\%options),
+                                  );
+
+  return '' unless $transaction->can('info');
+
+  my %supported_actions = $transaction->info('supported_actions');
+  return '' unless $supported_actions{'CC'} and grep(/^Tokenize$/,@{$supported_actions{'CC'}});
+
+  ###
+  # check for banned credit card/ACH
+  ###
+
+  my $ban = FS::banned_pay->ban_search(
+    'payby'   => $bop_method2payby{'CC'},
+    'payinfo' => $options{payinfo},
+  );
+  return "Banned credit card" if $ban && $ban->bantype ne 'warn';
+
+  ###
+  # massage data
+  ###
+
+  ### Currently, cardfortress only keys in on card number and exp date.
+  ### We pass everything we'd pass to a normal transaction,
+  ### for ease of current and future development,
+  ### but note, when tokenizing old records, we may only have access to payinfo/paydate
+
+  my $bop_content = $self->_bop_content(\%options);
+  return $bop_content unless ref($bop_content);
+
+  my $paydate = '';
+  my %content = ();
+
+  $content{card_number} = $options{payinfo};
+  $paydate = $options{'paydate'};
+  $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+  $content{expiration} = "$2/$1";
+
+  $content{cvv2} = $options{'paycvv'}
+    if length($options{'paycvv'});
+
+  my $paystart_month = $options{'paystart_month'};
+  my $paystart_year  = $options{'paystart_year'};
+
+  $content{card_start} = "$paystart_month/$paystart_year"
+    if $paystart_month && $paystart_year;
+
+  my $payissue       = $options{'payissue'};
+  $content{issue_number} = $payissue if $payissue;
+
+  $content{customer_id} = $self->custnum
+    if ref($self);
+
+  ###
+  # run transaction
+  ###
+
+  my $error;
+
+  # no cust_pay_pending---this is not a financial transaction
+
+  $transaction->content(
+    'type'           => 'CC',
+    _bop_auth(\%options),          
+    'action'         => 'Tokenize',
+    'description'    => $options{'description'},
+    %$bop_content,
+    %content, #after
+  );
+
+  # no $BOP_TESTING handling for this
+  $transaction->test_transaction(1)
+    if $conf->exists('business-onlinepayment-test_transaction');
+  $transaction->submit();
+
+  if ( $transaction->card_token() ) { # no is_success flag
+
+    # realtime_tokenize should not clear paycvv at this time.  it might be
+    # needed for the first transaction, and a tokenize isn't actually a
+    # transaction that hits the gateway.  at some point in the future, card
+    # fortress should take on the "store paycvv until first transaction"
+    # functionality and we should fix this in freeside, but i that's a bigger
+    # project for another time.
+
+    #important that we not replace cust_main here, 
+    #because cust_main->replace uses realtime_tokenize!
+    $self->_tokenize_card($transaction,$outoptions);
+
+  } else {
+
+    $error = $transaction->error_message || 'Unknown error when tokenizing card';
+
+  }
+
+  return $error;
+
+}
+
+=item token_check [ quiet => 1, queue => 1, daily => 1 ]
+
+NOT A METHOD.  Acts on all customers.  Placed here because it makes
+use of module-internal methods, and to keep everything that uses
+Billing::OnlinePayment all in one place.
+
+Tokenizes all tokenizable card numbers from payinfo in cust_main and 
+CARD transactions in cust_pay_pending, cust_pay, cust_pay_void and cust_refund.
+
+If the I<queue> flag is set, newly tokenized records will be immediately
+committed, regardless of AutoCommit, so as to release the mutex on the record.
+
+If all configured gateways have the ability to tokenize, detection of an 
+untokenizable record will cause a fatal error.  However, if the I<queue> flag 
+is set, this will instead cause a critical error to be recorded in the log, 
+and any other tokenizable records will still be committed.
+
+If the I<daily> flag is also set, detection of existing untokenized records will 
+record a critical error in the system log (because they should have never appeared 
+in the first place.)  Tokenization will still be attempted.
+
+If any configured gateways do NOT have the ability to tokenize, or if a
+default gateway is not configured, then untokenized records are not considered 
+a threat, and no critical errors will be generated in the log.
+
+=cut
+
+sub token_check {
+  #acts on all customers
+  my %opt = @_;
+  my $debug = !$opt{'quiet'} || $DEBUG;
+
+  warn "token_check called with opts\n".Dumper(\%opt) if $debug;
+
+  # force some explicitness when invoking this method
+  die "token_check must run with queue flag if run with daily flag"
+    if $opt{'daily'} && !$opt{'queue'};
+
+  my $conf = FS::Conf->new;
+
+  my $log = FS::Log->new('FS::cust_main::Billing_Realtime::token_check');
+
+  my $cache = {}; #cache for module info
+
+  # look for a gateway that can't tokenize
+  my $require_tokenized = 1;
+  foreach my $gateway (
+    FS::payment_gateway->all_gateways(
+      'method'  => 'CC',
+      'conf'    => $conf,
+      'nofatal' => 1,
+    )
+  ) {
+    if (!$gateway) {
+      # no default gateway, no promise to tokenize
+      # can just load other gateways as-needeed below
+      $require_tokenized = 0;
+      last;
+    }
+    my $info = _token_check_gateway_info($cache,$gateway);
+    die $info unless ref($info); # means it's an error message
+    unless ($info->{'can_tokenize'}) {
+      # a configured gateway can't tokenize, that's all we need to know right now
+      # can just load other gateways as-needeed below
+      $require_tokenized = 0;
+      last;
+    }
+  }
+
+  warn "REQUIRE TOKENIZED" if $require_tokenized && $debug;
+
+  # upgrade does not call this with autocommit turned on,
+  # and autocommit will be ignored if opt queue is set,
+  # but might as well be thorough...
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  # for retrieving data in chunks
+  my $step = 500;
+  my $offset = 0;
+
+  ### Tokenize cust_main
+
+  my @recnums;
+
+  while (my $custnum = _token_check_next_recnum($dbh,'cust_main',$step,\$offset,\@recnums)) {
+    my $cust_main = FS::cust_main->by_key($custnum);
+    next unless $cust_main->payby =~ /^(CARD|DCRD)$/;
+
+    # see if it's already tokenized
+    if ($cust_main->tokenized) {
+      warn "cust_main ".$cust_main->custnum." already tokenized" if $debug;
+      next;
+    }
+
+    if ($require_tokenized && $opt{'daily'}) {
+      $log->critical("Untokenized card number detected in cust_main ".$cust_main->custnum);
+      $dbh->commit or die $dbh->errstr; # commit log message
+    }
+
+    # load gateway
+    my $payment_gateway = $cust_main->_payment_gateway({
+      'method'  => 'CC',
+      'conf'    => $conf,
+      'nofatal' => 1, # handle lack of gateway smoothly below
+    });
+    unless ($payment_gateway) {
+      # no reason to have untokenized card numbers saved if no gateway,
+      #   but only a problem if we expected everyone to tokenize card numbers
+      unless ($require_tokenized) {
+        warn "Skipping cust_payby for cust_main ".$cust_main->custnum.", no payment gateway" if $debug;
+        next;
+      }
+      my $error = "No gateway found for custnum ".$cust_main->custnum;
+      if ($opt{'queue'}) {
+        $log->critical($error);
+        $dbh->commit or die $dbh->errstr; # commit error message
+        next;
+      }
+      $dbh->rollback if $oldAutoCommit;
+      die $error;
+    }
+
+    my $info = _token_check_gateway_info($cache,$payment_gateway);
+    unless (ref($info)) {
+      # only throws error if Business::OnlinePayment won't load,
+      #   which is just cause to abort this whole process, even if queue
+      $dbh->rollback if $oldAutoCommit;
+      die $info; # error message
+    }
+    # no fail here--a configured gateway can't tokenize, so be it
+    unless ($info->{'can_tokenize'}) {
+      warn "Skipping ".$cust_main->custnum." cannot tokenize" if $debug;
+      next;
+    }
+
+    # time to tokenize
+    $cust_main = $cust_main->select_for_update;
+    my %tokenopts = (
+      'payment_gateway' => $payment_gateway,
+    );
+    my $error = $cust_main->realtime_tokenize(\%tokenopts);
+    if ($cust_main->tokenized) { # implies no error
+      $error = $cust_main->replace;
+    } else {
+      $error ||= 'Unknown error';
+    }
+    if ($error) {
+      $error = "Error tokenizing cust_main ".$cust_main->custnum.": ".$error;
+      if ($opt{'queue'}) {
+        $log->critical($error);
+        $dbh->commit or die $dbh->errstr; # commit log message, release mutex
+        next;
+      }
+      $dbh->rollback if $oldAutoCommit;
+      die $error;
+    }
+    $dbh->commit or die $dbh->errstr if $opt{'queue'}; # release mutex
+    warn "TOKENIZED cust_main ".$cust_main->custnum if $debug;
+  }
+
+  ### Tokenize/mask transaction tables
+
+  # allow tokenization of closed cust_pay/cust_refund records
+  local $FS::payinfo_Mixin::allow_closed_replace = 1;
+
+  # grep assistance:
+  #   $cust_pay_pending->replace, $cust_pay->replace, $cust_pay_void->replace, $cust_refund->replace all run here
+  foreach my $table ( qw(cust_pay_pending cust_pay cust_pay_void cust_refund) ) {
+    warn "Checking $table" if $debug;
+
+    # FS::Cursor does not seem to work over multiple commits (gives cursor not found errors)
+    # loading only record ids, then loading individual records one at a time
+    my $tclass = 'FS::'.$table;
+    $offset = 0;
+    @recnums = ();
+
+    while (my $recnum = _token_check_next_recnum($dbh,$table,$step,\$offset,\@recnums)) {
+      my $record = $tclass->by_key($recnum);
+      if (FS::payinfo_Mixin->tokenized($record->payinfo)) {
+        warn "Skipping tokenized record for $table ".$record->get($record->primary_key) if $debug;
+        next;
+      }
+      if (!$record->payinfo) { #shouldn't happen, but at least it's not a card number
+        warn "Skipping blank payinfo for $table ".$record->get($record->primary_key) if $debug;
+        next;
+      }
+      if ($record->payinfo =~ /N\/A/) { # ??? Not sure why we do this, but it's not a card number
+        warn "Skipping NA payinfo for $table ".$record->get($record->primary_key) if $debug;
+        next;
+      }
+
+      if ($require_tokenized && $opt{'daily'}) {
+        $log->critical("Untokenized card number detected in $table ".$record->get($record->primary_key));
+        $dbh->commit or die $dbh->errstr; # commit log message
+      }
+
+      my $cust_main = $record->cust_main;
+      if (!$cust_main) {
+        # might happen for cust_pay_pending from failed verify records,
+        #   in which case we attempt tokenization without cust_main
+        # everything else should absolutely have a cust_main
+        if ($table eq 'cust_pay_pending' and !$record->custnum ) {
+          # override the usual safety check and allow the record to be
+          # updated even without a custnum.
+          $record->set('custnum_pending', 1);
+        } else {
+          my $error = "Could not load cust_main for $table ".$record->get($record->primary_key);
+          if ($opt{'queue'}) {
+            $log->critical($error);
+            $dbh->commit or die $dbh->errstr; # commit log message
+            next;
+          }
+          $dbh->rollback if $oldAutoCommit;
+          die $error;
+        }
+      }
+
+      my $gateway;
+
+      # use the gatewaynum specified by the record if possible
+      $gateway = FS::payment_gateway->by_key_with_namespace(
+        'gatewaynum' => $record->gatewaynum,
+      ) if $record->gateway;
+
+      # otherwise use the cust agent gateway if possible (which realtime_refund_bop would do)
+      # otherwise just use default gateway
+      unless ($gateway) {
+
+        $gateway = $cust_main 
+                 ? $cust_main->agent->payment_gateway
+                 : FS::payment_gateway->default_gateway;
+
+        # check for processor mismatch
+        unless ($table eq 'cust_pay_pending') { # has no processor table
+          if (my $processor = $record->processor) {
+
+            my $conf_processor = $gateway->gateway_module;
+            my %bop_options = $gateway->gatewaynum
+                            ? $gateway->options
+                            : @{ $gateway->get('options') };
+
+            # this is the same standard used by realtime_refund_bop
+            unless (
+              ($processor eq $conf_processor) ||
+              (($conf_processor eq 'CardFortress') && ($processor eq $bop_options{'gateway'}))
+            ) {
+
+              # processors don't match, so refund already cannot be run on this object,
+              # regardless of what we do now...
+              # but unless we gotta tokenize everything, just leave well enough alone
+              unless ($require_tokenized) {
+                warn "Skipping mismatched processor for $table ".$record->get($record->primary_key) if $debug;
+                next;
+              }
+              ### no error--we'll tokenize using the new gateway, just to remove stored payinfo,
+              ### because refunds are already impossible for this record, anyway
+
+            } # end processor mismatch
+
+          } # end record has processor
+        } # end not cust_pay_pending
+
+      }
+
+      # means no default gateway, no promise to tokenize, can skip
+      unless ($gateway) {
+        warn "Skipping missing gateway for $table ".$record->get($record->primary_key) if $debug;
+        next;
+      }
+
+      my $info = _token_check_gateway_info($cache,$gateway);
+      unless (ref($info)) {
+        # only throws error if Business::OnlinePayment won't load,
+        #   which is just cause to abort this whole process, even if queue
+        $dbh->rollback if $oldAutoCommit;
+        die $info; # error message
+      }
+
+      # a configured gateway can't tokenize, move along
+      unless ($info->{'can_tokenize'}) {
+        warn "Skipping, cannot tokenize $table ".$record->get($record->primary_key) if $debug;
+        next;
+      }
+
+      warn "ATTEMPTING GATEWAY-ONLY TOKENIZE" if $debug && !$cust_main;
+
+      # if we got this far, time to mutex
+      $record->select_for_update;
+
+      # no clear record of name/address/etc used for transaction,
+      # but will load name/phone/id from customer if run as an object method,
+      # so we try that if we can
+      my %tokenopts = (
+        'payment_gateway' => $gateway,
+        'method'          => 'CC',
+        'payinfo'         => $record->payinfo,
+        'paydate'         => $record->paydate,
+      );
+      my $error = $cust_main
+                ? $cust_main->realtime_tokenize(\%tokenopts)
+                : FS::cust_main::Billing_Realtime->realtime_tokenize(\%tokenopts);
+      if (FS::payinfo_Mixin->tokenized($tokenopts{'payinfo'})) { # implies no error
+        $record->payinfo($tokenopts{'payinfo'});
+        $error = $record->replace;
+      } else {
+        $error ||= 'Unknown error';
+      }
+      if ($error) {
+        $error = "Error tokenizing $table ".$record->get($record->primary_key).": ".$error;
+        if ($opt{'queue'}) {
+          $log->critical($error);
+          $dbh->commit or die $dbh->errstr; # commit log message, release mutex
+          next;
+        }
+        $dbh->rollback if $oldAutoCommit;
+        die $error;
+      }
+      $dbh->commit or die $dbh->errstr if $opt{'queue'}; # release mutex
+      warn "TOKENIZED $table ".$record->get($record->primary_key) if $debug;
+
+    } # end record loop
+  } # end table loop
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return '';
+}
+
+# not a method!
+sub _token_check_next_recnum {
+  my ($dbh,$table,$step,$offset,$recnums) = @_;
+  my $recnum = shift @$recnums;
+  return $recnum if $recnum;
+  my $tclass = 'FS::'.$table;
+  my $sth = $dbh->prepare('SELECT '.$tclass->primary_key.' FROM '.$table.' ORDER BY '.$tclass->primary_key.' LIMIT '.$step.' OFFSET '.$$offset) 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();
+  $$offset += $step;
+  return shift @$recnums;
+}
+
+# not a method!
+sub _token_check_gateway_info {
+  my ($cache,$payment_gateway) = @_;
+
+  return $cache->{$payment_gateway->gateway_module}
+    if $cache->{$payment_gateway->gateway_module};
+
+  my $info = {};
+  $cache->{$payment_gateway->gateway_module} = $info;
+
+  my $namespace = $payment_gateway->gateway_namespace;
+  return $info unless $namespace eq 'Business::OnlinePayment';
+  $info->{'is_bop'} = 1;
+
+  # only need to load this once,
+  # don't want to load if nothing is_bop
+  unless ($cache->{'Business::OnlinePayment'}) {
+    eval "use $namespace";  
+    return "Error initializing Business:OnlinePayment: ".$@ if $@;
+    $cache->{'Business::OnlinePayment'} = 1;
+  }
+
+  my $transaction = new $namespace( $payment_gateway->gateway_module,
+                                    _bop_options({ 'payment_gateway' => $payment_gateway }),
+                                  );
+
+  return $info unless $transaction->can('info');
+  $info->{'can_info'} = 1;
+
+  my %supported_actions = $transaction->info('supported_actions');
+  $info->{'can_tokenize'} = 1
+    if $supported_actions{'CC'}
+      && grep /^Tokenize$/, @{$supported_actions{'CC'}};
+
+  # not using this any more, but for future reference...
+  $info->{'void_requires_card'} = 1
+    if $transaction->info('CC_void_requires_card');
+
+  return $info;
+}
+
 =back
 
 =head1 BUGS
 
-Not autoloaded.
-
 =head1 SEE ALSO
 
 L<FS::cust_main>, L<FS::cust_main::Billing>
diff --git a/FS/FS/cust_pay.pm b/FS/FS/cust_pay.pm
index d45d2e3..eed735a 100644
--- a/FS/FS/cust_pay.pm
+++ b/FS/FS/cust_pay.pm
@@ -548,7 +548,8 @@ otherwise returns false.
 
 sub replace {
   my $self = shift;
-  return "Can't modify closed payment" if $self->closed =~ /^Y/i;
+  return "Can't modify closed payment"
+    if $self->closed =~ /^Y/i && !$FS::payinfo_Mixin::allow_closed_replace;
   $self->SUPER::replace(@_);
 }
 
diff --git a/FS/FS/cust_refund.pm b/FS/FS/cust_refund.pm
index 4affb15..adc19f7 100644
--- a/FS/FS/cust_refund.pm
+++ b/FS/FS/cust_refund.pm
@@ -285,7 +285,8 @@ otherwise returns false.
 
 sub replace {
   my $self = shift;
-  return "Can't modify closed refund" if $self->closed =~ /^Y/i;
+  return "Can't modify closed refund" 
+    if $self->closed =~ /^Y/i && !$FS::payinfo_Mixin::allow_closed_replace;
   $self->SUPER::replace(@_);
 }
 
diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm
index 7a59ea7..788cfff 100644
--- a/FS/FS/log_context.pm
+++ b/FS/FS/log_context.pm
@@ -5,11 +5,13 @@ use base qw( FS::Record );
 use FS::Record qw( qsearch qsearchs );
 
 my @contexts = ( qw(
-  test
   bill_and_collect
   FS::cust_main::Billing::bill_and_collect
   FS::cust_main::Billing::bill
+  FS::cust_main::Billing_Realtime::realtime_bop
+  FS::cust_main::Billing_Realtime::realtime_tokenize
   FS::cust_main::Billing_Realtime::realtime_verify_bop
+  FS::cust_main::Billing_Realtime::token_check
   FS::part_pkg
   FS::Misc::Geo::standardize_uscensus
   FS::saved_search::send
@@ -24,6 +26,7 @@ my @contexts = ( qw(
   upgrade_taxable_billpkgnum
   freeside-paymentech-upload
   freeside-paymentech-download
+  test
 ) );
 
 =head1 NAME
diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm
index 4da40e3..9f3ac16 100644
--- a/FS/FS/payinfo_Mixin.pm
+++ b/FS/FS/payinfo_Mixin.pm
@@ -8,7 +8,8 @@ use FS::UID qw(driver_name);
 use FS::Cursor;
 use Time::Local qw(timelocal);
 
-use vars qw($ignore_masked_payinfo);
+# 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 @@ 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');
   }
@@ -133,7 +135,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...
 
@@ -201,7 +203,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 ) {
@@ -216,7 +218,7 @@ sub payinfo_check {
         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 {
@@ -236,6 +238,7 @@ sub payinfo_check {
     }
   }
 
+  return '';
 }
 
 =item payby_payinfo_pretty [ LOCALE ]
@@ -342,6 +345,21 @@ sub upgrade_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
diff --git a/FS/FS/payment_gateway.pm b/FS/FS/payment_gateway.pm
index d2695ed..d0272cd 100644
--- a/FS/FS/payment_gateway.pm
+++ b/FS/FS/payment_gateway.pm
@@ -332,6 +332,119 @@ sub processor {
   }
 }
 
+=item default_gateway OPTIONS
+
+Class method.
+
+Returns default gateway (from business-onlinepayment conf) as a payment_gateway object.
+
+Accepts options
+
+conf - existing conf object
+
+nofatal - return blank instead of dying if no default gateway is configured
+
+method - if set to CHEK or ECHECK, returns object for business-onlinepayment-ach if available
+
+Before using this, be sure you wouldn't rather be using L</by_key_or_default> or,
+more likely, L<FS::agent/payment_gateway>.
+
+=cut
+
+# the standard settings from the config could be moved to a null agent
+# agent_payment_gateway referenced payment_gateway
+
+sub default_gateway {
+  my ($self,%options) = @_;
+
+  $options{'conf'} ||= new FS::Conf;
+  my $conf = $options{'conf'};
+
+  unless ( $conf->exists('business-onlinepayment') ) {
+    if ( $options{'nofatal'} ) {
+      return '';
+    } else {
+      die "Real-time processing not enabled\n";
+    }
+  }
+
+  #load up config
+  my $bop_config = 'business-onlinepayment';
+  $bop_config .= '-ach'
+    if ( $options{method}
+         && $options{method} =~ /^(ECHECK|CHEK)$/
+         && $conf->exists($bop_config. '-ach')
+       );
+  my ( $processor, $login, $password, $action, @bop_options ) =
+    $conf->config($bop_config);
+  $action ||= 'normal authorization';
+  pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
+  die "No real-time processor is enabled - ".
+      "did you set the business-onlinepayment configuration value?\n"
+    unless $processor;
+
+  my $payment_gateway = new FS::payment_gateway;
+  $payment_gateway->gateway_namespace( $conf->config('business-onlinepayment-namespace') ||
+                                       'Business::OnlinePayment');
+  $payment_gateway->gateway_module($processor);
+  $payment_gateway->gateway_username($login);
+  $payment_gateway->gateway_password($password);
+  $payment_gateway->gateway_action($action);
+  $payment_gateway->set('options', [ @bop_options ]);
+  return $payment_gateway;
+}
+
+=item by_key_with_namespace GATEWAYNUM
+
+Like usual by_key, but makes sure namespace is set,
+and dies if not found.
+
+=cut
+
+sub by_key_with_namespace {
+  my $self = shift;
+  my $payment_gateway = $self->by_key(@_);
+  die "payment_gateway not found"
+    unless $payment_gateway;
+  $payment_gateway->gateway_namespace('Business::OnlinePayment')
+    unless $payment_gateway->gateway_namespace;
+  return $payment_gateway;
+}
+
+=item by_key_or_default OPTIONS
+
+Either returns the gateway specified by option gatewaynum, or the default gateway.
+
+Accepts the same options as L</default_gateway>.
+
+Also ensures that the gateway_namespace has been set.
+
+=cut
+
+sub by_key_or_default {
+  my ($self,%options) = @_;
+
+  if ($options{'gatewaynum'}) {
+    return $self->by_key_with_namespace($options{'gatewaynum'});
+  } else {
+    return $self->default_gateway(%options);
+  }
+}
+
+# if it weren't for the way gateway_namespace default is set, this method would not be necessary
+# that should really go in check() with an accompanying upgrade, so we could just use qsearch safely,
+# but currently short on time to test deeper changes...
+#
+# if no default gateway is set and nofatal is passed, first value returned is blank string
+sub all_gateways {
+  my ($self,%options) = @_;
+  my @out;
+  foreach my $gatewaynum ('',( map {$_->gatewaynum} qsearch('payment_gateway') )) {
+    push @out, $self->by_key_or_default( %options, gatewaynum => $gatewaynum );
+  }
+  return @out;
+}
+
 # _upgrade_data
 #
 # Used by FS::Upgrade to migrate to a new database.
diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily
index e21569d..7aec3bf 100755
--- a/FS/bin/freeside-daily
+++ b/FS/bin/freeside-daily
@@ -92,7 +92,7 @@ use FS::Cron::backup qw(backup);
 backup();
 
 #except we'd rather not start cleanup jobs until the backup is done
-cleanup();
+cleanup( quiet => !$opt{'v'} );
 
 $log->info('finish');
 
diff --git a/FS/t/suite/13-tokenization.t b/FS/t/suite/13-tokenization.t
new file mode 100644
index 0000000..019b61c
--- /dev/null
+++ b/FS/t/suite/13-tokenization.t
@@ -0,0 +1,224 @@
+#!/usr/bin/perl
+
+use strict;
+use FS::Test;
+use Test::More;
+use FS::Conf;
+use FS::cust_main;
+use Business::CreditCard qw(generate_last_digit);
+use DateTime;
+if ( stat('/usr/local/etc/freeside/cardfortresstest.txt') ) {
+  plan tests => 21;
+} else {
+  plan skip_all => 'CardFortress test encryption key is not installed.';
+}
+
+### can only run on test database (company name "Freeside Test")
+### will run upgrade, which uses lots of prints & warns beyond regular test output
+
+my $fs = FS::Test->new( user => 'admin' );
+my $conf = FS::Conf->new;
+my $err;
+my $bopconf;
+
+like( $conf->config('company_name'), qr/^Freeside Test/, 'using test database' ) or BAIL_OUT('');
+
+# upgrade just schema, or v3 test db cannot create refunds
+$err = system('freeside-upgrade','-s','admin');
+ok( !$err, 'schema upgrade' ) or BAIL_OUT('Error string: '.$!);
+
+# some pre-upgrade cleanup, upgrade will fail if these are still configured
+foreach my $cust_main ( $fs->qsearch('cust_main') ) {
+  my @count = $fs->qsearch('agent_payment_gateway', { agentnum => $cust_main->agentnum } );
+  if (@count > 1) {
+    note("DELETING CARDTYPE GATEWAYS");
+    foreach my $apg (@count) {
+      $err = $apg->delete if $apg->cardtype;
+      last if $err;
+    }
+    @count = $fs->qsearch('agent_payment_gateway', { agentnum => $cust_main->agentnum } );
+    if (@count > 1) {
+      $err = "Still found ". at count." gateways for custnum ".$cust_main->custnum;
+      last;
+    }
+  }
+}
+ok( !$err, "remove obsolete payment gateways" ) or BAIL_OUT($err);
+
+$bopconf = 
+'IPPay
+TESTTERMINAL';
+$conf->set('business-onlinepayment' => $bopconf);
+is( join("\n",$conf->config('business-onlinepayment')), $bopconf, "setting first default gateway" ) or BAIL_OUT('');
+
+# generate a few void/refund records for upgrading
+my $counter = 20;
+foreach my $cust_pay ( $fs->qsearch('cust_pay',{ payby => 'CARD' }) ) {
+  if ($counter % 2) {
+    $err = $cust_pay->void('Testing');
+    $err = "Voiding: $err" if $err;
+  } else {
+    if ($fs->qsearch('cust_refund',{ source_paynum => $cust_pay->paynum })) {
+      note('refund skipping cust_pay '.$cust_pay->paynum.', already refunded');
+      next;
+    }
+    # from realtime_refund_bop, just the important bits    
+    while ( $cust_pay->unapplied < $cust_pay->paid ) {
+      my @cust_bill_pay = $cust_pay->cust_bill_pay;
+      last unless @cust_bill_pay;
+      my $cust_bill_pay = pop @cust_bill_pay;
+      $err = $cust_bill_pay->delete;
+      $err = "Refund unapply: $err" if $err;
+      last if $err;
+    }
+    last if $err;
+    my $cust_refund = new FS::cust_refund ( {
+      'custnum'  => $cust_pay->cust_main->custnum,
+      'paynum'   => $cust_pay->paynum,
+      'source_paynum' => $cust_pay->paynum,
+      'refund'   => $cust_pay->paid,
+      '_date'    => '',
+      'payby'    => $cust_pay->payby,
+      'payinfo'  => $cust_pay->payinfo,
+      'reason'     => 'Testing',
+      'gatewaynum'    => $cust_pay->gatewaynum,
+      'processor'     => $cust_pay->payment_gateway ? $cust_pay->payment_gateway->processor : '',
+      'auth'          => $cust_pay->auth,
+      'order_number'  => $cust_pay->order_number,
+    } );
+    $err = $cust_refund->insert( reason_type => 'Refund' );
+    $err = "Refunding ".$cust_pay->paynum.": $err" if $err;
+  }
+  last if $err;
+  $counter -= 1;
+  last unless $counter > 0;
+}
+$err ||= 'not enough records' if $counter;
+ok( !$err, "create some refunds and voids" ) or BAIL_OUT($err);
+
+# also, just to test behavior in this case, create a record for an aborted
+# verification payment. this will have no customer number.
+
+my $pending_failed = FS::cust_pay_pending->new({
+  'custnum_pending' => 1,
+  'paid'    => '1.00',
+  '_date'   => time - 86400,
+  random_card(),
+  'status'  => 'failed',
+  'statustext' => 'Tokenization upgrade test',
+});
+$err = $pending_failed->insert;
+ok( !$err, "create a failed payment attempt" ) or BAIL_OUT($err);
+
+# find two stored credit cards, and one stored checking account (to overwrite with CARD)
+my @cust = (
+  $fs->qsearch({ table=>'cust_main', hashref=>{payby=>'CARD'}, extra_sql=>' LIMIT 2' }),
+  $fs->qsearch({ table=>'cust_main', hashref=>{payby=>'CHEK'}, extra_sql=>' LIMIT 1' }),
+);
+my @payment;
+
+ok( $cust[0]->payby eq 'CARD' && !$cust[0]->tokenized,
+  "first customer has a non-tokenized card"
+  ) or BAIL_OUT();
+
+$err = $cust[0]->realtime_bop({method => 'CC', amount => '2.00'});
+ok( !$err, "create a payment through IPPay" )
+  or BAIL_OUT($err);
+$payment[0] = $fs->qsearchs('cust_pay', { custnum => $cust[0]->custnum,
+                                     paid => '2.00' })
+  or BAIL_OUT("can't find payment record");
+
+$err = system('freeside-upgrade','admin');
+ok( !$err, 'initial upgrade' ) or BAIL_OUT('Error string: '.$!);
+
+# switch to CardFortress
+$bopconf =
+'CardFortress
+cardfortresstest
+(TEST54)
+Normal Authorization
+gateway
+IPPay
+gateway_login
+TESTTERMINAL
+gateway_password
+
+private_key
+/usr/local/etc/freeside/cardfortresstest.txt';
+$conf->set('business-onlinepayment' => $bopconf);
+is( join("\n",$conf->config('business-onlinepayment')), $bopconf, "setting tokenizable default gateway" ) or BAIL_OUT('');
+
+foreach my $pg ($fs->qsearch('payment_gateway')) {
+  unless ($pg->gateway_module eq 'CardFortress') {
+    note('UPGRADING NON-CF PAYMENT GATEWAY');
+    my %pgopts = (
+      gateway          => $pg->gateway_module,
+      gateway_login    => $pg->gateway_username,
+      gateway_password => $pg->gateway_password,
+      private_key      => '/usr/local/etc/freeside/cardfortresstest.txt',
+    );
+    $pg->gateway_module('CardFortress');
+    $pg->gateway_username('cardfortresstest');
+    $pg->gateway_password('(TEST54)');
+    $err = $pg->replace(\%pgopts);
+    last if $err;
+  }
+}
+ok( !$err, "remove non-CF payment gateways" ) or BAIL_OUT($err);
+
+# create a payment using a non-tokenized card. this should immediately
+# trigger tokenization.
+ok( $cust[1]->payby eq 'CARD' && ! $cust[1]->tokenized,
+  "second customer has a non-tokenized card"
+  ) or BAIL_OUT();
+
+$err = $cust[1]->realtime_bop({method => 'CC', amount => '3.00'});
+ok( !$err, "tokenize a card when it's first used for payment" )
+  or BAIL_OUT($err);
+$payment[1] = $fs->qsearchs('cust_pay', { custnum => $cust[1]->custnum,
+                                     paid => '3.00' })
+  or BAIL_OUT("can't find payment record");
+ok( $payment[1]->tokenized, "payment is tokenized" );
+$cust[1] = $cust[1]->replace_old;
+ok( $cust[1]->tokenized, "card is now tokenized" );
+
+
+# invoke the part of freeside-upgrade that tokenizes
+FS::cust_main->queueable_upgrade();
+#$err = system('freeside-upgrade','admin');
+#ok( !$err, 'tokenizable upgrade' ) or BAIL_OUT('Error string: '.$!);
+
+$cust[0] = $cust[0]->replace_old;
+ok( $cust[0]->tokenized, "old card was tokenized during upgrade" );
+$payment[0] = $payment[0]->replace_old;
+ok( $payment[0]->tokenized, "old payment was tokenized during upgrade" );
+my $old_pending = $fs->qsearchs('cust_pay_pending',{ paynum => $payment[0]->paynum });
+ok( $old_pending->tokenized, "old cust_pay_pending was tokenized during upgrade" );
+
+$pending_failed = $pending_failed->replace_old;
+ok( $pending_failed->tokenized, "cust_pay_pending with no customer was tokenized" );
+
+# add a new payment card to one customer
+my %newcard = random_card();
+$cust[2]->$_($newcard{$_}) foreach keys %newcard;
+$err = $cust[2]->replace;
+ok( !$err, "new card was saved" ) or BAIL_OUT($err);
+ok($cust[2]->tokenized, "new card is tokenized" );
+
+sub random_card {
+  my $payinfo = '4111' . join('', map { int(rand(10)) } 1 .. 11);
+  $payinfo .= generate_last_digit($payinfo);
+  my $paydate = DateTime->now
+                ->add('years' => 1)
+                ->truncate(to => 'month')
+                ->strftime('%F');
+  return ( 'payby'    => 'CARD',
+           'payinfo'  => $payinfo,
+           'paydate'  => $paydate,
+           'payname'  => 'Tokenize Me',
+  );
+}
+
+1;
+
+
diff --git a/httemplate/browse/agent.cgi b/httemplate/browse/agent.cgi
index ae8c618..23da3af 100755
--- a/httemplate/browse/agent.cgi
+++ b/httemplate/browse/agent.cgi
@@ -38,7 +38,7 @@ full offerings (via their type).<BR><BR>
     <TH CLASS="grid" BGCOLOR="#cccccc">Ticketing</TH>
 % } 
 
-  <TH CLASS="grid" BGCOLOR="#cccccc"><FONT SIZE=-1>Payment Gateway Overrides</FONT></TH>
+  <TH CLASS="grid" BGCOLOR="#cccccc"><FONT SIZE=-1>Payment Gateway Override</FONT></TH>
   <TH CLASS="grid" BGCOLOR="#cccccc"><FONT SIZE=-1>Configuration Overrides</FONT></TH>
 </TR>
 
@@ -317,31 +317,24 @@ Unused
 
 
 %       ##
-%       # payment gateway overrides
+%       # payment gateway override
 %       ##
         <TD CLASS="inv" BGCOLOR="<% $bgcolor %>">
           <TABLE CLASS="inv" CELLSPACING=0 CELLPADDING=0>
-% foreach my $override (
-%                 # sort { }  want taxclass-full stuff first?  and default cards (empty cardtype)
-%                 qsearch('agent_payment_gateway', { 'agentnum' => $agent->agentnum } )
-%               ) {
-%            
+% my $gw_override = qsearchs('agent_payment_gateway', { 'agentnum' => $agent->agentnum } );
+% if ($gw_override) {
 
               <TR>
                 <TD> 
-                  <% $override->cardtype || 'Default' %> to <% $override->payment_gateway->gateway_module %> (<% $override->payment_gateway->gateway_username %>)
-                  <% $override->taxclass
-                        ? ' for '. $override->taxclass. ' only'
-                        : ''
-                  %>
-                  <FONT SIZE=-1><A HREF="javascript:areyousure('delete this payment gateway override', '<%$p%>misc/delete-agent_payment_gateway.cgi?<% $override->agentgatewaynum %>')">(delete)</A></FONT>
+                  <% $gw_override->payment_gateway->gateway_module %> (<% $gw_override->payment_gateway->gateway_username %>)
+                  <FONT SIZE=-1><A HREF="javascript:areyousure('delete this payment gateway override', '<%$p%>misc/delete-agent_payment_gateway.cgi?<% $gw_override->agentgatewaynum %>')">(delete)</A></FONT>
                 </TD>
               </TR>
-% } 
-
+% } else {
             <TR>
               <TD><FONT SIZE=-1><A HREF="<%$p%>edit/agent_payment_gateway.html?agentnum=<% $agent->agentnum %>">(add override)</A></FONT></TD>
             </TR>
+% }
           </TABLE>
         </TD>
 
diff --git a/httemplate/edit/agent_payment_gateway.html b/httemplate/edit/agent_payment_gateway.html
index 41a9f3e..753bc76 100644
--- a/httemplate/edit/agent_payment_gateway.html
+++ b/httemplate/edit/agent_payment_gateway.html
@@ -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') %>
@@ -20,32 +20,6 @@ Use gateway <SELECT NAME="gatewaynum">
 </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>
 
diff --git a/httemplate/edit/elements/edit.html b/httemplate/edit/elements/edit.html
index 5bee788..b521cd0 100644
--- a/httemplate/edit/elements/edit.html
+++ b/httemplate/edit/elements/edit.html
@@ -238,7 +238,7 @@ Example:
   >
 
   <INPUT TYPE="hidden" NAME="svcdb" VALUE="<% $table %>">
-  <INPUT TYPE="hidden" NAME="<% $pkey %>" VALUE="<% $clone ? '' : $object->$pkey() %>">
+  <INPUT TYPE="hidden" ID="<% $pkey %>" NAME="<% $pkey %>" VALUE="<% $clone ? '' : $object->$pkey() %>">
 
   <FONT SIZE="+1"><B>
   <% ( $opt{labels} && exists $opt{labels}->{$pkey} )
diff --git a/httemplate/edit/payment_gateway.html b/httemplate/edit/payment_gateway.html
index b28be23..2c7d315 100644
--- a/httemplate/edit/payment_gateway.html
+++ b/httemplate/edit/payment_gateway.html
@@ -22,6 +22,9 @@
 <SCRIPT TYPE="text/javascript">
   var modulesForNamespace = <% $json->encode(\%modules) %>;
   function changeNamespace() {
+    if (document.getElementById('gatewaynum').value) {
+      return true;
+    }
     var ns = document.getElementById('gateway_namespace').value;
     var select_module = document.getElementById('gateway_module');
     select_module.options.length = 0;
@@ -180,7 +183,13 @@ my $field_callback = sub {
   my ($cgi, $object, $field_hashref ) = @_;
   if ($object->gatewaynum) {
     if ( $field_hashref->{field} eq 'gateway_module' ) {
-      $field_hashref->{type} = 'fixed';
+      if ($object->gateway_namespace eq 'Business::OnlinePayment' &&
+          $object->gateway_module ne 'CardFortress'
+      ) {
+        $field_hashref->{options} = [ $object->gateway_module, 'CardFortress' ]
+      } else {
+        $field_hashref->{type} = 'fixed';
+      }
     } elsif ( $field_hashref->{field} eq 'gateway_namespace' ) {
       $field_hashref->{type} = 'fixed';
       $field_hashref->{formatted_value} = $object->namespace_description;
diff --git a/httemplate/edit/process/agent_payment_gateway.html b/httemplate/edit/process/agent_payment_gateway.html
index 5b5fd94..c9789cf 100644
--- a/httemplate/edit/process/agent_payment_gateway.html
+++ b/httemplate/edit/process/agent_payment_gateway.html
@@ -10,20 +10,13 @@ die "agentnum $1 not found" unless $agent;
 
 #my $old
 
-my @new = map {
-                my $cardtype = $_;
-                new FS::agent_payment_gateway {
+my $new = new FS::agent_payment_gateway {
                   ( map { $_ => scalar($cgi->param($_)) }
                                     fields('agent_payment_gateway')
                   ),
-                  'cardtype' => $cardtype,
                 };
-              }
-              $cgi->param('cardtype');
 
-foreach my $new (@new) {
-  my $error = $new->insert;
-  die $error if $error;
-}
+my $error = $new->insert;
+die $error if $error;
 
 </%init>
diff --git a/httemplate/misc/process/payment.cgi b/httemplate/misc/process/payment.cgi
index 8c12b4d..75c89f2 100644
--- a/httemplate/misc/process/payment.cgi
+++ b/httemplate/misc/process/payment.cgi
@@ -110,7 +110,7 @@ if ( $payby eq 'CHEK' ) {
   validate($payinfo)
     or errorpage(gettext('invalid_card'));
 
-  unless ( $payinfo =~ /^99\d{14}$/ ) { #token
+  unless ( $cust_main->tokenized($payinfo) ) { #token
 
     my $cardtype = cardtype($payinfo);
 
@@ -187,6 +187,11 @@ if ( $cgi->param('save') ) {
   errorpage("error saving info, payment not processed: $error")
     if $error;
   $cust_main = $new;
+
+} elsif ( $payby eq 'CARD' ) { # not saving
+
+  $paymask = FS::payinfo_Mixin->mask_payinfo('CARD',$payinfo); # for untokenized but tokenizable payinfo
+
 }
 
 my $error = '';

-----------------------------------------------------------------------




More information about the freeside-commits mailing list