Billing_Realtime.pm: skip void attempt on refund if gateway doesn't support void...
[freeside.git] / FS / FS / cust_main / Billing_Realtime.pm
index ba1e9c8..545e943 100644 (file)
@@ -43,14 +43,11 @@ These methods are available on FS::cust_main objects.
 
 =item realtime_collect [ OPTION => VALUE ... ]
 
 
 =item realtime_collect [ OPTION => VALUE ... ]
 
-Runs a realtime credit card, ACH (electronic check) or phone bill transaction
-via a Business::OnlinePayment or Business::OnlineThirdPartyPayment realtime
-gateway.  See L<http://420.am/business-onlinepayment> and 
-L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
-
-On failure returns an error message.
+Attempt to collect the customer's current balance with a realtime credit 
+card, electronic check, or phone bill transaction (see realtime_bop() below).
 
 
-Returns false or a hashref upon success.  The hashref contains keys popup_url reference, and collectitems.  The first is a URL to which a browser should be redirected for completion of collection.  The second is a reference id for the transaction suitable for the end user.  The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url.
+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>
 
 
 Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>, I<pkgnum>
 
@@ -68,12 +65,11 @@ the value defined by the business-onlinepayment-description configuration
 option, or "Internet services" if that is unset.
 
 If an I<invnum> is specified, this payment (if successful) is applied to the
 option, or "Internet services" if that is unset.
 
 If an I<invnum> is specified, this payment (if successful) is applied to the
-specified invoice.  If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method or set the I<apply> option.
+specified invoice.
 
 
-I<apply> can be set to true to apply a resulting payment.
+I<apply> will automatically apply a resulting payment.
 
 
-I<quiet> can be set true to surpress email decline notices.
+I<quiet> can be set true to suppress email decline notices.
 
 I<paynum_ref> can be set to a scalar reference.  It will be filled in with the
 resulting paynum, if any.
 
 I<paynum_ref> can be set to a scalar reference.  It will be filled in with the
 resulting paynum, if any.
@@ -125,8 +121,9 @@ the value defined by the business-onlinepayment-description configuration
 option, or "Internet services" if that is unset.
 
 If an I<invnum> is specified, this payment (if successful) is applied to the
 option, or "Internet services" if that is unset.
 
 If an I<invnum> is specified, this payment (if successful) is applied to the
-specified invoice.  If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method or set the I<apply> option.
+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.
 
 I<apply> can be set to true to apply a resulting payment.
 
 
 I<apply> can be set to true to apply a resulting payment.
 
@@ -143,6 +140,16 @@ 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
 
+A direct (Business::OnlinePayment) transaction will return nothing on success,
+or an error message on failure.
+
+A third-party transaction will return a hashref containing:
+
+- popup_url: the URL to which a browser should be redirected to complete 
+  the transaction.
+- collectitems: an arrayref of name-value pairs to be posted to popup_url.
+- reference: a reference ID for the transaction, to show the customer.
+
 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
 
 =cut
 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
 
 =cut
@@ -178,6 +185,21 @@ sub _bop_recurring_billing {
 sub _payment_gateway {
   my ($self, $options) = @_;
 
 sub _payment_gateway {
   my ($self, $options) = @_;
 
+  if ( $options->{'selfservice'} ) {
+    my $gatewaynum = FS::Conf->new->config('selfservice-payment_gateway');
+    if ( $gatewaynum ) {
+      return $options->{payment_gateway} ||= 
+          qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
+    }
+  }
+
+  if ( $options->{'fake_gatewaynum'} ) {
+       $options->{payment_gateway} =
+           qsearchs('payment_gateway',
+                     { 'gatewaynum' => $options->{'fake_gatewaynum'}, }
+                   );
+  }
+
   $options->{payment_gateway} = $self->agent->payment_gateway( %$options )
     unless exists($options->{payment_gateway});
 
   $options->{payment_gateway} = $self->agent->payment_gateway( %$options )
     unless exists($options->{payment_gateway});
 
@@ -218,7 +240,14 @@ sub _bop_defaults {
   }
 
   $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
   }
 
   $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
-  $options->{invnum} ||= '';
+
+  # Default invoice number if the customer has exactly one open invoice.
+  if( ! $options->{'invnum'} ) {
+    $options->{'invnum'} = '';
+    my @open = $self->open_cust_bill;
+    $options->{'invnum'} = $open[0]->invnum if scalar(@open) == 1;
+  }
+
   $options->{payname} = $self->payname unless exists( $options->{payname} );
 }
 
   $options->{payname} = $self->payname unless exists( $options->{payname} );
 }
 
