Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / cust_main / Billing.pm
index 0595618..deb5e84 100644 (file)
@@ -21,6 +21,7 @@ use FS::cust_bill_pkg_tax_rate_location;
 use FS::part_event;
 use FS::part_event_condition;
 use FS::pkg_category;
+use FS::Log;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -104,6 +105,9 @@ options of those methods are also available.
 sub bill_and_collect {
   my( $self, %options ) = @_;
 
+  my $log = FS::Log->new('bill_and_collect');
+  $log->debug('start', object => $self, agentnum => $self->agentnum);
+
   my $error;
 
   #$options{actual_time} not $options{time} because freeside-daily -d is for
@@ -129,6 +133,14 @@ sub bill_and_collect {
     else                                                     { warn   $error; }
   }
 
+  $error = $self->unsuspend_resumed_pkgs( day_end( $options{actual_time} ) );
+  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; }
+  }
+
   $job->update_statustext('20,billing packages') if $job;
   $error = $self->bill( %options );
   if ( $error ) {
@@ -160,6 +172,7 @@ sub bill_and_collect {
     }
   }
   $job->update_statustext('100,finished') if $job;
+  $log->debug('finish', object => $self, agentnum => $self->agentnum);
 
   '';
 
@@ -177,14 +190,15 @@ sub cancel_expired_pkgs {
   foreach my $cust_pkg ( @cancel_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
     my $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
-                                           'reason_otaker' => $cpr->otaker
+                                           'reason_otaker' => $cpr->otaker,
+                                           'time'          => $time,
                                          )
                                        : ()
                                  );
     push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
-  scalar(@errors) ? join(' / ', @errors) : '';
+  join(' / ', @errors);
 
 }
 
@@ -226,7 +240,25 @@ sub suspend_adjourned_pkgs {
     push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
-  scalar(@errors) ? join(' / ', @errors) : '';
+  join(' / ', @errors);
+
+}
+
+sub unsuspend_resumed_pkgs {
+  my ( $self, $time, %options ) = @_;
+  
+  my @unsusp_pkgs = $self->ncancelled_pkgs( { 
+    'extra_sql' => " AND resume IS NOT NULL AND resume > 0 AND resume <= $time "
+  } );
+
+  my @errors = ();
+
+  foreach my $cust_pkg ( @unsusp_pkgs ) {
+    my $error = $cust_pkg->unsuspend( 'time' => $time );
+    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
+  }
+
+  join(' / ', @errors);
 
 }
 
@@ -253,8 +285,9 @@ charges, etc.
 =item freq_override
 
 If set, then override the normal frequency and look for a part_pkg_discount
-to take at that frequency.  This will exclude any packages that aren't billed
-on a monthly cycle.
+to take at that frequency.  This is appropriate only when the normal 
+frequency for all packages is monthly, and is an error otherwise.  Use
+C<pkg_list> to limit the set of packages included in billing.
 
 =item time
 
@@ -377,11 +410,12 @@ sub bill {
   my @precommit_hooks = ();
 
   $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ];  #param checks?
+
   foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
 
     next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
 
-    warn "  bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
+    warn "  bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG;
 
     #? to avoid use of uninitialized value errors... ?
     $cust_pkg->setfield('bill', '')
