RT# 82137 - Added ability for processing fee to be pain on seperate invoice.
[freeside.git] / FS / FS / cust_main / Billing_Realtime.pm
index 55517ad..54b3847 100644 (file)
@@ -4,15 +4,17 @@ use strict;
 use vars qw( $conf $DEBUG $me );
 use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Data::Dumper;
 use vars qw( $conf $DEBUG $me );
 use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Data::Dumper;
-use Digest::MD5 qw(md5_base64);
-use Business::CreditCard 0.28;
-use FS::UID qw( dbh );
+use Business::CreditCard 0.35;
+use Business::OnlinePayment;
+use FS::UID qw( dbh myconnect );
 use FS::Record qw( qsearch qsearchs );
 use FS::Misc qw( send_email );
 use FS::payby;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::Record qw( qsearch qsearchs );
 use FS::Misc qw( send_email );
 use FS::payby;
 use FS::cust_pay;
 use FS::cust_pay_pending;
+use FS::cust_bill_pay;
 use FS::cust_refund;
 use FS::cust_refund;
+use FS::banned_pay;
 
 $realtime_bop_decline_quiet = 0;
 
 
 $realtime_bop_decline_quiet = 0;
 
@@ -22,6 +24,9 @@ $realtime_bop_decline_quiet = 0;
 $DEBUG = 0;
 $me = '[FS::cust_main::Billing_Realtime]';
 
 $DEBUG = 0;
 $me = '[FS::cust_main::Billing_Realtime]';
 
+our $BOP_TESTING = 0;
+our $BOP_TESTING_SUCCESS = 1;
+
 install_callback FS::UID sub { 
   $conf = new FS::Conf;
   #yes, need it for stuff below (prolly should be cached)
 install_callback FS::UID sub { 
   $conf = new FS::Conf;
   #yes, need it for stuff below (prolly should be cached)
@@ -44,14 +49,14 @@ These methods are available on FS::cust_main objects.
 =item realtime_collect [ OPTION => VALUE ... ]
 
 Attempt to collect the customer's current balance with a realtime credit 
 =item realtime_collect [ OPTION => VALUE ... ]
 
 Attempt to collect the customer's current balance with a realtime credit 
-card, electronic check, or phone bill transaction (see realtime_bop() below).
+card or electronic check transaction (see realtime_bop() below).
 
 Returns the result of realtime_bop(): nothing, an error message, or a 
 hashref of state information for a third-party transaction.
 
 Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>, I<pkgnum>
 
 
 Returns the result of realtime_bop(): nothing, an error message, or a 
 hashref of state information for a third-party transaction.
 
 Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>, I<pkgnum>
 
-I<method> is one of: I<CC>, I<ECHECK> and I<LEC>.  If none is specified
+I<method> is one of: I<CC> or I<ECHECK>.  If none is specified
 then it is deduced from the customer record.
 
 If no I<amount> is specified, then the customer balance is used.
 then it is deduced from the customer record.
 
 If no I<amount> is specified, then the customer balance is used.
@@ -93,6 +98,8 @@ sub realtime_collect {
   }
 
   $options{amount} = $self->balance unless exists( $options{amount} );
   }
 
   $options{amount} = $self->balance unless exists( $options{amount} );
+  return '' unless $options{amount} > 0;
+
   $options{method} = FS::payby->payby2bop($self->payby)
     unless exists( $options{method} );
 
   $options{method} = FS::payby->payby2bop($self->payby)
     unless exists( $options{method} );
 
@@ -102,13 +109,13 @@ sub realtime_collect {
 
 =item realtime_bop { [ ARG => VALUE ... ] }
 
 
 =item realtime_bop { [ ARG => VALUE ... ] }
 
-Runs a realtime credit card, ACH (electronic check) or phone bill transaction
+Runs a realtime credit card or ACH (electronic check) transaction
 via a Business::OnlinePayment realtime gateway.  See
 L<http://420.am/business-onlinepayment> for supported gateways.
 
 Required arguments in the hashref are I<method>, and I<amount>
 
 via a Business::OnlinePayment realtime gateway.  See
 L<http://420.am/business-onlinepayment> for supported gateways.
 
 Required arguments in the hashref are I<method>, and I<amount>
 
-Available methods are: I<CC>, I<ECHECK> and I<LEC>
+Available methods are: I<CC>, I<ECHECK>, or I<PAYPAL>
 
 Available optional arguments are: I<description>, I<invnum>, I<apply>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
 
 
 Available optional arguments are: I<description>, I<invnum>, I<apply>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
 
@@ -125,7 +132,13 @@ specified invoice.  If the customer has exactly one open invoice, that
 invoice number will be assumed.  If you don't specify an I<invnum> you might 
 want to call the B<apply_payments> method or set the I<apply> option.
 
 invoice number will be assumed.  If you don't specify an I<invnum> you might 
 want to call the B<apply_payments> method or set the I<apply> option.
 
-I<apply> can be set to true to apply a resulting payment.
+I<no_invnum> can be set to true to prevent that default invnum from being set.
+
+I<apply> can be set to true to run B<apply_payments_and_credits> on success.
+
+I<no_auto_apply> can be set to true to set that flag on the resulting payment
+(prevents payment from being applied by B<apply_payments> or B<apply_payments_and_credits>,
+but will still be applied if I<invnum> exists...use with I<no_invnum> for intended effect.)
 
 I<quiet> can be set true to surpress email decline notices.
 
 
 I<quiet> can be set true to surpress email decline notices.
 
@@ -138,7 +151,8 @@ I<session_id> is a session identifier associated with this payment.
 
 I<depend_jobnum> allows payment capture to unlock export jobs
 
 
 I<depend_jobnum> allows payment capture to unlock export jobs
 
-I<discount_term> attempts to take a discount by prepaying for discount_term
+I<discount_term> attempts to take a discount by prepaying for discount_term.
+The payment will fail if I<amount> is incorrect for this discount term.
 
 A direct (Business::OnlinePayment) transaction will return nothing on success,
 or an error message on failure.
 
 A direct (Business::OnlinePayment) transaction will return nothing on success,
 or an error message on failure.
@@ -166,15 +180,8 @@ sub _bop_recurring_billing {
 
   } else {
 
 
   } else {
 
-    my %hash = ( 'custnum' => $self->custnum,
-                 'payby'   => 'CARD',
-               );
-
-    return 1 
-      if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } )
-      || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD',
-                                                               $opt{'payinfo'} )
-                             } );
+    # return 1 if the payinfo has been used for another payment
+    return $self->payinfo_used($opt{'payinfo'}); # in payinfo_Mixin
 
   }
 
 
   }
 
@@ -239,10 +246,13 @@ sub _bop_defaults {
     }
   }
 
     }
   }
 
-  $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
+  unless ( exists( $options->{'payinfo'} ) ) {
+    $options->{'payinfo'} = $self->payinfo;
+    $options->{'paymask'} = $self->paymask;
+  }
 
   # Default invoice number if the customer has exactly one open invoice.
 
   # Default invoice number if the customer has exactly one open invoice.
