spacing, RT#83503
[freeside.git] / FS / FS / cust_main / Billing.pm
index 4821ce5..47cbbf1 100644 (file)
@@ -1,6 +1,7 @@
 package FS::cust_main::Billing;
 
 use strict;
+use feature 'state';
 use vars qw( $conf $DEBUG $me );
 use Carp;
 use Data::Dumper;
@@ -25,6 +26,7 @@ use FS::pkg_category;
 use FS::FeeOrigin_Mixin;
 use FS::Log;
 use FS::TaxEngine;
+use FS::Misc::Savepoint;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -57,7 +59,7 @@ Cancels and suspends any packages due, generates bills, applies payments and
 credits, and applies collection events to run cards, send bills and notices,
 etc.
 
-By default, warns on errors and continues with the next operation (but see the
+Any errors prevent subsequent operations from continuing and die (but see the
 "fatal" flag below).
 
 Options are passed as name-value pairs.  Currently available options are:
@@ -131,8 +133,7 @@ sub bill_and_collect {
   if ( $error ) {
     $error = "Error expiring custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
-    elsif ( $options{fatal}                                ) { die    $error; }
-    else                                                     { warn   $error; }
+    else                                                     { die    $error; }
   }
 
   $log->debug('suspending adjourned packages', %logopt);
@@ -140,8 +141,7 @@ sub bill_and_collect {
   if ( $error ) {
     $error = "Error adjourning custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
-    elsif ( $options{fatal}                                ) { die    $error; }
-    else                                                     { warn   $error; }
+    else                                                     { die    $error; }
   }
 
   $log->debug('unsuspending resumed packages', %logopt);
@@ -149,8 +149,42 @@ sub bill_and_collect {
   if ( $error ) {
     $error = "Error resuming custnum ".$self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
-    elsif ( $options{fatal}                                ) { die    $error; }
-    else                                                     { warn   $error; }
+    else                                                     { die    $error; }
+  }
+
+  my $tax_district_method = $conf->config('tax_district_method');
+  if ( $tax_district_method && $tax_district_method eq 'wa_sales' ) {
+    # When using Washington State Sales Tax Districts,
+    # Bail out of billing customer if sales tax district for location is missing
+
+    $log->debug('checking cust_location tax districts', %logopt);
+
+    if (
+      my @cust_locations_missing_district =
+        $self->cust_locations_missing_district
+    ) {
+      $error = sprintf
+        'cust_location missing tax district: '.
+        join( ', ' => (
+          map(
+            {
+              sprintf
+                'locationnum(%s) %s %s %s %s',
+                 $_->locationnum,
+                 $_->address1,
+                 $_->city,
+                 $_->state,
+                 $_->zip
+            }
+            @cust_locations_missing_district
+          )
+        ));
+    }
+  }
+  if ( $error ) {
+    $error = "Error calculating taxes ".$self->custnum. ": $error";
+    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+    else                                                     { die    $error; }
   }
 
   $job->update_statustext('20,billing packages') if $job;
@@ -159,8 +193,7 @@ sub bill_and_collect {
   if ( $error ) {
     $error = "Error billing custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
-    elsif ( $options{fatal}                                ) { die    $error; }
-    else                                                     { warn   $error; }
+    else                                                     { die    $error; }
   }
 
   $job->update_statustext('50,applying payments and credits') if $job;
@@ -169,17 +202,13 @@ sub bill_and_collect {
   if ( $error ) {
     $error = "Error applying custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
-    elsif ( $options{fatal}                                ) { die    $error; }
-    else                                                     { warn   $error; }
+    else                                                     { die    $error; }
   }
 
   # In a batch tax environment, do not run collection if any pending 
   # invoices were created.  Collection will run after the next tax batch.
-  my $tax = FS::TaxEngine->new;
-  if ( $tax->info->{batch} and 
-       qsearch('cust_bill', { custnum => $self->custnum, pending => 'Y' })
-     )
-  {
+  state $is_batch_tax = FS::TaxEngine->new->info->{batch} ? 1 : 0;
+  if ( $is_batch_tax && $self->pending_invoice_count ) {
     warn "skipped collection for custnum ".$self->custnum.
          " due to pending invoices\n" if $DEBUG;
   } elsif ( $conf->exists('cancelled_cust-noevents')
@@ -195,8 +224,7 @@ sub bill_and_collect {
     if ( $error ) {
       $error = "Error collecting custnum ". $self->custnum. ": $error";
       if    ($options{fatal} && $options{fatal} eq 'return') { return $error; }
-      elsif ($options{fatal}                               ) { die    $error; }
-      else                                                   { warn   $error; }
+      else                                                   { die    $error; }
     }
   }
 
@@ -216,12 +244,11 @@ sub cancel_expired_pkgs {
 
   my @errors = ();
 
-  my @really_cancel_pkgs;
-  my @cancel_reasons;
+  my @really_cancel_pkgs = ();
+  my @cancel_reasons = ();
 
   CUST_PKG: foreach my $cust_pkg ( @cancel_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
-    my $error;
 
     if ( $cust_pkg->change_to_pkgnum ) {
 
@@ -231,9 +258,10 @@ sub cancel_expired_pkgs {
                       $cust_pkg->change_to_pkgnum.'; not expiring';
         next CUST_PKG;
       }
-      $error = $cust_pkg->change( 'cust_pkg'        => $new_pkg,
-                                  'unprotect_svcs'  => 1 );
-      $error = '' if ref $error eq 'FS::cust_pkg';
+      my $error = $cust_pkg->change( 'cust_pkg'        => $new_pkg,
+                                     'unprotect_svcs'  => 1,
+                                   );
+      push @errors, $error if $error && ref($error) ne 'FS::cust_pkg';
 
     } else { # just cancel it
 
@@ -544,14 +572,19 @@ sub bill {
 
     foreach my $part_pkg ( @part_pkg ) {
 
-      $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
+      my $this_cust_pkg = $cust_pkg;
+      # for add-on packages, copy the object to avoid leaking changes back to
+      # the caller if pkg_list is in use; see RT#73607
+      if ( $part_pkg->get('pkgpart') != $real_pkgpart ) {
+        $this_cust_pkg = FS::cust_pkg->new({ %hash });
+      }
 
       my $pass = '';
-      if ( $cust_pkg->separate_bill ) {
+      if ( $this_cust_pkg->separate_bill ) {
         # if no_auto is also set, that's fine. we just need to not have
         # invoices that are both auto and no_auto, and since the package
         # gets an invoice all to itself, it will only be one or the other.
-        $pass = $cust_pkg->pkgnum;
+        $pass = $this_cust_pkg->pkgnum;
         if (!exists $cust_bill_pkg{$pass}) { # it may not exist yet
           push @passes, $pass;
           $total_setup{$pass} = do { my $z = 0; \$z };
@@ -565,17 +598,17 @@ sub bill {
                                 );
           $cust_bill_pkg{$pass} = [];
         }
-      } elsif ( ($cust_pkg->no_auto || $part_pkg->no_auto) ) {
+      } elsif ( ($this_cust_pkg->no_auto || $part_pkg->no_auto) ) {
         $pass = 'no_auto';
       }
 
-      my $next_bill = $cust_pkg->getfield('bill') || 0;
+      my $next_bill = $this_cust_pkg->getfield('bill') || 0;
       my $error;
       # let this run once if this is the last bill upon cancellation
       while ( $next_bill <= $cmp_time or $options{cancel} ) {
         $error =
           $self->_make_lines( 'part_pkg'            => $part_pkg,
-                              'cust_pkg'            => $cust_pkg,
+                              'cust_pkg'            => $this_cust_pkg,
                               'precommit_hooks'     => \@precommit_hooks,
                               'line_items'          => $cust_bill_pkg{$pass},
                               'setup'               => $total_setup{$pass},
@@ -590,12 +623,12 @@ sub bill {
         last if $error;
 
         # or if we're not incrementing the bill date.
-        last if ($cust_pkg->getfield('bill') || 0) == $next_bill;
+        last if ($this_cust_pkg->getfield('bill') || 0) == $next_bill;
 
         # or if we're letting it run only once
         last if $options{cancel};
 
-        $next_bill = $cust_pkg->getfield('bill') || 0;
+        $next_bill = $this_cust_pkg->getfield('bill') || 0;
 
         #stop if -o was passed to freeside-daily
         last if $options{'one_recur'};
@@ -1053,6 +1086,11 @@ sub _make_lines {
         }
     }
 
+    $lineitems++
+      if $cust_pkg->waive_setup
+      && $part_pkg->can('prorate_setup')
+      && $part_pkg->prorate_setup($cust_pkg, $time);
+
     if ( $cust_pkg->get('setup') ) {
       # don't change it
     } elsif ( $cust_pkg->get('start_date') ) {
@@ -1753,7 +1791,10 @@ sub collect {
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   #never want to roll back an event just because it returned an error
-  local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
+  # unless $FS::UID::ForceObeyAutoCommit is set
+  local $FS::UID::AutoCommit = 1
+    unless !$oldAutoCommit
+        && $FS::UID::ForceObeyAutoCommit;
 
   $self->do_cust_event(
     'debug'      => ( $options{'debug'} || 0 ),
@@ -1961,9 +2002,13 @@ sub do_cust_event {
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
   #never want to roll back an event just because it or a different one
   # returned an error
-  local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
+  # unless $FS::UID::ForceObeyAutoCommit is set
+  local $FS::UID::AutoCommit = 1
+    unless !$oldAutoCommit
+        && $FS::UID::ForceObeyAutoCommit;
 
   foreach my $cust_event ( @$due_cust_event ) {
 
@@ -2288,16 +2333,21 @@ sub apply_payments_and_credits {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  my $savepoint_label = 'Billing__apply_payments_and_credits';
+  savepoint_create( $savepoint_label );
+
   $self->select_for_update; #mutex
 
   foreach my $cust_bill ( $self->open_cust_bill ) {
     my $error = $cust_bill->apply_payments_and_credits(%options);
     if ( $error ) {
+      savepoint_rollback_and_release( $savepoint_label );
       $dbh->rollback if $oldAutoCommit;
       return "Error applying: $error";
     }
   }
 
+  savepoint_release( $savepoint_label );
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   ''; #no error