minor refactor and better safeguards on term discounts, #15068
[freeside.git] / FS / FS / cust_main / Billing_Realtime.pm
index e94f78e..364d4a7 100644 (file)
@@ -4,7 +4,6 @@ use strict;
 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 FS::Record qw( qsearch qsearchs );
@@ -13,6 +12,7 @@ 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;
 
@@ -43,14 +43,11 @@ These methods are available on FS::cust_main objects.
 
 =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>
 
@@ -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
-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.
@@ -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
-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.
 
@@ -141,7 +138,18 @@ I<session_id> is a session identifier associated with this payment.
 
 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 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)
 
@@ -233,7 +241,14 @@ sub _bop_defaults {
   }
 
   $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} );
 }
 
@@ -292,6 +307,10 @@ sub _bop_content {
   $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/
   $content{phone} = $self->daytime || $self->night;
 
+  my $currency =    $conf->exists('business-onlinepayment-currency')
+                 && $conf->config('business-onlinepayment-currency');
+  $content{currency} = $currency if $currency;
+
   \%content;
 }
 
@@ -322,7 +341,9 @@ sub realtime_bop {
   ###
 
   my $cc_surcharge = 0;
-  my $cc_surcharge_pct = $conf->config('credit-card-surcharge-percentage');
+  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) {
@@ -335,11 +356,10 @@ sub realtime_bop {
                                 # amount as post-surcharge
     $cc_surcharge = $options{'amount'} - ($options{'amount'} / ( 1 + $cc_surcharge_pct/100 ));
   }
-  if ( $cc_surcharge > 0) {
-      $cc_surcharge = sprintf("%.2f",$cc_surcharge);
-      $options{'cc_surcharge'} = $cc_surcharge;
-  }
+  
+  $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";
@@ -386,11 +406,29 @@ sub realtime_bop {
   # check for banned credit card/ACH
   ###
 
-  my $ban = qsearchs('banned_pay', {
+  my $ban = FS::banned_pay->ban_search(
     '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
@@ -460,9 +498,9 @@ sub realtime_bop {
     $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');
 
@@ -500,7 +538,6 @@ sub realtime_bop {
   #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
 
@@ -508,23 +545,18 @@ sub realtime_bop {
     '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;
-      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 ).
@@ -535,7 +567,6 @@ sub realtime_bop {
 
   my $cust_pay_pending = new FS::cust_pay_pending {
     'custnum'           => $self->custnum,
-    #'invnum'            => $options{'invnum'},
     'paid'              => $options{amount},
     '_date'             => '',
     'payby'             => $bop_method2payby{$options{method}},
@@ -599,6 +630,9 @@ sub realtime_bop {
 
   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 ) };
 
@@ -836,13 +870,16 @@ sub _realtime_bop_result {
     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->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.
+        $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".
@@ -912,6 +949,8 @@ 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
@@ -942,26 +981,28 @@ sub _realtime_bop_result {
                                    'cust_pkg_ref' => \$cust_pkg,
                                });
          if($charge_error) {
-               warn 'Unable to add CC surcharge';
+               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_pkg = new FS::cust_bill_pkg({
-           'invnum' => $invnum,
-           'pkgnum' => $cust_pkg->pkgnum,
-           'setup' => $options{'cc_surcharge'},
-         });
-         my $cbp_error = $cust_bill_pkg->insert;
-
-         if ( $cbp_error) {
-               warn 'Cannot add CC surcharge line item to invoice #'.$invnum;
-               return '';
-         } else {
-               my $cust_bill = qsearchs('cust_bill', { 'invnum' => $invnum });
-               warn 'invoice for cc surcharge: ' . Dumper($cust_bill) if $DEBUG;
-               $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_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
@@ -981,7 +1022,7 @@ sub _realtime_bop_result {
          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";
@@ -1267,7 +1308,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
-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,
@@ -1397,14 +1439,28 @@ sub realtime_refund_bop {
   }
 
   #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 ) )
     )
+    && $tryvoid
   ) {
     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') )