@@ -300,13 +329,40 @@ sub realtime_bop {
     $options{method} = $method;
     $options{amount} = $amount;
   }
     $options{method} = $method;
     $options{amount} = $amount;
   }
+
+
+  ### 
+  # optional credit card surcharge
+  ###
+
+  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');
   
   
+  # 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;
+      $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 ));
+  }
+  
+  $cc_surcharge = sprintf("%.2f",$cc_surcharge) if $cc_surcharge > 0;
+  $options{'cc_surcharge'} = $cc_surcharge;
+
+
   if ( $DEBUG ) {
     warn "$me realtime_bop (new): $options{method} $options{amount}\n";
   if ( $DEBUG ) {
     warn "$me realtime_bop (new): $options{method} $options{amount}\n";
+    warn " cc_surcharge = $cc_surcharge\n";
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
-  return $self->fake_bop(%options) if $options{'fake'};
+  return $self->fake_bop(\%options) if $options{'fake'};
 
   $self->_bop_defaults(\%options);
 
 
   $self->_bop_defaults(\%options);
 
@@ -419,9 +475,9 @@ sub realtime_bop {
     $content{bank_state} = exists($options{'paystate'})
                              ? $options{'paystate'}
                              : $self->getfield('paystate');
     $content{bank_state} = exists($options{'paystate'})
                              ? $options{'paystate'}
                              : $self->getfield('paystate');
-    $content{account_type} = exists($options{'paytype'})
-                               ? uc($options{'paytype'}) || 'CHECKING'
-                               : uc($self->getfield('paytype')) || 'CHECKING';
+    $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');
 
     $content{account_name} = $self->getfield('first'). ' '.
                              $self->getfield('last');
 
@@ -459,7 +515,6 @@ sub realtime_bop {
   #check the balance
   return "The customer's balance has changed; $options{method} transaction aborted."
     if $self->balance < $balance;
   #check the balance
   return "The customer's balance has changed; $options{method} transaction aborted."
     if $self->balance < $balance;
-    #&& $self->balance < $options{amount}; #might as well anyway?
 
   #also check and make sure there aren't *other* pending payments for this cust
 
 
   #also check and make sure there aren't *other* pending payments for this cust
 
@@ -467,6 +522,19 @@ sub realtime_bop {
     'custnum' => $self->custnum,
     'status'  => { op=>'!=', value=>'done' } 
   });
     'custnum' => $self->custnum,
     'status'  => { op=>'!=', value=>'done' } 
   });
+
+  #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;
+      warn "error deleting unfinished third-party payment ".
+          $_->paypendingnum . ": $error\n"
+        if $error;
+    }
+    @pending = grep { $_->status ne 'thirdparty' } @pending;
+  }
+
   return "A payment is already being processed for this customer (".
          join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
          "); $options{method} transaction aborted."
   return "A payment is already being processed for this customer (".
          join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
          "); $options{method} transaction aborted."
@@ -476,7 +544,6 @@ sub realtime_bop {
 
   my $cust_pay_pending = new FS::cust_pay_pending {
     'custnum'           => $self->custnum,
 
   my $cust_pay_pending = new FS::cust_pay_pending {
     'custnum'           => $self->custnum,
-    #'invnum'            => $options{'invnum'},
     'paid'              => $options{amount},
     '_date'             => '',
     'payby'             => $bop_method2payby{$options{method}},
     'paid'              => $options{amount},
     '_date'             => '',
     'payby'             => $bop_method2payby{$options{method}},
@@ -511,6 +578,7 @@ sub realtime_bop {
     'customer_id'    => $self->custnum,
     %$bop_content,
     'reference'      => $cust_pay_pending->paypendingnum, #for now
     'customer_id'    => $self->custnum,
     %$bop_content,
     'reference'      => $cust_pay_pending->paypendingnum, #for now
+    'callback_url'   => $payment_gateway->gateway_callback_url,
     'email'          => $email,
     %content, #after
   );
     'email'          => $email,
     %content, #after
   );
@@ -539,6 +607,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 ) };
 
@@ -682,6 +753,11 @@ sub fake_bop {
   } );
   $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
 
   } );
   $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
 
