RT#38314: Declined payment shows card as tokenized after first attempt [fixed if...
[freeside.git] / FS / FS / cust_main / Billing_Realtime.pm
index 500d2c2..9112607 100644 (file)
@@ -3,16 +3,16 @@ package FS::cust_main::Billing_Realtime;
 use strict;
 use vars qw( $conf $DEBUG $me );
 use vars qw( $realtime_bop_decline_quiet ); #ugh
 use strict;
 use vars qw( $conf $DEBUG $me );
 use vars qw( $realtime_bop_decline_quiet ); #ugh
+use Carp;
 use Data::Dumper;
 use Data::Dumper;
-use Digest::MD5 qw(md5_base64);
 use Business::CreditCard 0.28;
 use FS::UID qw( dbh );
 use FS::Record qw( qsearch qsearchs );
 use Business::CreditCard 0.28;
 use FS::UID qw( dbh );
 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_refund;
 use FS::payby;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::cust_refund;
+use FS::banned_pay;
 
 $realtime_bop_decline_quiet = 0;
 
 
 $realtime_bop_decline_quiet = 0;
 
@@ -22,6 +22,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)
@@ -41,6 +44,36 @@ These methods are available on FS::cust_main objects.
 
 =over 4
 
 
 =over 4
 
+=item realtime_cust_payby
+
+=cut
+
+sub realtime_cust_payby {
+  my( $self, %options ) = @_;
+
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+  $options{amount} = $self->balance unless exists( $options{amount} );
+
+  my @cust_payby = qsearch({
+    'table'     => 'cust_payby',
+    'hashref'   => { 'custnum' => $self->custnum, },
+    'extra_sql' => " AND payby IN ( 'CARD', 'CHEK' ) ",
+    'order_by'  => 'ORDER BY weight ASC',
+  });
+                                                   
+  my $error;
+  foreach my $cust_payby (@cust_payby) {
+    $error = $cust_payby->realtime_bop( %options, );
+    last unless $error;
+  }
+
+  #XXX what about the earlier errors?
+
+  $error;
+
+}
+
 =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 
@@ -108,7 +141,7 @@ L<http://420.am/business-onlinepayment> for supported gateways.
 
 Required arguments in the hashref are I<method>, and I<amount>
 
 
 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>, I<LEC>, and 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>
 
@@ -138,7 +171,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 +200,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
 
   }
 
 
   }
 
@@ -303,9 +330,12 @@ sub _bop_content {
                         ? $options->{country}
                         : $self->country;
 
                         ? $options->{country}
                         : $self->country;
 
-  $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/
   $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;
 }
 
   \%content;
 }
 
@@ -313,11 +343,16 @@ my %bop_method2payby = (
   'CC'     => 'CARD',
   'ECHECK' => 'CHEK',
   'LEC'    => 'LECB',
   'CC'     => 'CARD',
   'ECHECK' => 'CHEK',
   'LEC'    => 'LECB',
+  'PAYPAL' => 'PPAL',
 );
 
 sub realtime_bop {
   my $self = shift;
 
 );
 
 sub realtime_bop {
   my $self = shift;
 
+  confess "Can't call realtime_bop within another transaction ".
+          '($FS::UID::AutoCommit is false)'
+    unless $FS::UID::AutoCommit;
+
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
  
   my %options = ();
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
  
   my %options = ();
@@ -338,8 +373,9 @@ sub realtime_bop {
   my $cc_surcharge = 0;
   my $cc_surcharge_pct = 0;
   $cc_surcharge_pct = $conf->config('credit-card-surcharge-percentage') 
   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');
-  
+    if $conf->config('credit-card-surcharge-percentage')
+    && $options{method} eq 'CC';
+
   # 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;
   # 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;
@@ -359,6 +395,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 +439,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 +483,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';
+
+      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;
+
+    } elsif ( $options{method} eq 'LEC' ) {
+      $content{phone} = $options{payinfo};
+    } 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 +579,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 +596,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 +622,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 +633,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 +661,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 +670,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 +688,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 +766,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 +805,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 +813,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});
 
@@ -812,17 +878,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 +888,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 || '',
+
     } );
     #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} )
@@ -893,6 +955,8 @@ sub _realtime_bop_result {
          return $e;
        }
 
          return $e;
        }
 