-  if( ! $options->{'invnum'} ) {
+  unless ( $options->{'invnum'} || $options->{'no_invnum'} ) {
     $options->{'invnum'} = '';
     my @open = $self->open_cust_bill;
     $options->{'invnum'} = $open[0]->invnum if scalar(@open) == 1;
     $options->{'invnum'} = '';
     my @open = $self->open_cust_bill;
     $options->{'invnum'} = $open[0]->invnum if scalar(@open) == 1;
@@ -303,16 +313,23 @@ sub _bop_content {
                         ? $options->{country}
                         : $self->country;
 
                         ? $options->{country}
                         : $self->country;
 
-  $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/
+  #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;
 
   $content{phone} = $self->daytime || $self->night;
 
+  my $currency =    $conf->exists('business-onlinepayment-currency')
+                 && $conf->config('business-onlinepayment-currency');
+  $content{currency} = $currency if $currency;
+
   \%content;
 }
 
 my %bop_method2payby = (
   'CC'     => 'CARD',
   'ECHECK' => 'CHEK',
   \%content;
 }
 
 my %bop_method2payby = (
   'CC'     => 'CARD',
   'ECHECK' => 'CHEK',
-  'LEC'    => 'LECB',
+  'PAYPAL' => 'PPAL',
 );
 
 sub realtime_bop {
 );
 
 sub realtime_bop {
@@ -337,19 +354,28 @@ sub realtime_bop {
 
   my $cc_surcharge = 0;
   my $cc_surcharge_pct = 0;
 
   my $cc_surcharge = 0;
   my $cc_surcharge_pct = 0;
-  $cc_surcharge_pct = $conf->config('credit-card-surcharge-percentage') 
-    if $conf->config('credit-card-surcharge-percentage');
-  
+  $cc_surcharge_pct = $conf->config('credit-card-surcharge-percentage', $self->agentnum) 
+    if $conf->config('credit-card-surcharge-percentage', $self->agentnum)
+    && $options{method} eq 'CC';
+
+  my $cc_surcharge_flat = 0;
+  $cc_surcharge_flat = $conf->config('credit-card-surcharge-flatfee', $self->agentnum)
+    if $conf->config('credit-card-surcharge-flatfee', $self->agentnum)
+    && $options{method} eq 'CC';
+
   # always add cc surcharge if called from event 
   # always add cc surcharge if called from event 
-  if($options{'cc_surcharge_from_event'} && $cc_surcharge_pct > 0) {
-      $cc_surcharge = $options{'amount'} * $cc_surcharge_pct / 100;
+  if($options{'cc_surcharge_from_event'} && ($cc_surcharge_pct > 0 || $cc_surcharge_flat > 0)) {
+    if ($options{'amount'} > 0) {
+      $cc_surcharge = ($options{'amount'} * ($cc_surcharge_pct / 100)) + $cc_surcharge_flat;
       $options{'amount'} += $cc_surcharge;
       $options{'amount'} = sprintf("%.2f", $options{'amount'}); # round (again)?
       $options{'amount'} += $cc_surcharge;
       $options{'amount'} = sprintf("%.2f", $options{'amount'}); # round (again)?
+    }
   }
   }
-  elsif($cc_surcharge_pct > 0) { # we're called not from event (i.e. from a 
-                                 # payment screen), so consider the given 
-                                # amount as post-surcharge
-    $cc_surcharge = $options{'amount'} - ($options{'amount'} / ( 1 + $cc_surcharge_pct/100 ));
+  elsif($cc_surcharge_pct > 0 || $cc_surcharge_flat > 0) {
+    # we're called not from event (i.e. from a
+    # payment screen), so consider the given
+               # amount as post-surcharge-processing_fee
+    $cc_surcharge = $options{'amount'} - $options{'processing-fee'} - (($options{'amount'} - ($cc_surcharge_flat + $options{'processing-fee'})) / ( 1 + $cc_surcharge_pct/100 )) if $options{'amount'} > 0;
   }
   
   $cc_surcharge = sprintf("%.2f",$cc_surcharge) if $cc_surcharge > 0;
   }
   
   $cc_surcharge = sprintf("%.2f",$cc_surcharge) if $cc_surcharge > 0;
@@ -359,6 +385,8 @@ sub realtime_bop {
   if ( $DEBUG ) {
     warn "$me realtime_bop (new): $options{method} $options{amount}\n";
     warn " cc_surcharge = $cc_surcharge\n";
   if ( $DEBUG ) {
     warn "$me realtime_bop (new): $options{method} $options{amount}\n";
     warn " cc_surcharge = $cc_surcharge\n";
+  }
+  if ( $DEBUG > 2 ) {
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
@@ -401,11 +429,29 @@ sub realtime_bop {
   # check for banned credit card/ACH
   ###
 
   # check for banned credit card/ACH
   ###
 
-  my $ban = qsearchs('banned_pay', {
+  my $ban = FS::banned_pay->ban_search(
     'payby'   => $bop_method2payby{$options{method}},
     'payby'   => $bop_method2payby{$options{method}},
-    'payinfo' => md5_base64($options{payinfo}),
-  } );
-  return "Banned credit card" if $ban;
+    'payinfo' => $options{payinfo},
+  );
+  return "Banned credit card" if $ban && $ban->bantype ne 'warn';
+
+  ###
+  # check for term discount validity
+  ###
+
+  my $discount_term = $options{discount_term};
+  if ( $discount_term ) {
+    my $bill = ($self->cust_bill)[-1]
+      or return "Can't apply a term discount to an unbilled customer";
+    my $plan = FS::discount_plan->new(
+      cust_bill => $bill,
+      months    => $discount_term
+    ) or return "No discount available for term '$discount_term'";
+    
+    if ( $plan->discounted_total != $options{amount} ) {
+      return "Incorrect term prepayment amount (term $discount_term, amount $options{amount}, requires ".$plan->discounted_total.")";
+    }
+  }
 
   ###
   # massage data
 
   ###
   # massage data
@@ -427,76 +473,92 @@ sub realtime_bop {
 
   my $paydate = '';
   my %content = ();
 
   my $paydate = '';
   my %content = ();
-  if ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'CC' ) {
-
-    $content{card_number} = $options{payinfo};
-    $paydate = exists($options{'paydate'})
-                    ? $options{'paydate'}
-                    : $self->paydate;
-    $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
-    $content{expiration} = "$2/$1";
-
-    my $paycvv = exists($options{'paycvv'})
-                   ? $options{'paycvv'}
-                   : $self->paycvv;
-    $content{cvv2} = $paycvv
-      if length($paycvv);
-
-    my $paystart_month = exists($options{'paystart_month'})
-                           ? $options{'paystart_month'}
-                           : $self->paystart_month;
-
-    my $paystart_year  = exists($options{'paystart_year'})
-                           ? $options{'paystart_year'}
-                           : $self->paystart_year;
-
-    $content{card_start} = "$paystart_month/$paystart_year"
-      if $paystart_month && $paystart_year;
-
-    my $payissue       = exists($options{'payissue'})
-                           ? $options{'payissue'}
-                           : $self->payissue;
-    $content{issue_number} = $payissue if $payissue;
-
-    if ( $self->_bop_recurring_billing( 'payinfo'        => $options{'payinfo'},
-                                        'trans_is_recur' => $trans_is_recur,
-                                      )
-       )
-    {
-      $content{recurring_billing} = 'YES';
-      $content{acct_code} = 'rebill'
-        if $conf->exists('credit_card-recurring_billing_acct_code');
-    }
 
 
-  } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){
-    ( $content{account_number}, $content{routing_code} ) =
-      split('@', $options{payinfo});
-    $content{bank_name} = $options{payname};
-    $content{bank_state} = exists($options{'paystate'})
-                             ? $options{'paystate'}
-                             : $self->getfield('paystate');
-    $content{account_type}= (exists($options{'paytype'}) && $options{'paytype'})
-                               ? uc($options{'paytype'})
-                               : uc($self->getfield('paytype')) || 'PERSONAL CHECKING';
-    $content{account_name} = $self->getfield('first'). ' '.
-                             $self->getfield('last');
+  if ( $namespace eq 'Business::OnlinePayment' ) {
+
+    if ( $options{method} eq 'CC' ) {
+
+      $content{card_number} = $options{payinfo};
+      $paydate = exists($options{'paydate'})
+                      ? $options{'paydate'}
+                      : $self->paydate;
+      $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+      $content{expiration} = "$2/$1";
+
+      my $paycvv = exists($options{'paycvv'})
+                     ? $options{'paycvv'}
+                     : $self->paycvv;
+      $content{cvv2} = $paycvv
+        if length($paycvv);
+
+      my $paystart_month = exists($options{'paystart_month'})
+                             ? $options{'paystart_month'}
+                             : $self->paystart_month;
+
+      my $paystart_year  = exists($options{'paystart_year'})
+                             ? $options{'paystart_year'}
+                             : $self->paystart_year;
+
+      $content{card_start} = "$paystart_month/$paystart_year"
+        if $paystart_month && $paystart_year;
+
+      my $payissue       = exists($options{'payissue'})
+                             ? $options{'payissue'}
+                             : $self->payissue;
+      $content{issue_number} = $payissue if $payissue;
+
+      if ( $self->_bop_recurring_billing(
+             'payinfo'        => $options{'payinfo'},
+             'trans_is_recur' => $trans_is_recur,
+           )
+         )
+      {
+        $content{recurring_billing} = 'YES';
+        $content{acct_code} = 'rebill'
+          if $conf->exists('credit_card-recurring_billing_acct_code');
+      }
+
+    } elsif ( $options{method} eq 'ECHECK' ){
+
+      ( $content{account_number}, $content{routing_code} ) =
+        split('@', $options{payinfo});
+      $content{bank_name} = $options{payname};
+      $content{bank_state} = exists($options{'paystate'})
+                               ? $options{'paystate'}
+                               : $self->getfield('paystate');
+      $content{account_type}=
+        (exists($options{'paytype'}) && $options{'paytype'})
+          ? uc($options{'paytype'})
+          : uc($self->getfield('paytype')) || 'PERSONAL CHECKING';
+
+      $content{company} = $self->company if $self->company;
+
+      if ( $content{account_type} =~ /BUSINESS/i && $self->company ) {
+        $content{account_name} = $self->company;
+      } else {
+        $content{account_name} = $self->getfield('first'). ' '.
+                                 $self->getfield('last');
+      }
+
+      $content{customer_org} = $self->company ? 'B' : 'I';
+      $content{state_id}       = exists($options{'stateid'})
+                                   ? $options{'stateid'}
+                                   : $self->getfield('stateid');
+      $content{state_id_state} = exists($options{'stateid_state'})
+                                   ? $options{'stateid_state'}
+                                   : $self->getfield('stateid_state');
+      $content{customer_ssn} = exists($options{'ss'})
+                                 ? $options{'ss'}
+                                 : $self->ss;
+
+    } else {
+      die "unknown method ". $options{method};
+    }
 
 
-    $content{customer_org} = $self->company ? 'B' : 'I';
-    $content{state_id}       = exists($options{'stateid'})
-                                 ? $options{'stateid'}
-                                 : $self->getfield('stateid');
-    $content{state_id_state} = exists($options{'stateid_state'})
-                                 ? $options{'stateid_state'}
-                                 : $self->getfield('stateid_state');
-    $content{customer_ssn} = exists($options{'ss'})
-                               ? $options{'ss'}
-                               : $self->ss;
-  } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'LEC' ) {
-    $content{phone} = $options{payinfo};
   } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
     #move along
   } else {
   } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
     #move along
   } else {
-    #die an evil death
+    die "unknown namespace $namespace";
   }
 
   ###
   }
 
   ###
@@ -507,7 +569,9 @@ sub realtime_bop {
                   ? $options{'balance'}
                   : $self->balance;
 
                   ? $options{'balance'}
                   : $self->balance;
 
+  warn "claiming mutex on customer ". $self->custnum. "\n" if $DEBUG > 1;
   $self->select_for_update; #mutex ... just until we get our pending record in
   $self->select_for_update; #mutex ... just until we get our pending record in
+  warn "obtained mutex on customer ". $self->custnum. "\n" if $DEBUG > 1;
 
   #the checks here are intended to catch concurrent payments
   #double-form-submission prevention is taken care of in cust_pay_pending::check
 
   #the checks here are intended to catch concurrent payments
   #double-form-submission prevention is taken care of in cust_pay_pending::check
@@ -522,23 +586,18 @@ sub realtime_bop {
     'custnum' => $self->custnum,
     'status'  => { op=>'!=', value=>'done' } 
   });
     'custnum' => $self->custnum,
     'status'  => { op=>'!=', value=>'done' } 
   });
-  # This is a problem.  A self-service third party payment that fails somehow 
-  # can't be retried, EVER, until someone manually clears it.  Totally 
-  # arbitrary fix: if the existing payment is more than two minutes old, 
-  # kill it.  This doesn't limit how long it can take the pending payment 
-  # to complete, only how long it will obstruct new payments.
-  my @still_pending;
-  foreach (@pending) {
-    if ( time - $_->_date > 120 ) {
+
+  #for third-party payments only, remove pending payments if they're in the 
+  #'thirdparty' (waiting for customer action) state.
+  if ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
+    foreach ( grep { $_->status eq 'thirdparty' } @pending ) {
       my $error = $_->delete;
       my $error = $_->delete;
-      warn "error deleting stale pending payment ".$_->paypendingnum.": $error"
-        if $error; # not fatal, it will fail anyway
-    }
-    else {
-      push @still_pending, $_;
+      warn "error deleting unfinished third-party payment ".
+          $_->paypendingnum . ": $error\n"
+        if $error;
     }
     }
+    @pending = grep { $_->status ne 'thirdparty' } @pending;
   }
   }
-  @pending = @still_pending;
 
   return "A payment is already being processed for this customer (".
          join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
 
   return "A payment is already being processed for this customer (".
          join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
@@ -553,6 +612,7 @@ sub realtime_bop {
     '_date'             => '',
     'payby'             => $bop_method2payby{$options{method}},
     'payinfo'           => $options{payinfo},
     '_date'             => '',
     'payby'             => $bop_method2payby{$options{method}},
     'payinfo'           => $options{payinfo},
+    'paymask'           => $options{paymask},
     'paydate'           => $paydate,
     'recurring_billing' => $content{recurring_billing},
     'pkgnum'            => $options{'pkgnum'},
     'paydate'           => $paydate,
     'recurring_billing' => $content{recurring_billing},
     'pkgnum'            => $options{'pkgnum'},
@@ -563,9 +623,16 @@ sub realtime_bop {
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
+
+  warn "inserting cust_pay_pending record for customer ". $self->custnum. "\n"
+    if $DEBUG > 1;
   my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
   return $cpp_new_err if $cpp_new_err;
 
   my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
   return $cpp_new_err if $cpp_new_err;
 
+  warn "inserted cust_pay_pending record for customer ". $self->custnum. "\n"
+    if $DEBUG > 1;
+  warn Dumper($cust_pay_pending) if $DEBUG > 2;
+
   my( $action1, $action2 ) =
     split( /\s*\,\s*/, $payment_gateway->gateway_action );
 
   my( $action1, $action2 ) =
     split( /\s*\,\s*/, $payment_gateway->gateway_action );
 
@@ -584,6 +651,7 @@ sub realtime_bop {
     %$bop_content,
     'reference'      => $cust_pay_pending->paypendingnum, #for now
     'callback_url'   => $payment_gateway->gateway_callback_url,
     %$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
   );
     'email'          => $email,
     %content, #after
   );
@@ -592,9 +660,7 @@ sub realtime_bop {
   my $cpp_pending_err = $cust_pay_pending->replace;
   return $cpp_pending_err if $cpp_pending_err;
 
   my $cpp_pending_err = $cust_pay_pending->replace;
   return $cpp_pending_err if $cpp_pending_err;
 
-  #config?
-  my $BOP_TESTING = 0;
-  my $BOP_TESTING_SUCCESS = 1;
+  warn Dumper($transaction) if $DEBUG > 2;
 
   unless ( $BOP_TESTING ) {
     $transaction->test_transaction(1)
 
   unless ( $BOP_TESTING ) {
     $transaction->test_transaction(1)
@@ -612,6 +678,9 @@ sub realtime_bop {
 
   if ( $transaction->is_success() && $namespace eq 'Business::OnlineThirdPartyPayment' ) {
 
 
   if ( $transaction->is_success() && $namespace eq 'Business::OnlineThirdPartyPayment' ) {
 
+    $cust_pay_pending->status('thirdparty');
+    my $cpp_err = $cust_pay_pending->replace;
+    return { error => $cpp_err } if $cpp_err;
     return { reference => $cust_pay_pending->paypendingnum,
              map { $_ => $transaction->$_ } qw ( popup_url collectitems ) };
 
     return { reference => $cust_pay_pending->paypendingnum,
              map { $_ => $transaction->$_ } qw ( popup_url collectitems ) };
 
@@ -687,8 +756,6 @@ sub realtime_bop {
 
   if ( $transaction->can('card_token') && $transaction->card_token ) {
 
 
   if ( $transaction->can('card_token') && $transaction->card_token ) {
 
-    $self->card_token($transaction->card_token);
-
     if ( $options{'payinfo'} eq $self->payinfo ) {
       $self->payinfo($transaction->card_token);
       my $error = $self->replace;
     if ( $options{'payinfo'} eq $self->payinfo ) {
       $self->payinfo($transaction->card_token);
       my $error = $self->replace;
@@ -728,19 +795,6 @@ sub fake_bop {
      return "Error: No error; test failure requested with fake_failure";
   }
 
      return "Error: No error; test failure requested with fake_failure";
   }
 
-  #my $paybatch = '';
-  #if ( $payment_gateway->gatewaynum ) { # agent override
-  #  $paybatch = $payment_gateway->gatewaynum. '-';
-  #}
-  #
-  #$paybatch .= "$processor:". $transaction->authorization;
-  #
-  #$paybatch .= ':'. $transaction->order_number
-  #  if $transaction->can('order_number')
-  #  && length($transaction->order_number);
-
-  my $paybatch = 'FakeProcessor:54:32';
-
   my $cust_pay = new FS::cust_pay ( {
      'custnum'  => $self->custnum,
      'invnum'   => $options{'invnum'},
   my $cust_pay = new FS::cust_pay ( {
      'custnum'  => $self->custnum,
      'invnum'   => $options{'invnum'},
@@ -749,9 +803,11 @@ sub fake_bop {
      'payby'    => $bop_method2payby{$options{method}},
      #'payinfo'  => $payinfo,
      'payinfo'  => '4111111111111111',
      'payby'    => $bop_method2payby{$options{method}},
      #'payinfo'  => $payinfo,
      'payinfo'  => '4111111111111111',
-     'paybatch' => $paybatch,
      #'paydate'  => $paydate,
      'paydate'  => '2012-05-01',
      #'paydate'  => $paydate,
      'paydate'  => '2012-05-01',
+     'processor'      => 'FakeProcessor',
+     'auth'           => '54',
+     'order_number'   => '32',
   } );
   $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
 
   } );
   $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
 
@@ -789,8 +845,8 @@ sub fake_bop {
 
 # item _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ]
 # 
 
 # item _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ]
 # 
-# Wraps up processing of a realtime credit card, ACH (electronic check) or
-# phone bill transaction.
+# Wraps up processing of a realtime credit card or ACH (electronic check)
+# transaction.
 
 sub _realtime_bop_result {
   my( $self, $cust_pay_pending, $transaction, %options ) = @_;
 
 sub _realtime_bop_result {
   my( $self, $cust_pay_pending, $transaction, %options ) = @_;
@@ -812,17 +868,8 @@ sub _realtime_bop_result {
 
   if ( $transaction->is_success() ) {
 
 
   if ( $transaction->is_success() ) {
 
-    my $paybatch = '';
-    if ( $payment_gateway->gatewaynum ) { # agent override
-      $paybatch = $payment_gateway->gatewaynum. '-';
-    }
-
-    $paybatch .= $payment_gateway->gateway_module. ":".
-      $transaction->authorization;
-
-    $paybatch .= ':'. $transaction->order_number
-      if $transaction->can('order_number')
-      && length($transaction->order_number);
+    my $order_number = $transaction->order_number
+      if $transaction->can('order_number');
 
     my $cust_pay = new FS::cust_pay ( {
        'custnum'  => $self->custnum,
 
     my $cust_pay = new FS::cust_pay ( {
        'custnum'  => $self->custnum,
@@ -831,10 +878,15 @@ sub _realtime_bop_result {
        '_date'    => '',
        'payby'    => $cust_pay_pending->payby,
        'payinfo'  => $options{'payinfo'},
        '_date'    => '',
        'payby'    => $cust_pay_pending->payby,
        'payinfo'  => $options{'payinfo'},
-       'paybatch' => $paybatch,
+       'paymask'  => $options{'paymask'} || $cust_pay_pending->paymask,
        'paydate'  => $cust_pay_pending->paydate,
        'pkgnum'   => $cust_pay_pending->pkgnum,
        'paydate'  => $cust_pay_pending->paydate,
        'pkgnum'   => $cust_pay_pending->pkgnum,
-       'discount_term' => $options{'discount_term'},
+       'discount_term'  => $options{'discount_term'},
+       'gatewaynum'     => ($payment_gateway->gatewaynum || ''),
+       'processor'      => $payment_gateway->gateway_module,
+       'auth'           => $transaction->authorization,
+       'order_number'   => $order_number || '',
+       'no_auto_apply'  => $options{'no_auto_apply'} ? 'Y' : '',
     } );
     #doesn't hurt to know, even though the dup check is in cust_pay_pending now
     $cust_pay->payunique( $options{payunique} )
     } );
     #doesn't hurt to know, even though the dup check is in cust_pay_pending now
     $cust_pay->payunique( $options{payunique} )
@@ -845,17 +897,23 @@ sub _realtime_bop_result {
     my $dbh = dbh;
 
     #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
     my $dbh = dbh;
 
     #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
-
-    my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
+    my $error = $cust_pay->insert(
+      $options{'manual'} ? ( 'manual' => 1 ) : (),
+      $options{'processing-fee'} > 0 ? ( 'processing-fee' => $options{'processing-fee'} ) : (),
+    );
 
     if ( $error ) {
 
     if ( $error ) {
+      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
       $cust_pay->invnum(''); #try again with no specific invnum
       $cust_pay->invnum(''); #try again with no specific invnum
-      my $error2 = $cust_pay->insert( $options{'manual'} ?
-                                      ( 'manual' => 1 ) : ()
-                                    );
+      $cust_pay->paynum('');
+      my $error2 = $cust_pay->insert(
+        $options{'manual'} ? ( 'manual' => 1 ) : (),
+        $options{'processing-fee'} > 0 ? ( 'processing-fee' => $options{'processing-fee'} ) : (),
+      );
       if ( $error2 ) {
         # gah.  but at least we have a record of the state we had to abort in
         # from cust_pay_pending now.
       if ( $error2 ) {
         # gah.  but at least we have a record of the state we had to abort in
         # from cust_pay_pending now.
+        $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
         my $e = "WARNING: $options{method} captured but payment not recorded -".
                 " error inserting payment (". $payment_gateway->gateway_module.
                 "): $error2".
         my $e = "WARNING: $options{method} captured but payment not recorded -".
                 " error inserting payment (". $payment_gateway->gateway_module.
                 "): $error2".
@@ -924,7 +982,7 @@ sub _realtime_bop_result {
       }
 
       # have a CC surcharge portion --> one-time charge
       }
 
       # have a CC surcharge portion --> one-time charge
-      if ( $options{'cc_surcharge'} > 0 ) { 
+      if ( $options{'cc_surcharge'} > 0 || $options{'processing-fee'} > 0) {
            # XXX: this whole block needs to be in a transaction?
 
          my $invnum;
            # XXX: this whole block needs to be in a transaction?
 
          my $invnum;
@@ -945,42 +1003,119 @@ sub _realtime_bop_result {
          unless ( $invnum ) {
            # XXX: unlikely case - pre-paying before any invoices generated
            # what it should do is create a new invoice and pick it
          unless ( $invnum ) {
            # XXX: unlikely case - pre-paying before any invoices generated
            # what it should do is create a new invoice and pick it
-               warn 'CC SURCHARGE AND NO INVOICES PICKED TO APPLY IT!';
+               warn 'CC SURCHARGE OR PROCESS FEE AND NO INVOICES PICKED TO APPLY IT!';
                return '';
          }
 
                return '';
          }
 
-         my $cust_pkg;
-         my $charge_error = $self->charge({
+    if ($options{'cc_surcharge'} > 0) {
+           my $cust_pkg;
+      my $cc_surcharge_text = 'Credit Card Surcharge';
+      $cc_surcharge_text = $conf->config('credit-card-surcharge-text', $self->agentnum) if $conf->exists('credit-card-surcharge-text', $self->agentnum);
+           my $charge_error = $self->charge({
                                    'amount'    => $options{'cc_surcharge'},
                                    'amount'    => $options{'cc_surcharge'},
-                                   'pkg'       => 'Credit Card Surcharge',
+                                   'pkg'       => $cc_surcharge_text,
                                    'setuptax'  => 'Y',
                                    'cust_pkg_ref' => \$cust_pkg,
                                    'setuptax'  => 'Y',
                                    'cust_pkg_ref' => \$cust_pkg,
-                               });
-         if($charge_error) {
-               warn 'Unable to add CC surcharge cust_pkg';
-               return '';
-         }
+                       });
+
+           if($charge_error) {
+                   warn 'Unable to add CC surcharge cust_pkg';
+                   return '';
+           }
+
+      $cust_pkg->setup(time);
+      my $cp_error = $cust_pkg->replace;
+      if($cp_error) {
+        warn 'Unable to set setup time on cust_pkg for cc surcharge';
+        # but keep going...
+      }
 
 
-         $cust_pkg->setup(time);
-         my $cp_error = $cust_pkg->replace;
-         if($cp_error) {
-             warn 'Unable to set setup time on cust_pkg for cc surcharge';
-           # but keep going...
-         }
-                                   
-         my $cust_bill = qsearchs('cust_bill', { 'invnum' => $invnum });
-         unless ( $cust_bill ) {
-             warn "race condition + invoice deletion just happened";
-             return '';
-         }
+      my $cust_bill = qsearchs('cust_bill', { 'invnum' => $invnum });
+      unless ( $cust_bill ) {
+        warn "race condition + invoice deletion just happened";
+        return '';
+      }
+
+      my $grand_error =
+        $cust_bill->add_cc_surcharge($cust_pkg->pkgnum,$options{'cc_surcharge'});
 
 
-         my $grand_error = 
-           $cust_bill->add_cc_surcharge($cust_pkg->pkgnum,$options{'cc_surcharge'});
+      warn "cannot add CC surcharge to invoice #$invnum: $grand_error"
+        if $grand_error;
+    } # end if $options{'cc_surcharge'}
+
+    if ($options{'processing-fee'} > 0) {
+      my $pf_cust_pkg;
+      my $processing_fee_text = 'Payment Processing Fee';
+
+      my $conf = new FS::Conf;
+
+      my $pf_seperate_bill;
+      my $pf_bill_now;
+      if ($conf->exists('processing-fee_on_separate_invoice')) {
+        $pf_seperate_bill = 'Y';
+        $pf_bill_now = '1';
+      }
 
 
-         warn "cannot add CC surcharge to invoice #$invnum: $grand_error"
-           if $grand_error;
+      my $pf_change_error = $self->charge({
+            'amount'  => $options{'processing-fee'},
+            'pkg'   => $processing_fee_text,
+            'setuptax'  => 'Y',
+            'cust_pkg_ref' => \$pf_cust_pkg,
+            'separate_bill' => $pf_seperate_bill,
+            'bill_now' => $pf_bill_now,
+      });
+
+      if($pf_change_error) {
+        warn 'Unable to add payment processing fee';
+        return '';
       }
 
       }
 
+      $pf_cust_pkg->setup(time);
+      my $pf_error = $pf_cust_pkg->replace;
+      if($pf_error) {
+        warn 'Unable to set setup time on cust_pkg for processing fee';
+        # but keep going...
+      }
+
+      if ($conf->exists('processing-fee_on_separate_invoice')) {
+        my $cust_bill_pkg = qsearchs( 'cust_bill_pkg', { 'pkgnum' => $pf_cust_pkg->pkgnum } );
+
+        my $pf_cust_bill = qsearchs('cust_bill', { 'invnum' => $cust_bill_pkg->invnum });
+        unless ( $pf_cust_bill ) {
+          warn "no processing fee inv found!";
+          return '';
+        }
+
+        my $pf_apply_error = $pf_cust_bill->apply_payments_and_credits;
+
+        my $cust_bill = qsearchs('cust_bill', { 'invnum' => $invnum });
+        unless ( $cust_bill ) {
+          warn "race condition + invoice deletion just happened";
+         return '';
+        }
+
+        my $grand_pf_error = $cust_bill->apply_payments_and_credits;
+
+        warn "cannot apply Processing fee to invoice #$invnum: $grand_pf_error - $pf_apply_error"
+          if $grand_pf_error || $pf_apply_error;
+      } ## processing-fee_on_separate_invoice
+      else {
+        my $cust_bill = qsearchs('cust_bill', { 'invnum' => $invnum });
+        unless ( $cust_bill ) {
+          warn "race condition + invoice deletion just happened";
+          return '';
+        }
+
+        my $grand_pf_error =
+          $cust_bill->add_cc_surcharge($pf_cust_pkg->pkgnum,$options{'processing-fee'});
+
+        warn "cannot add Processing fee to invoice #$invnum: $grand_pf_error"
+          if $grand_pf_error;
+      } ## no processing-fee_on_separate_invoice
+    } #end if $options{'processing-fee'}
+
+      } #end if ( $options{'cc_surcharge'} > 0 || $options{'processing-fee'} > 0)
+
       return ''; #no error
 
     }
       return ''; #no error
 
     }
@@ -1073,7 +1208,7 @@ sub _realtime_bop_result {
         };
 
         my $error = send_email(
         };
 
         my $error = send_email(
-          'from'    => $conf->config('invoice_from', $self->agentnum ),
+          'from'    => $conf->invoice_from_full( $self->agentnum ),
           'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
           'subject' => 'Your payment could not be processed',
           'body'    => [ $template->fill_in(HASH => $templ_hash) ],
           'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
           'subject' => 'Your payment could not be processed',
           'body'    => [ $template->fill_in(HASH => $templ_hash) ],
@@ -1103,8 +1238,8 @@ sub _realtime_bop_result {
 
 =item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ]
 
 
 =item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ]
 
-Verifies successful third party processing of a realtime credit card,
-ACH (electronic check) or phone bill transaction via a
+Verifies successful third party processing of a realtime credit card or
+ACH (electronic check) transaction via a
 Business::OnlineThirdPartyPayment realtime gateway.  See
 L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
 
 Business::OnlineThirdPartyPayment realtime gateway.  See
 L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
 
@@ -1204,7 +1339,11 @@ sub realtime_botpp_capture {
     'amount'         => $cust_pay_pending->paid,
     #'invoice_number' => $options{'invnum'},
     'customer_id'    => $self->custnum,
     'amount'         => $cust_pay_pending->paid,
     #'invoice_number' => $options{'invnum'},
     'customer_id'    => $self->custnum,
-    'referer'        => 'http://cleanwhisker.420.am/',
+
+    #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
+    #'referer'        => 'http://cleanwhisker.420.am/',
+
     'reference'      => $cust_pay_pending->paypendingnum,
     'email'          => $email,
     'phone'          => $self->daytime || $self->night,
     'reference'      => $cust_pay_pending->paypendingnum,
     'email'          => $email,
     'phone'          => $self->daytime || $self->night,
@@ -1262,20 +1401,20 @@ sub default_payment_gateway {
 
 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
 
 
 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
 
-Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
+Refunds a realtime credit card or ACH (electronic check) transaction
 via a Business::OnlinePayment realtime gateway.  See
 L<http://420.am/business-onlinepayment> for supported gateways.
 
 via a Business::OnlinePayment realtime gateway.  See
 L<http://420.am/business-onlinepayment> for supported gateways.
 
-Available methods are: I<CC>, I<ECHECK> and I<LEC>
+Available methods are: I<CC> or I<ECHECK>
 
 
-Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
+Available options are: I<amount>, I<reasonnum>, I<paynum>, I<paydate>
 
 Most gateways require a reference to an original payment transaction to refund,
 so you probably need to specify a I<paynum>.
 
 I<amount> defaults to the original amount of the payment if not specified.
 
 
 Most gateways require a reference to an original payment transaction to refund,
 so you probably need to specify a I<paynum>.
 
 I<amount> defaults to the original amount of the payment if not specified.
 
-I<reason> specifies a reason for the refund.
+I<reasonnum> specifies a reason for the refund.
 
 I<paydate> specifies the expiration date for a credit card overriding the
 value from the customer record or the payment record. Specified as yyyy-mm-dd
 
 I<paydate> specifies the expiration date for a credit card overriding the
 value from the customer record or the payment record. Specified as yyyy-mm-dd
@@ -1284,7 +1423,8 @@ Implementation note: If I<amount> is unspecified or equal to the amount of the
 orignal payment, first an attempt is made to "void" the transaction via
 the gateway (to cancel a not-yet settled transaction) and then if that fails,
 the normal attempt is made to "refund" ("credit") the transaction via the
 orignal payment, first an attempt is made to "void" the transaction via
 the gateway (to cancel a not-yet settled transaction) and then if that fails,
 the normal attempt is made to "refund" ("credit") the transaction via the
-gateway is attempted.
+gateway is attempted. No attempt to "void" the transaction is made if the 
+gateway has introspection data and doesn't support void.
 
 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
 #I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
 
 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
 #I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
@@ -1312,11 +1452,34 @@ sub realtime_refund_bop {
     $options{method} = $method;
   }
 
     $options{method} = $method;
   }
 
+  my ($reason, $reason_text);
+  if ( $options{'reasonnum'} ) {
+    # do this here, because we need the plain text reason string in case we
+    # void the payment
+    $reason = FS::reason->by_key($options{'reasonnum'});
+    $reason_text = $reason->reason;
+  } else {
+    # support old 'reason' string parameter in case it's still used,
+    # or else set a default
+    $reason_text = $options{'reason'} || 'card or ACH refund';
+    local $@;
+    $reason = FS::reason->new_or_existing(
+      reason  => $reason_text,
+      type    => 'Refund reason',
+      class   => 'F',
+    );
+    if ($@) {
+      return "failed to add refund reason: $@";
+    }
+  }
+
   if ( $DEBUG ) {
     warn "$me realtime_refund_bop (new): $options{method} refund\n";
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
   if ( $DEBUG ) {
     warn "$me realtime_refund_bop (new): $options{method} refund\n";
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
+  my %content = ();
+
   ###
   # look up the original payment and optionally a gateway for that payment
   ###
   ###
   # look up the original payment and optionally a gateway for that payment
   ###
@@ -1326,6 +1489,7 @@ sub realtime_refund_bop {
 
   my( $processor, $login, $password, @bop_options, $namespace ) ;
   my( $auth, $order_number ) = ( '', '', '' );
 
   my( $processor, $login, $password, @bop_options, $namespace ) ;
   my( $auth, $order_number ) = ( '', '', '' );
+  my $gatewaynum = '';
 
   if ( $options{'paynum'} ) {
 
 
   if ( $options{'paynum'} ) {
 
@@ -1334,11 +1498,25 @@ sub realtime_refund_bop {
       or return "Unknown paynum $options{'paynum'}";
     $amount ||= $cust_pay->paid;
 
       or return "Unknown paynum $options{'paynum'}";
     $amount ||= $cust_pay->paid;
 
-    $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
-      or return "Can't parse paybatch for paynum $options{'paynum'}: ".
-                $cust_pay->paybatch;
-    my $gatewaynum = '';
-    ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
+    my @cust_bill_pay = qsearch('cust_bill_pay', { paynum=>$cust_pay->paynum });
+    $content{'invoice_number'} = $cust_bill_pay[0]->invnum if @cust_bill_pay;
+
+    if ( $cust_pay->get('processor') ) {
+      ($gatewaynum, $processor, $auth, $order_number) =
+      (
+        $cust_pay->gatewaynum,
+        $cust_pay->processor,
+        $cust_pay->auth,
+        $cust_pay->order_number,
+      );
+    } else {
+      # this payment wasn't upgraded, which probably means this won't work,
+      # but try it anyway
+      $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
+        or return "Can't parse paybatch for paynum $options{'paynum'}: ".
+                  $cust_pay->paybatch;
+      ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
+    }
 
     if ( $gatewaynum ) { #gateway for the payment to be refunded
 
 
     if ( $gatewaynum ) { #gateway for the payment to be refunded
 
@@ -1381,7 +1559,7 @@ sub realtime_refund_bop {
       $self->agent->payment_gateway( 'method'  => $options{method},
                                      #'payinfo' => $payinfo,
                                    );
       $self->agent->payment_gateway( 'method'  => $options{method},
                                      #'payinfo' => $payinfo,
                                    );
-    my( $processor, $login, $password, $namespace ) =
+    ( $processor, $login, $password, $namespace ) =
       map { my $method = "gateway_$_"; $payment_gateway->$method }
         qw( module username password namespace );
 
       map { my $method = "gateway_$_"; $payment_gateway->$method }
         qw( module username password namespace );
 
@@ -1395,18 +1573,26 @@ sub realtime_refund_bop {
   eval "use $namespace";  
   die $@ if $@;
 
   eval "use $namespace";  
   die $@ if $@;
 
-  my %content = (
+  %content = (
+    %content,
     'type'           => $options{method},
     'login'          => $login,
     'password'       => $password,
     'order_number'   => $order_number,
     'amount'         => $amount,
     'type'           => $options{method},
     'login'          => $login,
     'password'       => $password,
     'order_number'   => $order_number,
     'amount'         => $amount,
-    'referer'        => 'http://cleanwhisker.420.am/', #XXX fix referer :/
+
+    #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
+    #'referer'        => 'http://cleanwhisker.420.am/',
   );
   $content{authorization} = $auth
     if length($auth); #echeck/ACH transactions have an order # but no auth
                       #(at least with authorize.net)
 
   );
   $content{authorization} = $auth
     if length($auth); #echeck/ACH transactions have an order # but no auth
                       #(at least with authorize.net)
 
+  my $currency =    $conf->exists('business-onlinepayment-currency')
+                 && $conf->config('business-onlinepayment-currency');
+  $content{currency} = $currency if $currency;
+
   my $disable_void_after;
   if ($conf->exists('disable_void_after')
       && $conf->config('disable_void_after') =~ /^(\d+)$/) {
   my $disable_void_after;
   if ($conf->exists('disable_void_after')
       && $conf->config('disable_void_after') =~ /^(\d+)$/) {
@@ -1414,14 +1600,28 @@ sub realtime_refund_bop {
   }
 
   #first try void if applicable
   }
 
   #first try void if applicable
+  my $void = new Business::OnlinePayment( $processor, @bop_options );
+
+  my $tryvoid = 1;
+  if ($void->can('info')) {
+      my $paytype = '';
+      $paytype = 'ECHECK' if $cust_pay && $cust_pay->payby eq 'CHEK';
+      $paytype = 'CC' if $cust_pay && $cust_pay->payby eq 'CARD';
+      my %supported_actions = $void->info('supported_actions');
+      $tryvoid = 0 
+        if ( %supported_actions && $paytype 
+                && defined($supported_actions{$paytype}) 
+                && !grep{ $_ eq 'Void' } @{$supported_actions{$paytype}} );
+  }
+
   if ( $cust_pay && $cust_pay->paid == $amount
     && (
       ( not defined($disable_void_after) )
       || ( time < ($cust_pay->_date + $disable_void_after ) )
     )
   if ( $cust_pay && $cust_pay->paid == $amount
     && (
       ( not defined($disable_void_after) )
       || ( time < ($cust_pay->_date + $disable_void_after ) )
     )
+    && $tryvoid
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
-    my $void = new Business::OnlinePayment( $processor, @bop_options );
     if ( $void->can('info') ) {
       if ( $cust_pay->payby eq 'CARD'
            && $void->info('CC_void_requires_card') )
     if ( $void->can('info') ) {
       if ( $cust_pay->payby eq 'CARD'
            && $void->info('CC_void_requires_card') )
@@ -1440,7 +1640,7 @@ sub realtime_refund_bop {
       if $conf->exists('business-onlinepayment-test_transaction');
     $void->submit();
     if ( $void->is_success ) {
       if $conf->exists('business-onlinepayment-test_transaction');
     $void->submit();
     if ( $void->is_success ) {
-      my $error = $cust_pay->void($options{'reason'});
+      my $error = $cust_pay->void($reason_text);
       if ( $error ) {
         # gah, even with transactions.
         my $e = 'WARNING: Card/ACH voided but database not updated - '.
       if ( $error ) {
         # gah, even with transactions.
         my $e = 'WARNING: Card/ACH voided but database not updated - '.
@@ -1517,8 +1717,7 @@ sub realtime_refund_bop {
     $content{account_name} = $payname;
     $content{customer_org} = $self->company ? 'B' : 'I';
     $content{customer_ssn} = $self->ss;
     $content{account_name} = $payname;
     $content{customer_org} = $self->company ? 'B' : 'I';
     $content{customer_ssn} = $self->ss;
-  } elsif ( $options{method} eq 'LEC' ) {
-    $content{phone} = $payinfo = $self->payinfo;
+
   }
 
   #then try refund
   }
 
   #then try refund
@@ -1547,10 +1746,9 @@ sub realtime_refund_bop {
   return "$processor error: ". $refund->error_message
     unless $refund->is_success();
 
   return "$processor error: ". $refund->error_message
     unless $refund->is_success();
 
-  my $paybatch = "$processor:". $refund->authorization;
-  $paybatch .= ':'. $refund->order_number
-    if $refund->can('order_number') && $refund->order_number;
+  $order_number = $refund->order_number if $refund->can('order_number');
 
 
+  # change this to just use $cust_pay->delete_cust_bill_pay?
   while ( $cust_pay && $cust_pay->unapplied < $amount ) {
     my @cust_bill_pay = $cust_pay->cust_bill_pay;
     last unless @cust_bill_pay;
   while ( $cust_pay && $cust_pay->unapplied < $amount ) {
     my @cust_bill_pay = $cust_pay->cust_bill_pay;
     last unless @cust_bill_pay;
@@ -1562,16 +1760,21 @@ sub realtime_refund_bop {
   my $cust_refund = new FS::cust_refund ( {
     'custnum'  => $self->custnum,
     'paynum'   => $options{'paynum'},
   my $cust_refund = new FS::cust_refund ( {
     'custnum'  => $self->custnum,
     'paynum'   => $options{'paynum'},
+    'source_paynum' => $options{'paynum'},
     'refund'   => $amount,
     '_date'    => '',
     'payby'    => $bop_method2payby{$options{method}},
     'payinfo'  => $payinfo,
     'refund'   => $amount,
     '_date'    => '',
     'payby'    => $bop_method2payby{$options{method}},
     'payinfo'  => $payinfo,
-    'paybatch' => $paybatch,
-    'reason'   => $options{'reason'} || 'card or ACH refund',
+    'reasonnum'   => $reason->reasonnum,
+    'gatewaynum'    => $gatewaynum, # may be null
+    'processor'     => $processor,
+    'auth'          => $refund->authorization,
+    'order_number'  => $order_number,
   } );
   my $error = $cust_refund->insert;
   if ( $error ) {
     $cust_refund->paynum(''); #try again with no specific paynum
   } );
   my $error = $cust_refund->insert;
   if ( $error ) {
     $cust_refund->paynum(''); #try again with no specific paynum
+    $cust_refund->source_paynum('');
     my $error2 = $cust_refund->insert;
     if ( $error2 ) {
       # gah, even with transactions.
     my $error2 = $cust_refund->insert;
     if ( $error2 ) {
       # gah, even with transactions.
@@ -1588,6 +1791,443 @@ sub realtime_refund_bop {
 
 }
 
 
 }
 
+=item realtime_verify_bop [ OPTION => VALUE ... ]
+
+Runs an authorization-only transaction for $1 against this credit card (if
+successful, immediatly reverses the authorization).
+
+Returns the empty string if the authorization was sucessful, or an error
+message otherwise.
+
+I<payinfo>
+
+I<payname>
+
+I<paydate> specifies the expiration date for a credit card overriding the
+value from the customer record or the payment record. Specified as yyyy-mm-dd
+
+#The additional options I<address1>, I<address2>, I<city>, I<state>,
+#I<zip> are also available.  Any of these options,
+#if set, will override the value from the customer record.
+
+=cut
+
+#Available methods are: I<CC> or I<ECHECK>
+
+#some false laziness w/realtime_bop and realtime_refund_bop, not enough to make
+#it worth merging but some useful small subs should be pulled out
+sub realtime_verify_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_verify_bop');
+
+  my %options = ();
+  if (ref($_[0]) eq 'HASH') {
+    %options = %{$_[0]};
+  } else {
+    %options = @_;
+  }
+
+  if ( $DEBUG ) {
+    warn "$me realtime_verify_bop\n";
+    warn "  $_ => $options{$_}\n" foreach keys %options;
+  }
+
+  ###
+  # select a gateway
+  ###
+
+  my $payment_gateway =  $self->_payment_gateway( \%options );
+  my $namespace = $payment_gateway->gateway_namespace;
+
+  eval "use $namespace";  
+  die $@ if $@;
+
+  ###
+  # check for banned credit card/ACH
+  ###
+
+  my $ban = FS::banned_pay->ban_search(
+    'payby'   => $bop_method2payby{'CC'},
+    'payinfo' => $options{payinfo} || $self->payinfo,
+  );
+  return "Banned credit card" if $ban && $ban->bantype ne 'warn';
+
+  ###
+  # massage data
+  ###
+
+  my $bop_content = $self->_bop_content(\%options);
+  return $bop_content unless ref($bop_content);
+
+  my @invoicing_list = $self->invoicing_list_emailonly;
+  if ( $conf->exists('emailinvoiceautoalways')
+       || $conf->exists('emailinvoiceauto') && ! @invoicing_list
+       || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
+    push @invoicing_list, $self->all_emails;
+  }
+
+  my $email = ($conf->exists('business-onlinepayment-email-override'))
+              ? $conf->config('business-onlinepayment-email-override')
+              : $invoicing_list[0];
+
+  my $paydate = '';
+  my %content = ();
+
+  if ( $namespace eq 'Business::OnlinePayment' ) {
+
+    if ( $options{method} eq 'CC' ) {
+
+      $content{card_number} = $options{payinfo} || $self->payinfo;
+      $paydate = exists($options{'paydate'})
+                      ? $options{'paydate'}
+                      : $self->paydate;
+      $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+      $content{expiration} = "$2/$1";
+
+      my $paycvv = exists($options{'paycvv'})
+                     ? $options{'paycvv'}
+                     : $self->paycvv;
+      $content{cvv2} = $paycvv
+        if length($paycvv);
+
+      my $paystart_month = exists($options{'paystart_month'})
+                             ? $options{'paystart_month'}
+                             : $self->paystart_month;
+
+      my $paystart_year  = exists($options{'paystart_year'})
+                             ? $options{'paystart_year'}
+                             : $self->paystart_year;
+
+      $content{card_start} = "$paystart_month/$paystart_year"
+        if $paystart_month && $paystart_year;
+
+      my $payissue       = exists($options{'payissue'})
+                             ? $options{'payissue'}
+                             : $self->payissue;
+      $content{issue_number} = $payissue if $payissue;
+
+    } elsif ( $options{method} eq 'ECHECK' ){
+
+      #nop for checks (though it shouldn't be called...)
+
+    } else {
+      die "unknown method ". $options{method};
+    }
+
+  } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
+    #move along
+  } else {
+    die "unknown namespace $namespace";
+  }
+
+  ###
+  # run transaction(s)
+  ###
+
+  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.
+
+  my $cust_pay_pending = new FS::cust_pay_pending {
+    'custnum_pending'   => 1,
+    'paid'              => '1.00',
+    '_date'             => '',
+    'payby'             => $bop_method2payby{'CC'},
+    '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});
+
+  IMMEDIATE: {
+    # open a separate handle for creating/updating the cust_pay_pending
+    # record
+    local $FS::UID::dbh = myconnect();
+    local $FS::UID::AutoCommit = 1;
+
+    # if this is an existing customer (and we can tell now because
+    # this is a fresh transaction), it's safe to assign their custnum
+    # to the cust_pay_pending record, and then the verification attempt
+    # will remain linked to them even if it fails.
+    if ( FS::cust_main->by_key($self->custnum) ) {
+      $cust_pay_pending->set('custnum', $self->custnum);
+    }
+
+    warn "inserting cust_pay_pending record for customer ". $self->custnum. "\n"
+      if $DEBUG > 1;
+
+    # if this fails, just return; everything else will still allow the
+    # cust_pay_pending to have its custnum set later
+    my $cpp_new_err = $cust_pay_pending->insert;
+    return $cpp_new_err if $cpp_new_err;
+
+    warn "inserted cust_pay_pending record for customer ". $self->custnum. "\n"
+      if $DEBUG > 1;
+    warn Dumper($cust_pay_pending) if $DEBUG > 2;
+
+    $transaction = new $namespace( $payment_gateway->gateway_module,
+                                   $self->_bop_options(\%options),
+                                    );
+
+    $transaction->content(
+      'type'           => 'CC',
+      $self->_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
+    );
+
+    $cust_pay_pending->status('pending');
+    my $cpp_pending_err = $cust_pay_pending->replace;
+    return $cpp_pending_err if $cpp_pending_err;
+
+    warn Dumper($transaction) if $DEBUG > 2;
+
+    unless ( $BOP_TESTING ) {
+      $transaction->test_transaction(1)
+        if $conf->exists('business-onlinepayment-test_transaction');
+      $transaction->submit();
+    } else {
+      if ( $BOP_TESTING_SUCCESS ) {
+        $transaction->is_success(1);
+        $transaction->authorization('fake auth');
+      } else {
+        $transaction->is_success(0);
+        $transaction->error_message('fake failure');
+      }
+    }
+
+    if ( $transaction->is_success() ) {
+
+      $cust_pay_pending->status('authorized');
+      my $cpp_authorized_err = $cust_pay_pending->replace;
+      return $cpp_authorized_err if $cpp_authorized_err;
+
+      my $auth = $transaction->authorization;
+      my $ordernum = $transaction->can('order_number')
+                     ? $transaction->order_number
+                     : '';
+
+      my $reverse = new $namespace( $payment_gateway->gateway_module,
+                                    $self->_bop_options(\%options),
+                                  );
+
+      $reverse->content( 'action'        => 'Reverse Authorization',
+                         $self->_bop_auth(\%options),          
+
+                         # B:OP
+                         'amount'        => '1.00',
+                         'authorization' => $transaction->authorization,
+                         'order_number'  => $ordernum,
+
+                         # vsecure
+                         'result_code'   => $transaction->result_code,
+                         'txn_date'      => $transaction->txn_date,
+
+                         %content,
+                       );
+      $reverse->test_transaction(1)
+        if $conf->exists('business-onlinepayment-test_transaction');
+      $reverse->submit();
+
+      if ( $reverse->is_success ) {
+
+        $cust_pay_pending->status('done');
+        $cust_pay_pending->statustext('reversed');
+        my $cpp_reversed_err = $cust_pay_pending->replace;
+        return $cpp_reversed_err if $cpp_reversed_err;
+
+      } else {
+
+        my $e = "Authorization successful but reversal failed, custnum #".
+                $self->custnum. ': '.  $reverse->result_code.
+                ": ". $reverse->error_message;
+        $log->warning($e);
+        warn $e;
+        return $e;
+
+      }
+
+      ### Address Verification ###
+      #
+      # Single-letter codes vary by cardtype.
+      #
+      # Erring on the side of accepting cards if avs is not available,
+      # only rejecting if avs occurred and there's been an explicit mismatch
+      #
+      # Charts below taken from vSecure documentation,
+      #    shows codes for Amex/Dscv/MC/Visa
+      #
+      # ACCEPTABLE AVS RESPONSES:
+      # Both Address and 5-digit postal code match Y A Y Y
+      # Both address and 9-digit postal code match Y A X Y
+      # United Kingdom – Address and postal code match _ _ _ F
+      # International transaction – Address and postal code match _ _ _ D/M
+      #
+      # ACCEPTABLE, BUT ISSUE A WARNING:
+      # Ineligible transaction; or message contains a content error _ _ _ E
+      # System unavailable; retry R U R R
+      # Information unavailable U W U U
+      # Issuer does not support AVS S U S S
+      # AVS is not applicable _ _ _ S
+      # Incompatible formats – Not verified _ _ _ C
+      # Incompatible formats – Address not verified; postal code matches _ _ _ P
+      # International transaction – address not verified _ G _ G/I
+      #
+      # UNACCEPTABLE AVS RESPONSES:
+      # Only Address matches A Y A A
+      # Only 5-digit postal code matches Z Z Z Z
+      # Only 9-digit postal code matches Z Z W W
+      # Neither address nor postal code matches N N N N
+
+      if (my $avscode = uc($transaction->avs_code)) {
+
+        # map codes to accept/warn/reject
+        my $avs = {
+          'American Express card' => {
+            'A' => 'r',
+            'N' => 'r',
+            'R' => 'w',
+            'S' => 'w',
+            'U' => 'w',
+            'Y' => 'a',
+            'Z' => 'r',
+          },
+          'Discover card' => {
+            'A' => 'a',
+            'G' => 'w',
+            'N' => 'r',
+            'U' => 'w',
+            'W' => 'w',
+            'Y' => 'r',
+            'Z' => 'r',
+          },
+          'MasterCard' => {
+            'A' => 'r',
+            'N' => 'r',
+            'R' => 'w',
+            'S' => 'w',
+            'U' => 'w',
+            'W' => 'r',
+            'X' => 'a',
+            'Y' => 'a',
+            'Z' => 'r',
+          },
+          'VISA card' => {
+            'A' => 'r',
+            'C' => 'w',
+            'D' => 'a',
+            'E' => 'w',
+            'F' => 'a',
+            'G' => 'w',
+            'I' => 'w',
+            'M' => 'a',
+            'N' => 'r',
+            'P' => 'w',
+            'R' => 'w',
+            'S' => 'w',
+            'U' => 'w',
+            'W' => 'r',
+            'Y' => 'a',
+            'Z' => 'r',
+          },
+        };
+        my $cardtype = cardtype($content{card_number});
+        if ($avs->{$cardtype}) {
+          my $avsact = $avs->{$cardtype}->{$avscode};
+          my $warning = '';
+          if ($avsact eq 'r') {
+            return "AVS code verification failed, cardtype $cardtype, code $avscode";
+          } elsif ($avsact eq 'w') {
+            $warning = "AVS did not occur, cardtype $cardtype, code $avscode";
+          } elsif (!$avsact) {
+            $warning = "AVS code unknown, cardtype $cardtype, code $avscode";
+          } # else $avsact eq 'a'
+          if ($warning) {
+            $log->warning($warning);
+            warn $warning;
+          }
+        } # else $cardtype avs handling not implemented
+      } # else !$transaction->avs_code
+
+    } else { # is not success
+
+      # status is 'done' not 'declined', as in _realtime_bop_result
+      $cust_pay_pending->status('done');
+      $error = $transaction->error_message || 'Unknown error';
+      $cust_pay_pending->statustext($error);
+      # could also record failure_status here,
+      #   but it's not supported by B::OP::vSecureProcessing...
+      #   need a B::OP module with (reverse) auth only to test it with
+      my $cpp_declined_err = $cust_pay_pending->replace;
+      return $cpp_declined_err if $cpp_declined_err;
+
+    }
+
+  } # end of IMMEDIATE; we now have our $error and $transaction
+
+  ###
+  # Save the custnum (as part of the main transaction, so it can reference
+  # the cust_main)
+  ###
+
+  if (!$cust_pay_pending->custnum) {
+    $cust_pay_pending->set('custnum', $self->custnum);
+    my $set_custnum_err = $cust_pay_pending->replace;
+    if ($set_custnum_err) {
+      $log->error($set_custnum_err);
+      $error ||= $set_custnum_err;
+      # but if there was a real verification error also, return that one
+    }
+  }
+
+  ###
+  # Tokenize
+  ###
+
+  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;
+      }
+    }
+
+  }
+
+  ###
+  # result handling
+  ###
+
+  # $error contains the transaction error_message, if is_success was false.
+  return $error;
+
+}
+
 =back
 
 =head1 BUGS
 =back
 
 =head1 BUGS