Merge branch 'patch-5' of https://github.com/gjones2/Freeside (#13854 as this bug...
[freeside.git] / FS / FS / cust_main / Billing.pm
index 496ef71..3dc8f9c 100644 (file)
@@ -7,12 +7,12 @@ use Data::Dumper;
 use List::Util qw( min );
 use FS::UID qw( dbh );
 use FS::Record qw( qsearch qsearchs dbdef );
 use List::Util qw( min );
 use FS::UID qw( dbh );
 use FS::Record qw( qsearch qsearchs dbdef );
+use FS::Misc::DateTime qw( day_end );
 use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_bill_pkg_display;
 use FS::cust_bill_pay;
 use FS::cust_credit_bill;
 use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_bill_pkg_display;
 use FS::cust_bill_pay;
 use FS::cust_credit_bill;
-use FS::cust_pkg;
 use FS::cust_tax_adjustment;
 use FS::tax_rate;
 use FS::tax_rate_location;
 use FS::cust_tax_adjustment;
 use FS::tax_rate;
 use FS::tax_rate_location;
@@ -20,6 +20,8 @@ use FS::cust_bill_pkg_tax_location;
 use FS::cust_bill_pkg_tax_rate_location;
 use FS::part_event;
 use FS::part_event_condition;
 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
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -103,6 +105,9 @@ options of those methods are also available.
 sub bill_and_collect {
   my( $self, %options ) = @_;
 
 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
   my $error;
 
   #$options{actual_time} not $options{time} because freeside-daily -d is for
@@ -112,7 +117,7 @@ sub bill_and_collect {
   my $job = $options{'job'};
 
   $job->update_statustext('0,cleaning expired packages') if $job;
   my $job = $options{'job'};
 
   $job->update_statustext('0,cleaning expired packages') if $job;
-  $error = $self->cancel_expired_pkgs( $options{actual_time} );
+  $error = $self->cancel_expired_pkgs( day_end( $options{actual_time} ) );
   if ( $error ) {
     $error = "Error expiring custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
   if ( $error ) {
     $error = "Error expiring custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
@@ -120,7 +125,7 @@ sub bill_and_collect {
     else                                                     { warn   $error; }
   }
 
     else                                                     { warn   $error; }
   }
 
-  $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
+  $error = $self->suspend_adjourned_pkgs( day_end( $options{actual_time} ) );
   if ( $error ) {
     $error = "Error adjourning custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
   if ( $error ) {
     $error = "Error adjourning custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
@@ -128,6 +133,14 @@ sub bill_and_collect {
     else                                                     { warn   $error; }
   }
 
     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 ) {
   $job->update_statustext('20,billing packages') if $job;
   $error = $self->bill( %options );
   if ( $error ) {
@@ -159,6 +172,7 @@ sub bill_and_collect {
     }
   }
   $job->update_statustext('100,finished') if $job;
     }
   }
   $job->update_statustext('100,finished') if $job;
+  $log->debug('finish', object => $self, agentnum => $self->agentnum);
 
   '';
 
 
   '';
 
@@ -166,7 +180,7 @@ sub bill_and_collect {
 
 sub cancel_expired_pkgs {
   my ( $self, $time, %options ) = @_;
 
 sub cancel_expired_pkgs {
   my ( $self, $time, %options ) = @_;
-
+  
   my @cancel_pkgs = $self->ncancelled_pkgs( { 
     'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
   } );
   my @cancel_pkgs = $self->ncancelled_pkgs( { 
     'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
   } );
@@ -176,20 +190,21 @@ 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,
   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;
   }
 
                                          )
                                        : ()
                                  );
     push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
-  scalar(@errors) ? join(' / ', @errors) : '';
+  join(' / ', @errors);
 
 }
 
 sub suspend_adjourned_pkgs {
   my ( $self, $time, %options ) = @_;
 
 }
 
 sub suspend_adjourned_pkgs {
   my ( $self, $time, %options ) = @_;
-
+  
   my @susp_pkgs = $self->ncancelled_pkgs( {
     'extra_sql' =>
       " AND ( susp IS NULL OR susp = 0 )
   my @susp_pkgs = $self->ncancelled_pkgs( {
     'extra_sql' =>
       " AND ( susp IS NULL OR susp = 0 )
@@ -225,7 +240,25 @@ sub suspend_adjourned_pkgs {
     push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
     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);
 
 }
 
 
 }
 
@@ -252,7 +285,9 @@ charges, etc.
 =item freq_override
 
 If set, then override the normal frequency and look for a part_pkg_discount
 =item freq_override
 
 If set, then override the normal frequency and look for a part_pkg_discount
-to take at that frequency.
+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
 
 
 =item time
 
@@ -379,7 +414,7 @@ sub bill {
 
     next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
 
 
     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', '')
 
     #? to avoid use of uninitialized value errors... ?
     $cust_pkg->setfield('bill', '')
@@ -402,18 +437,37 @@ sub bill {
 
       my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
 
 
       my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
 
-      my $error =
-        $self->_make_lines( 'part_pkg'            => $part_pkg,
-                            'cust_pkg'            => $cust_pkg,
-                            'precommit_hooks'     => \@precommit_hooks,
-                            'line_items'          => $cust_bill_pkg{$pass},
-                            'setup'               => $total_setup{$pass},
-                            'recur'               => $total_recur{$pass},
-                            'tax_matrix'          => $taxlisthash{$pass},
-                            'time'                => $time,
-                            'real_pkgpart'        => $real_pkgpart,
-                            'options'             => \%options,
-                          );
+      my $next_bill = $cust_pkg->getfield('bill') || 0;
+      my $error;
+      # 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,
+                              'precommit_hooks'     => \@precommit_hooks,
+                              'line_items'          => $cust_bill_pkg{$pass},
+                              'setup'               => $total_setup{$pass},
+                              'recur'               => $total_recur{$pass},
+                              'tax_matrix'          => $taxlisthash{$pass},
+                              'time'                => $time,
+                              'real_pkgpart'        => $real_pkgpart,
+                              'options'             => \%options,
+                            );
+
+        # 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};
         return $error;
       if ($error) {
         $dbh->rollback if $oldAutoCommit && !$options{no_commit};
         return $error;
@@ -547,7 +601,7 @@ sub bill {
     #create the new invoice
     my $cust_bill = new FS::cust_bill ( {
       'custnum'             => $self->custnum,
     #create the new invoice
     my $cust_bill = new FS::cust_bill ( {
       'custnum'             => $self->custnum,
-      '_date'               => ( $invoice_time ),
+      '_date'               => $invoice_time,
       'charged'             => $charged,
       'billing_balance'     => $balance,
       'previous_balance'    => $previous_balance,
       'charged'             => $charged,
       'billing_balance'     => $balance,
       'previous_balance'    => $previous_balance,
@@ -580,21 +634,52 @@ sub bill {
 
 #discard bundled packages of 0 value
 sub _omit_zero_value_bundles {
 
 #discard bundled packages of 0 value
 sub _omit_zero_value_bundles {
+  my @in = @_;
 
   my @cust_bill_pkg = ();
   my @cust_bill_pkg_bundle = ();
   my $sum = 0;
 
   my @cust_bill_pkg = ();
   my @cust_bill_pkg_bundle = ();
   my $sum = 0;
+  my $discount_show_always = 0;
+
+  foreach my $cust_bill_pkg ( @in ) {
+
+    $discount_show_always = ($cust_bill_pkg->get('discounts')
+                               && scalar(@{$cust_bill_pkg->get('discounts')})
+                               && $conf->exists('discount-show-always'));
+
+    warn "  pkgnum ". $cust_bill_pkg->pkgnum. " sum $sum, ".
+         "setup_show_zero ". $cust_bill_pkg->setup_show_zero.
+         "recur_show_zero ". $cust_bill_pkg->recur_show_zero. "\n"
+      if $DEBUG > 0;
 
 
-  foreach my $cust_bill_pkg ( @_ ) {
     if (scalar(@cust_bill_pkg_bundle) && !$cust_bill_pkg->pkgpart_override) {
     if (scalar(@cust_bill_pkg_bundle) && !$cust_bill_pkg->pkgpart_override) {
-      push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0;
+      push @cust_bill_pkg, @cust_bill_pkg_bundle 
+        if $sum > 0
+        || ($sum == 0 && (    $discount_show_always
+                           || grep {$_->recur_show_zero || $_->setup_show_zero}
+                                   @cust_bill_pkg_bundle
+                         )
+           );
       @cust_bill_pkg_bundle = ();
       $sum = 0;
     }
       @cust_bill_pkg_bundle = ();
       $sum = 0;
     }
+
     $sum += $cust_bill_pkg->setup + $cust_bill_pkg->recur;
     push @cust_bill_pkg_bundle, $cust_bill_pkg;
     $sum += $cust_bill_pkg->setup + $cust_bill_pkg->recur;
     push @cust_bill_pkg_bundle, $cust_bill_pkg;
+
   }
   }
-  push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0;
+
+  push @cust_bill_pkg, @cust_bill_pkg_bundle
+    if $sum > 0
+    || ($sum == 0 && (    $discount_show_always
+                       || grep {$_->recur_show_zero || $_->setup_show_zero}
+                               @cust_bill_pkg_bundle
+                     )
+       );
+
+  warn "  _omit_zero_value_bundles: ". scalar(@in).
+       '->'. scalar(@cust_bill_pkg). "\n" #. Dumper(@cust_bill_pkg). "\n"
+    if $DEBUG > 2;
 
   (@cust_bill_pkg);
 
 
   (@cust_bill_pkg);
 
@@ -641,6 +726,11 @@ jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
 sub calculate_taxes {
   my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
 
 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"
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
   warn "$me calculate_taxes\n"
@@ -650,32 +740,52 @@ sub calculate_taxes {
   my @tax_line_items = ();
 
   # keys are tax names (as printed on invoices / itemdesc )
   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 %taxname = ();
 
   # keys are taxlisthash keys (internal identifiers)
   # values are (cumulative) amounts
-  my %tax = ();
+  my %tax_amount = ();
 
   # keys are taxlisthash keys (internal identifiers)
 
   # 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)
   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 = ();
 
   my %tax_rate_location = ();
 
+  # keys are taxnums (not internal identifiers!)
+  # values are arrayrefs of cust_tax_exempt_pkg objects
+  my %tax_exemption;
+
   foreach my $tax ( keys %$taxlisthash ) {
   foreach my $tax ( keys %$taxlisthash ) {
+    # $tax is a tax identifier
     my $tax_object = shift @{ $taxlisthash->{$tax} };
     my $tax_object = shift @{ $taxlisthash->{$tax} };
+    # $tax_object is a cust_main_county or tax_rate 
+    # (with pkgnum and locationnum set)
+    # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg objects
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
     warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
     warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
+    # 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_object->taxnum} ||= [];
     my $hashref_or_error =
     my $hashref_or_error =
-      $tax_object->taxline( $taxlisthash->{$tax},
+      $tax_object->taxline( $taxables,
                             'custnum'      => $self->custnum,
                             'custnum'      => $self->custnum,
-                            'invoice_time' => $invoice_time
+                            'invoice_time' => $invoice_time,
+                            'exemptions'   => $exemptions,
                           );
     return $hashref_or_error unless ref($hashref_or_error);
 
                           );
     return $hashref_or_error unless ref($hashref_or_error);
 
+    # then collect any new exemptions generated for this tax
+    push @$exemptions, @{ $_->cust_tax_exempt_pkg }
+      foreach @$taxables;
+
     unshift @{ $taxlisthash->{$tax} }, $tax_object;
 
     my $name   = $hashref_or_error->{'name'};
     unshift @{ $taxlisthash->{$tax} }, $tax_object;
 
     my $name   = $hashref_or_error->{'name'};
@@ -685,10 +795,12 @@ sub calculate_taxes {
     $taxname{ $name } ||= [];
     push @{ $taxname{ $name } }, $tax;
 
     $taxname{ $name } ||= [];
     push @{ $taxname{ $name } }, $tax;
 
-    $tax{ $tax } += $amount;
+    $tax_amount{ $tax } += $amount;
 
 
+    # link records between cust_main_county/tax_rate and cust_location
     $tax_location{ $tax } ||= [];
     $tax_location{ $tax } ||= [];
-    if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
+    $tax_rate_location{ $tax } ||= [];
+    if ( ref($tax_object) eq 'FS::cust_main_county' ) {
       push @{ $tax_location{ $tax }  },
         {
           'taxnum'      => $tax_object->taxnum, 
       push @{ $tax_location{ $tax }  },
         {
           'taxnum'      => $tax_object->taxnum, 
@@ -698,9 +810,7 @@ sub calculate_taxes {
           'amount'      => sprintf('%.2f', $amount ),
         };
     }
           'amount'      => sprintf('%.2f', $amount ),
         };
     }
-
-    $tax_rate_location{ $tax } ||= [];
-    if ( ref($tax_object) eq 'FS::tax_rate' ) {
+    elsif ( ref($tax_object) eq 'FS::tax_rate' ) {
       my $taxratelocationnum =
         $tax_object->tax_rate_location->taxratelocationnum;
       push @{ $tax_rate_location{ $tax }  },
       my $taxratelocationnum =
         $tax_object->tax_rate_location->taxratelocationnum;
       push @{ $tax_rate_location{ $tax }  },
@@ -718,25 +828,37 @@ sub calculate_taxes {
   #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 ) {
   #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';
-      push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, 
-        splice( @{ $_->_cust_tax_exempt_pkg } );
+    my $taxables = $taxlisthash->{$tax};
+    my $tax_object = shift @$taxables; # the rest are line items
+    foreach my $cust_bill_pkg ( @$taxables ) {
+      next unless ref($cust_bill_pkg) eq 'FS::cust_bill_pkg';
+
+      my @cust_tax_exempt_pkg = splice @{ $cust_bill_pkg->cust_tax_exempt_pkg };
+
+      next unless @cust_tax_exempt_pkg;
+      # get the non-disintegrated version
+      my $real_cust_bill_pkg = $packagemap{$cust_bill_pkg->pkgnum}
+        or die "can't distribute tax exemptions: no line item for ".
+          Dumper($_). " in packagemap ". 
+          join(',', sort {$a<=>$b} keys %packagemap). "\n";
+
+      push @{ $real_cust_bill_pkg->cust_tax_exempt_pkg },
+           @cust_tax_exempt_pkg;
     }
   }
 
   #consolidate and create tax line items
   warn "consolidating and generating...\n" if $DEBUG > 2;
   foreach my $taxname ( keys %taxname ) {
     }
   }
 
   #consolidate and create tax line items
   warn "consolidating and generating...\n" if $DEBUG > 2;
   foreach my $taxname ( keys %taxname ) {
-    my $tax = 0;
+    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}++;
     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};
+      warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1;
+      $tax_total += $tax_amount{$taxitem};
       push @cust_bill_pkg_tax_location,
         map { new FS::cust_bill_pkg_tax_location $_ }
             @{ $tax_location{ $taxitem } };
       push @cust_bill_pkg_tax_location,
         map { new FS::cust_bill_pkg_tax_location $_ }
             @{ $tax_location{ $taxitem } };
@@ -744,9 +866,9 @@ sub calculate_taxes {
         map { new FS::cust_bill_pkg_tax_rate_location $_ }
             @{ $tax_rate_location{ $taxitem } };
     }
         map { new FS::cust_bill_pkg_tax_rate_location $_ }
             @{ $tax_rate_location{ $taxitem } };
     }
-    next unless $tax;
+    next unless $tax_total;
 
 
-    $tax = sprintf('%.2f', $tax );
+    $tax_total = sprintf('%.2f', $tax_total );
   
     my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
                                                    'disabled'     => '',
   
     my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
                                                    'disabled'     => '',
@@ -767,7 +889,7 @@ sub calculate_taxes {
 
     push @tax_line_items, new FS::cust_bill_pkg {
       'pkgnum'   => 0,
 
     push @tax_line_items, new FS::cust_bill_pkg {
       'pkgnum'   => 0,
-      'setup'    => $tax,
+      'setup'    => $tax_total,
       'recur'    => 0,
       'sdate'    => '',
       'edate'    => '',
       'recur'    => 0,
       'sdate'    => '',
       'edate'    => '',
@@ -789,7 +911,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 $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";
   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";
@@ -797,13 +919,18 @@ sub _make_lines {
   my $time = $params{'time'} or die "no time specified";
   my (%options) = %{$params{options}};
 
   my $time = $params{'time'} or die "no time specified";
   my (%options) = %{$params{options}};
 
+  if ( $part_pkg->freq ne '1' and ($options{'freq_override'} || 0) > 0 ) {
+    # this should never happen
+    die 'freq_override billing attempted on non-monthly package '.
+      $cust_pkg->pkgnum;
+  }
+
   my $dbh = dbh;
   my $real_pkgpart = $params{real_pkgpart};
   my %hash = $cust_pkg->hash;
   my $old_cust_pkg = new FS::cust_pkg \%hash;
 
   my @details = ();
   my $dbh = dbh;
   my $real_pkgpart = $params{real_pkgpart};
   my %hash = $cust_pkg->hash;
   my $old_cust_pkg = new FS::cust_pkg \%hash;
 
   my @details = ();
-  my @discounts = ();
   my $lineitems = 0;
 
   $cust_pkg->pkgpart($part_pkg->pkgpart);
   my $lineitems = 0;
 
   $cust_pkg->pkgpart($part_pkg->pkgpart);
@@ -814,29 +941,36 @@ sub _make_lines {
 
   my $setup = 0;
   my $unitsetup = 0;
 
   my $setup = 0;
   my $unitsetup = 0;
-  if ( $options{'resetup'}
-       || ( ! $cust_pkg->setup
-            && ( ! $cust_pkg->start_date
-                 || $cust_pkg->start_date <= $time
-               )
-            && ( ! $conf->exists('disable_setup_suspended_pkgs')
-                 || ( $conf->exists('disable_setup_suspended_pkgs') &&
-                      ! $cust_pkg->getfield('susp')
-                    )
-               )
-          )
-        and !$options{recurring_only}
-    )
+  my @setup_discounts = ();
+  my %setup_param = ( 'discounts' => \@setup_discounts );
+  if (     ! $options{recurring_only}
+       and ! $options{cancel}
+       and ( $options{'resetup'}
+             || ( ! $cust_pkg->setup
+                  && ( ! $cust_pkg->start_date
+                       || $cust_pkg->start_date <= day_end($time)
+                     )
+                  && ( ! $conf->exists('disable_setup_suspended_pkgs')
+                       || ( $conf->exists('disable_setup_suspended_pkgs') &&
+                            ! $cust_pkg->getfield('susp')
+                          )
+                     )
+                )
+           )
+     )
   {
     
     warn "    bill setup\n" if $DEBUG > 1;
   {
     
     warn "    bill setup\n" if $DEBUG > 1;
-    $lineitems++;
 
 
-    $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
-    return "$@ running calc_setup for $cust_pkg\n"
-      if $@;
+    unless ( $cust_pkg->waive_setup ) {
+        $lineitems++;
+
+        $setup = eval { $cust_pkg->calc_setup( $time, \@details, \%setup_param ) };
+        return "$@ running calc_setup for $cust_pkg\n"
+          if $@;
 
 
-    $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
+        $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
+    }
 
     $cust_pkg->setfield('setup', $time)
       unless $cust_pkg->setup;
 
     $cust_pkg->setfield('setup', $time)
       unless $cust_pkg->setup;
@@ -852,14 +986,17 @@ sub _make_lines {
   # bill recurring fee
   ### 
 
   # bill recurring fee
   ### 
 
-  #XXX unit stuff here too
   my $recur = 0;
   my $unitrecur = 0;
   my $recur = 0;
   my $unitrecur = 0;
+  my @recur_discounts = ();
   my $sdate;
   if (     ! $cust_pkg->start_date
   my $sdate;
   if (     ! $cust_pkg->start_date
-       and ( ! $cust_pkg->susp || $part_pkg->option('suspend_bill') )
+       and ( ! $cust_pkg->susp || $cust_pkg->option('suspend_bill',1)
+                               || ( $part_pkg->option('suspend_bill', 1) )
+                                     && ! $cust_pkg->option('no_suspend_bill',1)
+                                  )
        and
        and
-            ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= $time )
+            ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= day_end($time) )
          || ( $part_pkg->plan eq 'voip_cdr'
                && $part_pkg->option('bill_every_call')
             )
          || ( $part_pkg->plan eq 'voip_cdr'
                && $part_pkg->option('bill_every_call')
             )
@@ -883,25 +1020,36 @@ sub _make_lines {
 
     #over two params!  lets at least switch to a hashref for the rest...
     my $increment_next_bill = ( $part_pkg->freq ne '0'
 
     #over two params!  lets at least switch to a hashref for the rest...
     my $increment_next_bill = ( $part_pkg->freq ne '0'
-                                && ( $cust_pkg->getfield('bill') || 0 ) <= $time
+                                && ( $cust_pkg->getfield('bill') || 0 ) <= day_end($time)
                                 && !$options{cancel}
                               );
                                 && !$options{cancel}
                               );
-    my %param = ( 'precommit_hooks'     => $precommit_hooks,
+    my %param = ( %setup_param,
+                  'precommit_hooks'     => $precommit_hooks,
                   'increment_next_bill' => $increment_next_bill,
                   'increment_next_bill' => $increment_next_bill,
-                  'discounts'           => \@discounts,
+                  'discounts'           => \@recur_discounts,
                   'real_pkgpart'        => $real_pkgpart,
                   'freq_override'      => $options{freq_override} || '',
                   'real_pkgpart'        => $real_pkgpart,
                   'freq_override'      => $options{freq_override} || '',
+                  'setup_fee'           => 0,
                 );
 
     my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
 
     # There may be some part_pkg for which this is wrong.  Only those
     # which can_discount are supported.
                 );
 
     my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
 
     # There may be some part_pkg for which this is wrong.  Only those
     # which can_discount are supported.
+    # (the UI should prevent adding discounts to these at the moment)
 
 
+    warn "calling $method on cust_pkg ". $cust_pkg->pkgnum.
+         " for pkgpart ". $cust_pkg->pkgpart.
+         " with params ". join(' / ', map "$_=>$param{$_}", keys %param). "\n"
+      if $DEBUG > 2;
+           
     $recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) };
     return "$@ running $method for $cust_pkg\n"
       if ( $@ );
 
     $recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) };
     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);
     if ( $increment_next_bill ) {
 
       my $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
@@ -919,6 +1067,20 @@ sub _make_lines {
 
     }
 
 
     }
 
+    if ( $param{'setup_fee'} ) {
+      # Add an additional setup fee at the billing stage.
+      # Used for prorate_defer_bill.
+      $setup += $param{'setup_fee'};
+      $unitsetup += $param{'setup_fee'};
+      $lineitems++;
+    }
+
+    if ( defined $param{'discount_left_setup'} ) {
+        foreach my $discount_setup ( values %{$param{'discount_left_setup'}} ) {
+            $setup -= $discount_setup;
+        }
+    }
+
   }
 
   warn "\$setup is undefined" unless defined($setup);
   }
 
   warn "\$setup is undefined" unless defined($setup);
@@ -939,6 +1101,7 @@ sub _make_lines {
         if $DEBUG >1;
   
       my $error = $cust_pkg->replace( $old_cust_pkg,
         if $DEBUG >1;
   
       my $error = $cust_pkg->replace( $old_cust_pkg,
+                                      'depend_jobnum'=>$options{depend_jobnum},
                                       'options' => { $cust_pkg->options },
                                     )
         unless $options{no_commit};
                                       'options' => { $cust_pkg->options },
                                     )
         unless $options{no_commit};
@@ -955,9 +1118,18 @@ sub _make_lines {
       return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
     }
 
       return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
     }
 
-    if ( $setup != 0 ||
-         $recur != 0 ||
-         !$part_pkg->hidden && $options{has_hidden} ) #include some $0 lines
+    my $discount_show_always = $conf->exists('discount-show-always')
+                               && (    ($setup == 0 && scalar(@setup_discounts))
+                                    || ($recur == 0 && scalar(@recur_discounts))
+                                  );
+
+    if (    $setup != 0
+         || $recur != 0
+         || (!$part_pkg->hidden && $options{has_hidden}) #include some $0 lines
+         || $discount_show_always
+         || ($setup == 0 && $cust_pkg->_X_show_zero('setup'))
+         || ($recur == 0 && $cust_pkg->_X_show_zero('recur'))
+       ) 
     {
 
       warn "    charges (setup=$setup, recur=$recur); adding line items\n"
     {
 
       warn "    charges (setup=$setup, recur=$recur); adding line items\n"
@@ -978,16 +1150,21 @@ sub _make_lines {
         'unitrecur' => $unitrecur,
         'quantity'  => $cust_pkg->quantity,
         'details'   => \@details,
         'unitrecur' => $unitrecur,
         'quantity'  => $cust_pkg->quantity,
         'details'   => \@details,
-        'discounts' => \@discounts,
+        'discounts' => [ @setup_discounts, @recur_discounts ],
         'hidden'    => $part_pkg->hidden,
         'freq'      => $part_pkg->freq,
       };
 
         'hidden'    => $part_pkg->hidden,
         'freq'      => $part_pkg->freq,
       };
 
-      if ( $part_pkg->option('recur_temporality', 1) 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};
         $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};
-      } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) {
+      } else { #if ( $part_pkg->recur_temporality eq 'upcoming' ) {
         $cust_bill_pkg->sdate( $sdate );
         $cust_bill_pkg->edate( $cust_pkg->bill );
         #$cust_bill_pkg->edate( $time ) if $options{cancel};
         $cust_bill_pkg->sdate( $sdate );
         $cust_bill_pkg->edate( $cust_pkg->bill );
         #$cust_bill_pkg->edate( $time ) if $options{cancel};
@@ -1003,9 +1180,11 @@ sub _make_lines {
       # handle taxes
       ###
 
       # handle taxes
       ###
 
-      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 ) {
+         my $error = 
+           $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
+         return $error if $error;
+      }
 
       push @$cust_bill_pkgs, $cust_bill_pkg;
 
 
       push @$cust_bill_pkgs, $cust_bill_pkg;
 
@@ -1038,7 +1217,14 @@ sub _handle_taxes {
   push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
   push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
 
   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' ) {
+  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 !~ /Y/i && $self->payby ne 'COMP' ) {
+  if ( $self->payby ne 'COMP' ) {
 
     if ( $conf->exists('enable_taxproducts')
          && ( scalar($part_pkg->part_pkg_taxoverride)
 
     if ( $conf->exists('enable_taxproducts')
          && ( scalar($part_pkg->part_pkg_taxoverride)
@@ -1047,38 +1233,38 @@ sub _handle_taxes {
        )
     {
 
        )
     {
 
-      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);
-        $taxes{$class} = $err_or_ref;
-      }
+      if ( !$exempt ) {
 
 
-      unless (exists $taxes{''}) {
-        my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg );
-        return $err_or_ref unless ref($err_or_ref);
-        $taxes{''} = $err_or_ref;
-      }
+        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);
+          $taxes{$class} = $err_or_ref;
+        }
 
 
-    } else {
+        unless (exists $taxes{''}) {
+          my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg );
+          return $err_or_ref unless ref($err_or_ref);
+          $taxes{''} = $err_or_ref;
+        }
 
 
-      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;
       }
 
       }
 
+    } else { # cust_main_county tax system
+
+      # We fetch taxes even if the customer is completely exempt,
+      # because we need to record that fact.
+
+      my @loc_keys = qw( district city county state country );
+      my $location = $cust_pkg->tax_location;
+      my %taxhash = map { $_ => $location->$_ } @loc_keys;
+
       $taxhash{'taxclass'} = $part_pkg->taxclass;
 
       $taxhash{'taxclass'} = $part_pkg->taxclass;
 
-      my @taxes = ();
+      warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2;
+
+      my @taxes = (); # entries are cust_main_county objects
       my %taxhash_elim = %taxhash;
       my %taxhash_elim = %taxhash;
-      my @elim = qw( city county state );
+      my @elim = qw( district city county state );
       do { 
 
         #first try a match with taxclass
       do { 
 
         #first try a match with taxclass
@@ -1095,15 +1281,11 @@ sub _handle_taxes {
 
       } while ( !scalar(@taxes) && scalar(@elim) );
 
 
       } while ( !scalar(@taxes) && scalar(@elim) );
 
-      @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
-                    @taxes
-        if $self->cust_main_exemption; #just to be safe
-
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        foreach (@taxes) {
-          $_->set('pkgnum',      $cust_pkg->pkgnum );
-          $_->set('locationnum', $cust_pkg->locationnum );
-        }
+      foreach (@taxes) {
+        # These could become cust_bill_pkg_tax_location records,
+        # or cust_tax_exempt_pkg.  We'll decide later.
+        $_->set('pkgnum',       $cust_pkg->pkgnum);
+        $_->set('locationnum',  $cust_pkg->tax_locationnum);
       }
 
       $taxes{''} = [ @taxes ];
       }
 
       $taxes{''} = [ @taxes ];
@@ -1120,61 +1302,38 @@ sub _handle_taxes {
 
     } #if $conf->exists('enable_taxproducts') ...
 
 
     } #if $conf->exists('enable_taxproducts') ...
 
-  }
-  my @display = ();
-  my $separate = $conf->exists('separate_usage');
-  my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
-  my $usage_mandate = $temp_pkg->part_pkg->option('usage_mandate', 'Hush!');
-  my $section = $temp_pkg->part_pkg->categoryname;
-  if ( $separate || $section || $usage_mandate ) {
-
-    my %hash = ( 'section' => $section );
-
-    $section = $temp_pkg->part_pkg->option('usage_section', 'Hush!');
-    my $summary = $temp_pkg->part_pkg->option('summarize_usage', 'Hush!');
-    if ( $separate ) {
-      push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
-      push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
-    } else {
-      push @display, new FS::cust_bill_pkg_display
-                       { type => '',
-                         %hash,
-                         ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
-                       };
-    }
-
-    if ($separate && $section && $summary) {
-      push @display, new FS::cust_bill_pkg_display { type    => 'U',
-                                                     summary => 'Y',
-                                                     %hash,
-                                                   };
-    }
-    if ($usage_mandate || $section && $summary) {
-      $hash{post_total} = 'Y';
-    }
-
-    if ($separate || $usage_mandate) {
-      $hash{section} = $section if ($separate || $usage_mandate);
-      push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
-    }
+  } # if $self->payby eq 'COMP'
 
 
-  }
-  $cust_bill_pkg->set('display', \@display);
+  #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,
+                             );
 
   my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
   foreach my $key (keys %tax_cust_bill_pkg) {
 
   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 ) {
 
     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;
       my $taxname = ref( $tax ). ' '. $tax->taxnum;
-#      $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
-#                  ' locationnum'. $cust_pkg->locationnum
-#        if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
-
+      $taxname .= ' pkgnum'. $cust_pkg->pkgnum;
+      # We need to create a separate $taxlisthash entry for each pkgnum
+      # 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;
 
       $taxlisthash->{ $taxname } ||= [ $tax ];
       push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
 
@@ -1225,9 +1384,13 @@ sub _gather_taxes {
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
-  my $geocode = $self->geocode('cch');
-  $geocode = $cust_pkg->cust_location->geocode('cch')
-    if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum );
+  my $geocode;
+  if ( $cust_pkg->locationnum && $conf->exists('tax-pkg_address') ) {
+    $geocode = $cust_pkg->cust_location->geocode('cch');
+  } else {
+    $geocode = $self->geocode('cch');
+  }
+
   my @taxes = ();
 
   my @taxclassnums = map { $_->taxclassnum }
   my @taxes = ();
 
   my @taxclassnums = map { $_->taxclassnum }
@@ -1390,8 +1553,12 @@ sub retry_realtime {
   my $mine = 
   '( '
    . join ( ' OR ' , map { 
   my $mine = 
   '( '
    . join ( ' OR ' , map { 
+    my $cust_join = FS::part_event->eventtables_cust_join->{$_} || '';
+    my $custnum = FS::part_event->eventtables_custnum->{$_};
     "( part_event.eventtable = " . dbh->quote($_) 
     "( part_event.eventtable = " . dbh->quote($_) 
-    . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
+    . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key 
+    . " from $_ $cust_join"
+    . " where $custnum = " . dbh->quote( $self->custnum ) . "))" ;
    } FS::part_event->eventtables)
    . ') ';
 
    } FS::part_event->eventtables)
    . ') ';
 
@@ -1407,17 +1574,23 @@ sub retry_realtime {
     cust_bill_batch
   );
 
     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' },
     '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"
   });
 
                    " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
   });
 
@@ -1551,7 +1724,7 @@ sub do_cust_event {
     #XXX lock event
     
     #re-eval event conditions (a previous event could have changed things)
     #XXX lock event
     
     #re-eval event conditions (a previous event could have changed things)
-    unless ( $cust_event->test_conditions( 'time' => $time ) ) {
+    unless ( $cust_event->test_conditions ) {
       #don't leave stray "new/locked" records around
       my $error = $cust_event->delete;
       return $error if $error;
       #don't leave stray "new/locked" records around
       my $error = $cust_event->delete;
       return $error if $error;
@@ -1565,7 +1738,7 @@ sub do_cust_event {
         if $DEBUG > 1;
 
       #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
         if $DEBUG > 1;
 
       #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
-      if ( my $error = $cust_event->do_event() ) {
+      if ( my $error = $cust_event->do_event( 'time' => $time ) ) {
         #XXX wtf is this?  figure out a proper dealio with return value
         #from do_event
         return $error;
         #XXX wtf is this?  figure out a proper dealio with return value
         #from do_event
         return $error;
@@ -1622,174 +1795,6 @@ Explicitly pass the objects to be tested (typically used with eventtable).
 Set to true to return the objects, but not actually insert them into the
 database.
 
 Set to true to return the objects, but not actually insert them into the
 database.
 
-=item discount_terms
-
-Returns a list of lengths for term discounts
-
-=cut
-
-sub _discount_pkgs_and_bill {
-my $self = shift;
-
-  my @cust_bill = $self->cust_bill;
-  my $cust_bill = pop @cust_bill;
-  return () unless $cust_bill && $cust_bill->owed;
-
-  my @where = ();
-  push @where, "cust_bill_pkg.invnum = ". $cust_bill->invnum;
-  push @where, "cust_bill_pkg.pkgpart_override IS NULL";
-  push @where, "part_pkg.freq = '1'";
-  push @where, "(cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0)";
-  push @where, "(cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0)";
-  push @where, "0<(SELECT count(*) FROM part_pkg_discount
-                  WHERE part_pkg.pkgpart = part_pkg_discount.pkgpart)";
-  push @where,
-    "0=(SELECT count(*) FROM cust_bill_pkg_discount
-         WHERE cust_bill_pkg.billpkgnum = cust_bill_pkg_discount.billpkgnum)";
-
-  my $extra_sql = 'WHERE '. join(' AND ', @where);
-
-  my @cust_pkg = 
-    qsearch({
-      'table' => 'cust_pkg',
-      'select' => "DISTINCT cust_pkg.*",
-      'addl_from' => 'JOIN cust_bill_pkg USING(pkgnum) '.
-                     'JOIN part_pkg USING(pkgpart)',
-      'hashref' => {},
-      'extra_sql' => $extra_sql,
-    }); 
-
-  ($cust_bill, @cust_pkg);
-}
-
-sub _discountable_pkgs_at_term {
-  my ($term, @pkgs) = @_;
-  my $part_pkg = new FS::part_pkg { freq => $term - 1 };
-  grep { ( !$_->adjourn || $_->adjourn > $part_pkg->add_freq($_->bill) ) && 
-         ( !$_->expire  || $_->expire  > $part_pkg->add_freq($_->bill) )
-       }
-    @pkgs;
-}
-
-=item discount_terms
-
-Returns a list of lengths for term discounts
-
-=cut
-
-sub discount_terms {
-my $self = shift;
-
-  my %terms = ();
-
-  my @discount_pkgs = $self->_discount_pkgs_and_bill;
-  shift @discount_pkgs; #discard bill;
-  
-  map { $terms{$_->months} = 1 }
-    grep { $_->months && $_->months > 1 }
-    map { $_->discount }
-    map { $_->part_pkg->part_pkg_discount }
-    @discount_pkgs;
-
-  return sort { $a <=> $b } keys %terms;
-
-}
-
-=back
-
-=item discount_term_values MONTHS
-
-Returns a list with credit, dollar amount saved, and total bill acheived
-by prepaying the most recent invoice for MONTHS.
-
-=cut
-
-sub discount_term_values {
-  my $self = shift;
-  my $term = shift;
-
-  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
-
-  warn "$me discount_term_values called with $term\n" if $DEBUG;
-
-  my %result = ();
-
-  my @packages = $self->_discount_pkgs_and_bill;
-  my $cust_bill = shift(@packages);
-  @packages = _discountable_pkgs_at_term( $term, @packages );
-  return () unless scalar(@packages);
-
-  $_->bill($_->last_bill) foreach @packages;
-  my @final = map { new FS::cust_pkg { $_->hash } } @packages;
-
-  my %options = (
-                  'recurring_only' => 1,
-                  'no_usage_reset' => 1,
-                  'no_commit'      => 1,
-                );
-
-  my %params =  (
-                  'return_bill'    => [],
-                  'pkg_list'       => \@packages,
-                  'time'           => $cust_bill->_date,
-                );
-
-  my $error = $self->bill(%options, %params);
-  die $error if $error; # XXX think about this a bit more
-
-  my $credit = 0;
-  $credit += $_->charged foreach @{$params{return_bill}};
-  $credit = sprintf('%.2f', $credit);
-  warn "$me discount_term_values $term credit: $credit\n" if $DEBUG;
-
-  %params =  (
-               'return_bill'    => [],
-               'pkg_list'       => \@packages,
-               'time'           => $packages[0]->part_pkg->add_freq($cust_bill->_date)
-             );
-
-  $error = $self->bill(%options, %params);
-  die $error if $error; # XXX think about this a bit more
-
-  my $next = 0;
-  $next += $_->charged foreach @{$params{return_bill}};
-  warn "$me discount_term_values $term next: $next\n" if $DEBUG;
-  
-  %params =  ( 
-               'return_bill'    => [],
-               'pkg_list'       => \@final,
-               'time'           => $cust_bill->_date,
-               'freq_override'  => $term,
-             );
-
-  $error = $self->bill(%options, %params);
-  die $error if $error; # XXX think about this a bit more
-
-  my $final = $self->balance - $credit;
-  $final += $_->charged foreach @{$params{return_bill}};
-  $final = sprintf('%.2f', $final);
-  warn "$me discount_term_values $term final: $final\n" if $DEBUG;
-
-  my $savings = sprintf('%.2f', $self->balance + ($term - 1) * $next - $final);
-
-  ( $credit, $savings, $final );
-
-}
-
-sub discount_terms_hash {
-  my $self = shift;
-
-  my %result = ();
-  my @terms = $self->discount_terms;
-  foreach my $term (@terms) {
-    my @result = $self->discount_term_values($term);
-    $result{$term} = [ @result ] if scalar(@result);
-  }
-
-  return %result;
-
-}
-
 =back
 
 =cut
 =back
 
 =cut
@@ -1843,45 +1848,45 @@ sub due_cust_event {
 
       @objects = @{ $opt{'objects'} };
 
 
       @objects = @{ $opt{'objects'} };
 
-    } else {
+    } elsif ( $eventtable eq 'cust_main' ) {
 
 
-      #my @objects = $self->$eventtable(); # sub cust_main { @{ [ $self ] }; }
-      if ( $eventtable eq 'cust_main' ) {
-        @objects = ( $self );
-      } else {
-
-        my $cm_join =
-          "LEFT JOIN cust_main USING ( custnum )";
-
-        #some false laziness w/Cron::bill bill_where
-
-        my $join  = FS::part_event_condition->join_conditions_sql( $eventtable);
-        my $where = FS::part_event_condition->where_conditions_sql($eventtable,
-                                                           'time'=>$opt{'time'},
-                                                                  );
-        $where = $where ? "AND $where" : '';
-
-        my $are_part_event = 
-          "EXISTS ( SELECT 1 FROM part_event $join
-                      WHERE check_freq = '$check_freq'
-                        AND eventtable = '$eventtable'
-                        AND ( disabled = '' OR disabled IS NULL )
-                        $where
-                  )
-          ";
-        #eofalse
-
-        @objects = $self->$eventtable(
-                     'addl_from' => $cm_join,
-                     'extra_sql' => " AND $are_part_event",
-                   );
-      }
+      @objects = ( $self );
 
 
-    }
+    } else {
+
+      my $cm_join = " LEFT JOIN cust_main USING ( custnum )";
+      # linkage not needed here because FS::cust_main->$eventtable will 
+      # already supply it
+
+      #some false laziness w/Cron::bill bill_where
+
+      my $join  = FS::part_event_condition->join_conditions_sql( $eventtable);
+      my $where = FS::part_event_condition->where_conditions_sql($eventtable,
+        'time'=>$opt{'time'},
+      );
+      $where = $where ? "AND $where" : '';
+
+      my $are_part_event = 
+      "EXISTS ( SELECT 1 FROM part_event $join
+        WHERE check_freq = '$check_freq'
+        AND eventtable = '$eventtable'
+        AND ( disabled = '' OR disabled IS NULL )
+        $where
+        )
+      ";
+      #eofalse
+
+      @objects = $self->$eventtable(
+        'addl_from' => $cm_join,
+        'extra_sql' => " AND $are_part_event",
+      );
+    } # if ( !$opt{objects} and $eventtable ne 'cust_main' )
 
     my @e_cust_event = ();
 
 
     my @e_cust_event = ();
 
-    my $cross = "CROSS JOIN $eventtable";
+    my $linkage = FS::part_event->eventtables_cust_join->{$eventtable} || '';
+
+    my $cross = "CROSS JOIN $eventtable $linkage";
     $cross .= ' LEFT JOIN cust_main USING ( custnum )'
       unless $eventtable eq 'cust_main';
 
     $cross .= ' LEFT JOIN cust_main USING ( custnum )'
       unless $eventtable eq 'cust_main';
 
@@ -1929,7 +1934,9 @@ sub due_cust_event {
              " possible events found for $eventtable ". $object->$pkey(). "\n";
       }
 
              " possible events found for $eventtable ". $object->$pkey(). "\n";
       }
 
-      push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
+      push @e_cust_event, map { 
+        $_->new_cust_event($object, 'time' => $opt{'time'}) 
+      } @part_event;
 
     }
 
 
     }
 
@@ -1963,8 +1970,7 @@ sub due_cust_event {
   
   my %unsat = ();
 
   
   my %unsat = ();
 
-  @cust_event = grep $_->test_conditions( 'time'          => $opt{'time'},
-                                          'stats_hashref' => \%unsat ),
+  @cust_event = grep $_->test_conditions( 'stats_hashref' => \%unsat ),
                      @cust_event;
 
   warn "  ". scalar(@cust_event). " cust events left satisfying conditions\n"
                      @cust_event;
 
   warn "  ". scalar(@cust_event). " cust events left satisfying conditions\n"
@@ -2217,11 +2223,14 @@ sub apply_payments {
 
     my $amount = min( $payment->unapplied, $owed );
 
 
     my $amount = min( $payment->unapplied, $owed );
 
-    my $cust_bill_pay = new FS::cust_bill_pay ( {
+    my $cbp = {
       'paynum' => $payment->paynum,
       'invnum' => $cust_bill->invnum,
       'amount' => $amount,
       'paynum' => $payment->paynum,
       'invnum' => $cust_bill->invnum,
       'amount' => $amount,
-    } );
+    };
+    $cbp->{_date} = $payment->_date 
+        if $options{'manual'} && $options{'backdate_application'};
+    my $cust_bill_pay = new FS::cust_bill_pay($cbp);
     $cust_bill_pay->pkgnum( $payment->pkgnum )
       if $conf->exists('pkg-balances') && $payment->pkgnum;
     my $error = $cust_bill_pay->insert(%options);
     $cust_bill_pay->pkgnum( $payment->pkgnum )
       if $conf->exists('pkg-balances') && $payment->pkgnum;
     my $error = $cust_bill_pay->insert(%options);
@@ -2241,6 +2250,29 @@ sub apply_payments {
   return $total_unapplied_payments;
 }
 
   return $total_unapplied_payments;
 }
 
+=back
+
+=head1 FLOW
+
+  bill_and_collect
+
+    cancel_expired_pkgs
+    suspend_adjourned_pkgs
+    unsuspend_resumed_pkgs
+
+    bill
+      (do_cust_event pre-bill)
+      _make_lines
+        _handle_taxes
+          (vendor-only) _gather_taxes
+      _omit_zero_value_bundles
+      calculate_taxes
+
+    apply_payments_and_credits
+    collect
+      do_cust_event
+        due_cust_event
+
 =head1 BUGS
 
 =head1 SEE ALSO
 =head1 BUGS
 
 =head1 SEE ALSO