+       $cust_pay_pending->set('jobnum','');
+
     }
     
     if ( $options{'paynum_ref'} ) {
     }
     
     if ( $options{'paynum_ref'} ) {
@@ -990,8 +1054,9 @@ sub _realtime_bop_result {
 
   } else {
 
 
   } else {
 
-    my $perror = $payment_gateway->gateway_module. " error: ".
-      $transaction->error_message;
+    my $perror = $transaction->error_message;
+    #$payment_gateway->gateway_module. " error: ".
+    # removed for conciseness
 
     my $jobnum = $cust_pay_pending->jobnum;
     if ( $jobnum ) {
 
     my $jobnum = $cust_pay_pending->jobnum;
     if ( $jobnum ) {
@@ -1000,8 +1065,9 @@ sub _realtime_bop_result {
        if ( $placeholder ) {
          my $error = $placeholder->depended_delete;
          $error ||= $placeholder->delete;
        if ( $placeholder ) {
          my $error = $placeholder->depended_delete;
          $error ||= $placeholder->delete;
+         $cust_pay_pending->set('jobnum','');
          warn "error removing provisioning jobs after declined paypendingnum ".
          warn "error removing provisioning jobs after declined paypendingnum ".
-           $cust_pay_pending->paypendingnum. ": $error\n";
+           $cust_pay_pending->paypendingnum. ": $error\n" if $error;
        } else {
          my $e = "error finding job $jobnum for declined paypendingnum ".
               $cust_pay_pending->paypendingnum. "\n";
        } else {
          my $e = "error finding job $jobnum for declined paypendingnum ".
               $cust_pay_pending->paypendingnum. "\n";
@@ -1057,31 +1123,7 @@ sub _realtime_bop_result {
         $error = $msg_template->send( 'cust_main' => $self,
                                       'object'    => $cust_pay_pending );
       }
         $error = $msg_template->send( 'cust_main' => $self,
                                       'object'    => $cust_pay_pending );
       }
-      else { #!$msgnum
-
-        my @templ = $conf->config('declinetemplate');
-        my $template = new Text::Template (
-          TYPE   => 'ARRAY',
-          SOURCE => [ map "$_\n", @templ ],
-        ) or return "($perror) can't create template: $Text::Template::ERROR";
-        $template->compile()
-          or return "($perror) can't compile template: $Text::Template::ERROR";
-
-        my $templ_hash = {
-          'company_name'    =>
-            scalar( $conf->config('company_name', $self->agentnum ) ),
-          'company_address' =>
-            join("\n", $conf->config('company_address', $self->agentnum ) ),
-          'error'           => $transaction->error_message,
-        };
-
-        my $error = send_email(
-          'from'    => $conf->config('invoice_from', $self->agentnum ),
-          'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
-          'subject' => 'Your payment could not be processed',
-          'body'    => [ $template->fill_in(HASH => $templ_hash) ],
-        );
-      }
+
 
       $perror .= " (also received error sending decline notification: $error)"
         if $error;
 
       $perror .= " (also received error sending decline notification: $error)"
         if $error;
@@ -1089,7 +1131,11 @@ sub _realtime_bop_result {
     }
 
     $cust_pay_pending->status('done');
     }
 
     $cust_pay_pending->status('done');
-    $cust_pay_pending->statustext("declined: $perror");
+    $cust_pay_pending->statustext($perror);
+    #'declined:': no, that's failure_status
+    if ( $transaction->can('failure_status') ) {
+      $cust_pay_pending->failure_status( $transaction->failure_status );
+    }
     my $cpp_done_err = $cust_pay_pending->replace;
     if ( $cpp_done_err ) {
       my $e = "WARNING: $options{method} declined but pending payment not ".
     my $cpp_done_err = $cust_pay_pending->replace;
     if ( $cpp_done_err ) {
       my $e = "WARNING: $options{method} declined but pending payment not ".
@@ -1207,7 +1253,6 @@ 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/',
     'reference'      => $cust_pay_pending->paypendingnum,
     'email'          => $email,
     'phone'          => $self->daytime || $self->night,
     'reference'      => $cust_pay_pending->paypendingnum,
     'email'          => $email,
     'phone'          => $self->daytime || $self->night,
@@ -1287,7 +1332,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,
@@ -1329,6 +1375,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'} ) {
 
@@ -1337,11 +1384,22 @@ 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 );
+    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
 
@@ -1404,12 +1462,15 @@ sub realtime_refund_bop {
     'password'       => $password,
     'order_number'   => $order_number,
     'amount'         => $amount,
     'password'       => $password,
     'order_number'   => $order_number,
     'amount'         => $amount,
-    'referer'        => 'http://cleanwhisker.420.am/', #XXX fix referer :/
   );
   $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+)$/) {
@@ -1417,14 +1478,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') )
@@ -1550,10 +1625,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;
@@ -1569,8 +1643,11 @@ sub realtime_refund_bop {
     '_date'    => '',
     'payby'    => $bop_method2payby{$options{method}},
     'payinfo'  => $payinfo,
     '_date'    => '',
     'payby'    => $bop_method2payby{$options{method}},
     'payinfo'  => $payinfo,
-    'paybatch' => $paybatch,
     'reason'   => $options{'reason'} || 'card or ACH refund',
     'reason'   => $options{'reason'} || 'card or ACH refund',
+    'gatewaynum'    => $gatewaynum, # may be null
+    'processor'     => $processor,
+    'auth'          => $refund->authorization,
+    'order_number'  => $order_number,
   } );
   my $error = $cust_refund->insert;
   if ( $error ) {
   } );
   my $error = $cust_refund->insert;
   if ( $error ) {