+  if ( $DEBUG ) {
+      warn "fake_bop\n cust_pay: ". Dumper($cust_pay) . "\n options: ";
+      warn "  $_ => $options{$_}\n" foreach keys %options;
+  }
+
   my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
 
   if ( $error ) {
   my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
 
   if ( $error ) {
@@ -771,13 +847,16 @@ sub _realtime_bop_result {
     my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
 
     if ( $error ) {
     my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
 
     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
+      $cust_pay->paynum('');
       my $error2 = $cust_pay->insert( $options{'manual'} ?
                                       ( 'manual' => 1 ) : ()
                                     );
       if ( $error2 ) {
         # gah.  but at least we have a record of the state we had to abort in
         # from cust_pay_pending now.
       my $error2 = $cust_pay->insert( $options{'manual'} ?
                                       ( 'manual' => 1 ) : ()
                                     );
       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".
@@ -845,6 +924,64 @@ sub _realtime_bop_result {
         }
       }
 
         }
       }
 
+      # have a CC surcharge portion --> one-time charge
+      if ( $options{'cc_surcharge'} > 0 ) { 
+           # XXX: this whole block needs to be in a transaction?
+
+         my $invnum;
+         $invnum = $options{'invnum'} if $options{'invnum'};
+         unless ( $invnum ) { # probably from a payment screen
+            # do we have any open invoices? pick earliest
+            # uses the fact that cust_main->cust_bill sorts by date ascending
+            my @open = $self->open_cust_bill;
+            $invnum = $open[0]->invnum if scalar(@open);
+         }
+           
+         unless ( $invnum ) {  # still nothing? pick last closed invoice
+            # again uses fact that cust_main->cust_bill sorts by date ascending
+            my @closed = $self->cust_bill;
+            $invnum = $closed[$#closed]->invnum if scalar(@closed);
+         }
+
+         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!';
+               return '';
+         }
+
+         my $cust_pkg;
+         my $charge_error = $self->charge({
+                                   'amount'    => $options{'cc_surcharge'},
+                                   'pkg'       => 'Credit Card Surcharge',
+                                   'setuptax'  => 'Y',
+                                   'cust_pkg_ref' => \$cust_pkg,
+                               });
+         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...
+         }
+                                   
+         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'});
+
+         warn "cannot add CC surcharge to invoice #$invnum: $grand_error"
+           if $grand_error;
+      }
+
       return ''; #no error
 
     }
       return ''; #no error
 
     }
@@ -862,7 +999,7 @@ sub _realtime_bop_result {
          my $error = $placeholder->depended_delete;
          $error ||= $placeholder->delete;
          warn "error removing provisioning jobs after declined paypendingnum ".
          my $error = $placeholder->depended_delete;
          $error ||= $placeholder->delete;
          warn "error removing provisioning jobs after declined paypendingnum ".
-           $cust_pay_pending->paypendingnum. "\n";
+           $cust_pay_pending->paypendingnum. ": $error\n";
        } 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";
@@ -1016,9 +1153,10 @@ sub realtime_botpp_capture {
 
   my $method = FS::payby->payby2bop($cust_pay_pending->payby);
 
 
   my $method = FS::payby->payby2bop($cust_pay_pending->payby);
 
-  my $payment_gateway = $cust_pay_pending->gatewaynum
-    ? qsearchs( 'payment_gateway',
-                { gatewaynum => $cust_pay_pending->gatewaynum }
+  my $payment_gateway;
+  my $gatewaynum = $cust_pay_pending->getfield('gatewaynum');
+  $payment_gateway = $gatewaynum ? qsearchs( 'payment_gateway',
+                { gatewaynum => $gatewaynum }
               )
     : $self->agent->payment_gateway( 'method' => $method,
                                      # 'invnum'  => $cust_pay_pending->invnum,
               )
     : $self->agent->payment_gateway( 'method' => $method,
                                      # 'invnum'  => $cust_pay_pending->invnum,
@@ -1080,7 +1218,14 @@ sub realtime_botpp_capture {
   my $error =
     $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
 
   my $error =
     $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
 
-  {
+  if ( $options{'apply'} ) {
+    my $apply_error = $self->apply_payments_and_credits;
+    if ( $apply_error ) {
+      warn "WARNING: error applying payment: $apply_error\n";
+    }
+  }
+
+  return {
     bill_error => $error,
     session_id => $cust_pay_pending->session_id,
   }
     bill_error => $error,
     session_id => $cust_pay_pending->session_id,
   }
@@ -1140,7 +1285,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,
@@ -1270,14 +1416,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') )