@@ -406,7 +440,8 @@ sub bill {
 
       my $next_bill = $cust_pkg->getfield('bill') || 0;
       my $error;
-      while ( $next_bill <= $time ) {
+      # let this run once if this is the last bill upon cancellation
+      while ( $next_bill <= $time or $options{cancel} ) {
         $error =
           $self->_make_lines( 'part_pkg'            => $part_pkg,
                               'cust_pkg'            => $cust_pkg,
@@ -419,11 +454,20 @@ sub bill {
                               'real_pkgpart'        => $real_pkgpart,
                               'options'             => \%options,
                             );
-        # Stop if anything goes wrong, or if we're not incrementing 
-        # the bill date.
+
+        # Stop if anything goes wrong
         last if $error;
+
+        # or if we're not incrementing the bill date.
         last if ($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;
+
+        #stop if -o was passed to freeside-daily
+        last if $options{'one_recur'};
       }
       if ($error) {
         $dbh->rollback if $oldAutoCommit && !$options{no_commit};
@@ -644,8 +688,6 @@ sub _omit_zero_value_bundles {
 
 =item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
 
-This is a weird one.  Perhaps it should not even be exposed.
-
 Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
 Usually used internally by bill method B<bill>.
 
@@ -683,6 +725,11 @@ jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
 sub calculate_taxes {
   my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
 
+  # $taxlisthash is a hashref
+  # keys are identifiers, values are arrayrefs
+  # each arrayref starts with a tax object (cust_main_county or tax_rate)
+  # then any cust_bill_pkg objects the tax applies to
+
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
   warn "$me calculate_taxes\n"
@@ -692,57 +739,70 @@ sub calculate_taxes {
   my @tax_line_items = ();
 
   # keys are tax names (as printed on invoices / itemdesc )
-  # values are listrefs of taxlisthash keys (internal identifiers)
+  # values are arrayrefs of taxlisthash keys (internal identifiers)
   my %taxname = ();
 
   # keys are taxlisthash keys (internal identifiers)
   # values are (cumulative) amounts
-  my %tax = ();
+  my %tax_amount = ();
 
   # keys are taxlisthash keys (internal identifiers)
-  # values are listrefs of cust_bill_pkg_tax_location hashrefs
+  # values are arrayrefs of cust_bill_pkg_tax_location hashrefs
   my %tax_location = ();
 
   # keys are taxlisthash keys (internal identifiers)
-  # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
+  # values are arrayrefs of cust_bill_pkg_tax_rate_location hashrefs
   my %tax_rate_location = ();
 
+  # keys are taxlisthash keys (internal identifiers!)
+  # values are arrayrefs of cust_tax_exempt_pkg objects
+  my %tax_exemption;
+
   foreach my $tax ( keys %$taxlisthash ) {
+    # $tax is a tax identifier (intersection of a tax definition record
+    # and a cust_bill_pkg record)
     my $tax_object = shift @{ $taxlisthash->{$tax} };
+    # $tax_object is a cust_main_county or tax_rate 
+    # (with billpkgnum, pkgnum, locationnum set)
+    # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg component objects
+    # (setup, recurring, usage classes)
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
     warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
-    my $hashref_or_error =
-      $tax_object->taxline( $taxlisthash->{$tax},
+    # taxline calculates the tax on all cust_bill_pkgs in the 
+    # first (arrayref) argument, and returns a hashref of 'name' 
+    # (the line item description) and 'amount'.
+    # It also calculates exemptions and attaches them to the cust_bill_pkgs
+    # in the argument.
+    my $taxables = $taxlisthash->{$tax};
+    my $exemptions = $tax_exemption{$tax} ||= [];
+    my $taxline = $tax_object->taxline(
+                            $taxables,
                             'custnum'      => $self->custnum,
-                            'invoice_time' => $invoice_time
+                            'invoice_time' => $invoice_time,
+                            'exemptions'   => $exemptions,
                           );
-    return $hashref_or_error unless ref($hashref_or_error);
+    return $taxline unless ref($taxline);
 
     unshift @{ $taxlisthash->{$tax} }, $tax_object;
 
-    my $name   = $hashref_or_error->{'name'};
-    my $amount = $hashref_or_error->{'amount'};
+    if ( $tax_object->isa('FS::cust_main_county') ) {
+      # then $taxline is a real line item
+      push @{ $taxname{ $taxline->itemdesc } }, $taxline;
 
-    #warn "adding $amount as $name\n";
-    $taxname{ $name } ||= [];
-    push @{ $taxname{ $name } }, $tax;
+    } else {
+      # leave this as is for now
 
-    $tax{ $tax } += $amount;
+      my $name   = $taxline->{'name'};
+      my $amount = $taxline->{'amount'};
 
-    $tax_location{ $tax } ||= [];
-    if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
-      push @{ $tax_location{ $tax }  },
-        {
-          'taxnum'      => $tax_object->taxnum, 
-          'taxtype'     => ref($tax_object),
-          'pkgnum'      => $tax_object->get('pkgnum'),
-          'locationnum' => $tax_object->get('locationnum'),
-          'amount'      => sprintf('%.2f', $amount ),
-        };
-    }
+      #warn "adding $amount as $name\n";
+      $taxname{ $name } ||= [];
+      push @{ $taxname{ $name } }, $tax;
+
+      $tax_amount{ $tax } += $amount;
 
-    $tax_rate_location{ $tax } ||= [];
-    if ( ref($tax_object) eq 'FS::tax_rate' ) {
+      # link records between cust_main_county/tax_rate and cust_location
+      $tax_rate_location{ $tax } ||= [];
       my $taxratelocationnum =
         $tax_object->tax_rate_location->taxratelocationnum;
       push @{ $tax_rate_location{ $tax }  },
@@ -753,50 +813,52 @@ sub calculate_taxes {
           'locationtaxid'      => $tax_object->location,
           'taxratelocationnum' => $taxratelocationnum,
         };
-    }
-
-  }
-
-  #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
-  my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg;
-  foreach my $tax ( keys %$taxlisthash ) {
-    foreach ( @{ $taxlisthash->{$tax} }[1 ... scalar(@{ $taxlisthash->{$tax} })] ) {
-      next unless ref($_) eq 'FS::cust_bill_pkg';
-     
-      my @cust_tax_exempt_pkg = splice( @{ $_->_cust_tax_exempt_pkg } );
-
-      next unless @cust_tax_exempt_pkg; #just avoiding the prob when irrelevant?
-      die "can't distribute tax exemptions: no line item for ".  Dumper($_).
-          " in packagemap ". join(',', sort {$a<=>$b} keys %packagemap). "\n"
-        unless $packagemap{$_->pkgnum};
-
-      push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
-           @cust_tax_exempt_pkg;
-    }
-  }
+    } #if ref($tax_object)...
+  } #foreach keys %$taxlisthash
 
   #consolidate and create tax line items
   warn "consolidating and generating...\n" if $DEBUG > 2;
   foreach my $taxname ( keys %taxname ) {
-    my $tax = 0;
+    my @cust_bill_pkg_tax_location;
+    my @cust_bill_pkg_tax_rate_location;
+    my $tax_cust_bill_pkg = FS::cust_bill_pkg->new({
+        'pkgnum'    => 0,
+        'recur'     => 0,
+        'sdate'     => '',
+        'edate'     => '',
+        'itemdesc'  => $taxname,
+        'cust_bill_pkg_tax_location'      => \@cust_bill_pkg_tax_location,
+        'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
+    });
+
+    my $tax_total = 0;
     my %seen = ();
-    my @cust_bill_pkg_tax_location = ();
-    my @cust_bill_pkg_tax_rate_location = ();
     warn "adding $taxname\n" if $DEBUG > 1;
     foreach my $taxitem ( @{ $taxname{$taxname} } ) {
-      next if $seen{$taxitem}++;
-      warn "adding $tax{$taxitem}\n" if $DEBUG > 1;
-      $tax += $tax{$taxitem};
-      push @cust_bill_pkg_tax_location,
-        map { new FS::cust_bill_pkg_tax_location $_ }
-            @{ $tax_location{ $taxitem } };
-      push @cust_bill_pkg_tax_rate_location,
-        map { new FS::cust_bill_pkg_tax_rate_location $_ }
-            @{ $tax_rate_location{ $taxitem } };
+      if ( ref($taxitem) eq 'FS::cust_bill_pkg' ) {
+        # then we need to transfer the amount and the links from the
+        # line item to the new one we're creating.
+        $tax_total += $taxitem->setup;
+        foreach my $link ( @{ $taxitem->get('cust_bill_pkg_tax_location') } ) {
+          $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg);
+          push @cust_bill_pkg_tax_location, $link;
+        }
+      } else {
+        # the tax_rate way
+        next if $seen{$taxitem}++;
+        warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1;
+        $tax_total += $tax_amount{$taxitem};
+        push @cust_bill_pkg_tax_rate_location,
+          map { new FS::cust_bill_pkg_tax_rate_location $_ }
+              @{ $tax_rate_location{ $taxitem } };
+      }
     }
-    next unless $tax;
+    next unless $tax_total;
 
-    $tax = sprintf('%.2f', $tax );
+    # we should really neverround this up...I guess it's okay if taxline 
+    # already returns amounts with 2 decimal places
+    $tax_total = sprintf('%.2f', $tax_total );
+    $tax_cust_bill_pkg->set('setup', $tax_total);
   
     my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
                                                    'disabled'     => '',
@@ -814,19 +876,9 @@ sub calculate_taxes {
       push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
 
     }
+    $tax_cust_bill_pkg->set('display', \@display);
 
-    push @tax_line_items, new FS::cust_bill_pkg {
-      'pkgnum'   => 0,
-      'setup'    => $tax,
-      'recur'    => 0,
-      'sdate'    => '',
-      'edate'    => '',
-      'itemdesc' => $taxname,
-      'display'  => \@display,
-      'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
-      'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
-    };
-
+    push @tax_line_items, $tax_cust_bill_pkg;
   }
 
   \@tax_line_items;
@@ -839,7 +891,7 @@ sub _make_lines {
 
   my $part_pkg = $params{part_pkg} or die "no part_pkg specified";
   my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified";
-  my $precommit_hooks = $params{precommit_hooks} or die "no package specified";
+  my $precommit_hooks = $params{precommit_hooks} or die "no precommit_hooks specified";
   my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified";
   my $total_setup = $params{setup} or die "no setup accumulator specified";
   my $total_recur = $params{recur} or die "no recur accumulator specified";
@@ -914,13 +966,15 @@ sub _make_lines {
   # bill recurring fee
   ### 
 
-  #XXX unit stuff here too
   my $recur = 0;
   my $unitrecur = 0;
   my @recur_discounts = ();
   my $sdate;
   if (     ! $cust_pkg->start_date
-       and ( ! $cust_pkg->susp || $part_pkg->option('suspend_bill', 1) )
+       and ( ! $cust_pkg->susp || $cust_pkg->option('suspend_bill',1)
+                               || ( $part_pkg->option('suspend_bill', 1) )
+                                     && ! $cust_pkg->option('no_suspend_bill',1)
+                                  )
        and
             ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= day_end($time) )
          || ( $part_pkg->plan eq 'voip_cdr'
@@ -973,11 +1027,36 @@ sub _make_lines {
     return "$@ running $method for $cust_pkg\n"
       if ( $@ );
 
+    #base_cancel???
+    $unitrecur = $cust_pkg->part_pkg->base_recur || $recur; #XXX uuh
+
     if ( $increment_next_bill ) {
 
-      my $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
+      my $next_bill;
+
+      if ( my $main_pkg = $cust_pkg->main_pkg ) {
+        # supplemental package
+        # to keep in sync with the main package, simulate billing at 
+        # its frequency
+        my $main_pkg_freq = $main_pkg->part_pkg->freq;
+        my $supp_pkg_freq = $part_pkg->freq;
+        my $ratio = $supp_pkg_freq / $main_pkg_freq;
+        if ( $ratio != int($ratio) ) {
+          # the UI should prevent setting up packages like this, but just
+          # in case
+          return "supplemental package period is not an integer multiple of main  package period";
+        }
+        $next_bill = $sdate;
+        for (1..$ratio) {
+          $next_bill = $part_pkg->add_freq( $next_bill, $main_pkg_freq );
+        }
+
+      } else {
+        # the normal case
+      $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
       return "unparsable frequency: ". $part_pkg->freq
         if $next_bill == -1;
+      }  
   
       #pro-rating magic - if $recur_prog fiddled $sdate, want to use that
       # only for figuring next bill date, nothing else, so, reset $sdate again
@@ -1078,7 +1157,12 @@ sub _make_lines {
         'freq'      => $part_pkg->freq,
       };
 
-      if ( $part_pkg->recur_temporality eq 'preceding' ) {
+      if ( $part_pkg->option('prorate_defer_bill',1) 
+           and !$hash{last_bill} ) {
+        # both preceding and upcoming, technically
+        $cust_bill_pkg->sdate( $cust_pkg->setup );
+        $cust_bill_pkg->edate( $cust_pkg->bill );
+      } elsif ( $part_pkg->recur_temporality eq 'preceding' ) {
         $cust_bill_pkg->sdate( $hash{last_bill} );
         $cust_bill_pkg->edate( $sdate - 86399   ); #60s*60m*24h-1
         $cust_bill_pkg->edate( $time ) if $options{cancel};
@@ -1098,11 +1182,23 @@ sub _make_lines {
       # handle taxes
       ###
 
-      unless ( $discount_show_always ) {
-         my $error = 
-           $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
-         return $error if $error;
-      }
+      #unless ( $discount_show_always ) { # oh, for god's sake
+      my $error = $self->_handle_taxes(
+        $part_pkg,
+        $taxlisthash,
+        $cust_bill_pkg,
+        $cust_pkg,
+        $options{invoice_time},
+        $real_pkgpart,
+        \%options # I have serious objections to this
+      );
+      return $error if $error;
+      #}
+
+      $cust_bill_pkg->set_display(
+        part_pkg     => $part_pkg,
+        real_pkgpart => $real_pkgpart,
+      );
 
       push @$cust_bill_pkgs, $cust_bill_pkg;
 
@@ -1114,6 +1210,25 @@ sub _make_lines {
 
 }
 
+# This is _handle_taxes.  It's called once for each cust_bill_pkg generated
+# from _make_lines, along with the part_pkg, cust_pkg, invoice time, the 
+# non-overridden pkgpart, a flag indicating whether the package is being
+# canceled, and a partridge in a pear tree.
+#
+# The most important argument is 'taxlisthash'.  This is shared across the 
+# entire invoice.  It looks like this:
+# {
+#   'cust_main_county 1001' => [ [FS::cust_main_county], ... ],
+#   'cust_main_county 1002' => [ [FS::cust_main_county], ... ],
+# }
+#
+# 'cust_main_county' can also be 'tax_rate'.  The first object in the array
+# is always the cust_main_county or tax_rate identified by the key.
+#
+# That "..." is a list of FS::cust_bill_pkg objects that will be fed to 
+# the 'taxline' method to calculate the amount of the tax.  This doesn't
+# happen until calculate_taxes, though.
+
 sub _handle_taxes {
   my $self = shift;
   my $part_pkg = shift;
@@ -1126,24 +1241,34 @@ sub _handle_taxes {
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
-  my %cust_bill_pkg = ();
-  my %taxes = ();
-    
-  my @classes;
-  #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
-  push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
-  push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
-  push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
-
-  if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
+  return if ( $self->payby eq 'COMP' ); #dubious
 
-    if ( $conf->exists('enable_taxproducts')
-         && ( scalar($part_pkg->part_pkg_taxoverride)
-              || $part_pkg->has_taxproduct
-            )
-       )
+  if ( $conf->exists('enable_taxproducts')
+       && ( scalar($part_pkg->part_pkg_taxoverride)
+            || $part_pkg->has_taxproduct
+          )
+     )
     {
 
+    # EXTERNAL TAX RATES (via tax_rate)
+    my %cust_bill_pkg = ();
+    my %taxes = ();
+
+    my @classes;
+    #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
+    push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
+    # debatable
+    push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
+    push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
+
+    my $exempt = $conf->exists('cust_class-tax_exempt')
+                   ? ( $self->cust_class ? $self->cust_class->tax : '' )
+                   : $self->tax;
+    # standardize this just to be sure
+    $exempt = ($exempt eq 'Y') ? 'Y' : '';
+  
+    if ( !$exempt ) {
+
       foreach my $class (@classes) {
         my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg );
         return $err_or_ref unless ref($err_or_ref);
@@ -1156,127 +1281,112 @@ sub _handle_taxes {
         $taxes{''} = $err_or_ref;
       }
 
-    } else {
-
-      my @loc_keys = qw( city county state country );
-      my %taxhash;
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        my $cust_location = $cust_pkg->cust_location;
-        %taxhash = map { $_ => $cust_location->$_()    } @loc_keys;
-      } else {
-        my $prefix = 
-          ( $conf->exists('tax-ship_address') && length($self->ship_last) )
-          ? 'ship_'
-          : '';
-        %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
-      }
-
-      $taxhash{'taxclass'} = $part_pkg->taxclass;
-
-      my @taxes = ();
-      my %taxhash_elim = %taxhash;
-      my @elim = qw( city county state );
-      do { 
-
-        #first try a match with taxclass
-        @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
-
-        if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
-          #then try a match without taxclass
-          my %no_taxclass = %taxhash_elim;
-          $no_taxclass{ 'taxclass' } = '';
-          @taxes = qsearch( 'cust_main_county', \%no_taxclass );
-        }
+    }
 
-        $taxhash_elim{ shift(@elim) } = '';
+    my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
+    foreach my $key (keys %tax_cust_bill_pkg) {
+      # $key is "setup", "recur", or a usage class name. ('' is a usage class.)
+      # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of 
+      # the line item.
+      # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that
+      # apply to $key-class charges.
+      my @taxes = @{ $taxes{$key} || [] };
+      my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
+
+      my %localtaxlisthash = ();
+      foreach my $tax ( @taxes ) {
+
+        # this is the tax identifier, not the taxname
+        my $taxname = ref( $tax ). ' '. $tax->taxnum;
+        $taxname .= ' billpkgnum'. $cust_bill_pkg->billpkgnum;
+        # We need to create a separate $taxlisthash entry for each billpkgnum
+        # on the invoice, so that cust_bill_pkg_tax_location records will
+        # be linked correctly.
+
+        # $taxlisthash: keys are "setup", "recur", and usage classes.
+        # Values are arrayrefs, first the tax object (cust_main_county
+        # or tax_rate) and then any cust_bill_pkg objects that the 
+        # tax applies to.
+        $taxlisthash->{ $taxname } ||= [ $tax ];
+        push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
+
+        $localtaxlisthash{ $taxname } ||= [ $tax ];
+        push @{ $localtaxlisthash{ $taxname  } }, $tax_cust_bill_pkg;
 
-      } while ( !scalar(@taxes) && scalar(@elim) );
+      }
 
-      @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
-                    @taxes
-        if $self->cust_main_exemption; #just to be safe
+      warn "finding taxed taxes...\n" if $DEBUG > 2;
+      foreach my $tax ( keys %localtaxlisthash ) {
+        my $tax_object = shift @{ $localtaxlisthash{$tax} };
+        warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
+          if $DEBUG > 2;
+        next unless $tax_object->can('tax_on_tax');
+
+        foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
+          my $totname = ref( $tot ). ' '. $tot->taxnum;
+
+          warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
+            if $DEBUG > 2;
+          next unless exists( $localtaxlisthash{ $totname } ); # only increase
+                                                               # existing taxes
+          warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
+          # we're calling taxline() right here?  wtf?
+          my $hashref_or_error = 
+            $tax_object->taxline( $localtaxlisthash{$tax},
+                                  'custnum'      => $self->custnum,
+                                  'invoice_time' => $invoice_time,
+                                );
+          return $hashref_or_error
+            unless ref($hashref_or_error);
+          
+          $taxlisthash->{ $totname } ||= [ $tot ];
+          push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount};
 
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        foreach (@taxes) {
-          $_->set('pkgnum',      $cust_pkg->pkgnum );
-          $_->set('locationnum', $cust_pkg->locationnum );
         }
       }
+    }
 
-      $taxes{''} = [ @taxes ];
-      $taxes{'setup'} = [ @taxes ];
-      $taxes{'recur'} = [ @taxes ];
-      $taxes{$_} = [ @taxes ] foreach (@classes);
-
-      # # maybe eliminate this entirely, along with all the 0% records
-      # unless ( @taxes ) {
-      #   return
-      #     "fatal: can't find tax rate for state/county/country/taxclass ".
-      #     join('/', map $taxhash{$_}, qw(state county country taxclass) );
-      # }
-
-    } #if $conf->exists('enable_taxproducts') ...
-
-  }
+  } else {
 
-  #what's this doing in the middle of _handle_taxes?  probably should split
-  #this into three parts above in _make_lines
-  $cust_bill_pkg->set_display(   part_pkg     => $part_pkg,
-                                 real_pkgpart => $real_pkgpart,
-                             );
+    # INTERNAL TAX RATES (cust_main_county)
 
-  my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
-  foreach my $key (keys %tax_cust_bill_pkg) {
-    my @taxes = @{ $taxes{$key} || [] };
-    my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
+    # We fetch taxes even if the customer is completely exempt,
+    # because we need to record that fact.
 
-    my %localtaxlisthash = ();
-    foreach my $tax ( @taxes ) {
+    my @loc_keys = qw( district city county state country );
+    my $location = $cust_pkg->tax_location;
+    my %taxhash = map { $_ => $location->$_ } @loc_keys;
 
-      my $taxname = ref( $tax ). ' '. $tax->taxnum;
-#      $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
-#                  ' locationnum'. $cust_pkg->locationnum
-#        if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
+    $taxhash{'taxclass'} = $part_pkg->taxclass;
 
-      $taxlisthash->{ $taxname } ||= [ $tax ];
-      push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
+    warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2;
 
-      $localtaxlisthash{ $taxname } ||= [ $tax ];
-      push @{ $localtaxlisthash{ $taxname  } }, $tax_cust_bill_pkg;
+    my @taxes = (); # entries are cust_main_county objects
+    my %taxhash_elim = %taxhash;
+    my @elim = qw( district city county state );
+    do { 
 
-    }
+      #first try a match with taxclass
+      @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
 
-    warn "finding taxed taxes...\n" if $DEBUG > 2;
-    foreach my $tax ( keys %localtaxlisthash ) {
-      my $tax_object = shift @{ $localtaxlisthash{$tax} };
-      warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
-        if $DEBUG > 2;
-      next unless $tax_object->can('tax_on_tax');
+      if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
+        #then try a match without taxclass
+        my %no_taxclass = %taxhash_elim;
+        $no_taxclass{ 'taxclass' } = '';
+        @taxes = qsearch( 'cust_main_county', \%no_taxclass );
+      }
 
-      foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
-        my $totname = ref( $tot ). ' '. $tot->taxnum;
+      $taxhash_elim{ shift(@elim) } = '';
 
-        warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
-          if $DEBUG > 2;
-        next unless exists( $localtaxlisthash{ $totname } ); # only increase
-                                                             # existing taxes
-        warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
-        my $hashref_or_error = 
-          $tax_object->taxline( $localtaxlisthash{$tax},
-                                'custnum'      => $self->custnum,
-                                'invoice_time' => $invoice_time,
-                              );
-        return $hashref_or_error
-          unless ref($hashref_or_error);
-        
-        $taxlisthash->{ $totname } ||= [ $tot ];
-        push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount};
+    } while ( !scalar(@taxes) && scalar(@elim) );
 
-      }
+    foreach (@taxes) {
+      my $tax_id = 'cust_main_county '.$_->taxnum;
+      $taxlisthash->{$tax_id} ||= [ $_ ];
+      push @{ $taxlisthash->{$tax_id} }, $cust_bill_pkg;
     }
 
   }
-
   '';
 }
 
@@ -1478,17 +1588,23 @@ sub retry_realtime {
     cust_bill_batch
   );
 
-  my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
-                                                  @realtime_events
-                                     ).
-                          ' ) ';
+  my $is_realtime_event =
+    ' part_event.action IN ( '.
+        join(',', map "'$_'", @realtime_events ).
+    ' ) ';
+
+  my $batch_or_statustext =
+    "( part_event.action = 'cust_bill_batch'
+       OR ( statustext IS NOT NULL AND statustext != '' )
+     )";
+
 
-  my @cust_event = qsearchs({
+  my @cust_event = qsearch({
     'table'     => 'cust_event',
     'select'    => 'cust_event.*',
     'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
     'hashref'   => { 'status' => 'done' },
-    'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
+    'extra_sql' => " AND $batch_or_statustext ".
                    " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
   });
 
@@ -2156,6 +2272,7 @@ sub apply_payments {
 
     cancel_expired_pkgs
     suspend_adjourned_pkgs
+    unsuspend_resumed_pkgs
 
     bill
       (do_cust_event pre-bill)