Merge branch 'patch-8' of https://github.com/gjones2/Freeside (#13854 as this bug...
[freeside.git] / FS / FS / cust_main / Billing.pm
index 37eae8c..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 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_pkg;
 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::pkg_category;
+use FS::Log;
 
 # 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 ) = @_;
 
+  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
@@ -112,7 +117,7 @@ sub bill_and_collect {
   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; }
@@ -120,7 +125,7 @@ sub bill_and_collect {
     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; }
@@ -128,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 ) {
@@ -159,6 +172,7 @@ sub bill_and_collect {
     }
   }
   $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 ) = @_;
-
+  
   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,
-                                           '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);
 
 }
 
 sub suspend_adjourned_pkgs {
   my ( $self, $time, %options ) = @_;
-
+  
   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;
   }
 
-  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
-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
 
@@ -306,7 +341,11 @@ terms or the default terms are used.
 
 sub bill {
   my( $self, %options ) = @_;
+
   return '' if $self->payby eq 'COMP';
+
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
   warn "$me bill customer ". $self->custnum. "\n"
     if $DEBUG;
 
@@ -375,7 +414,7 @@ sub bill {
 
     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', '')
@@ -398,18 +437,37 @@ sub bill {
 
       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;
@@ -431,6 +489,10 @@ sub bill {
 
     next unless @cust_bill_pkg; #don't create an invoice w/o line items
 
+    warn "$me billing pass $pass\n"
+           #.Dumper(\@cust_bill_pkg)."\n"
+      if $DEBUG > 2;
+
     if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
            !$conf->exists('postal_invoice-recurring_only')
        )
@@ -539,7 +601,7 @@ sub bill {
     #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,
@@ -572,21 +634,52 @@ sub bill {
 
 #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 $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) {
-      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;
     }
+
     $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);
 
@@ -633,37 +726,66 @@ jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
 sub calculate_taxes {
   my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
 
-  my @tax_line_items = ();
+  # $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
 
-  warn "having a look at the taxes we found...\n" if $DEBUG > 2;
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+  warn "$me calculate_taxes\n"
+       #.Dumper($self, $cust_bill_pkg, $taxlisthash, $invoice_time). "\n"
+    if $DEBUG > 2;
+
+  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 taxnums (not internal identifiers!)
+  # values are arrayrefs of cust_tax_exempt_pkg objects
+  my %tax_exemption;
+
   foreach my $tax ( keys %$taxlisthash ) {
+    # $tax is a tax identifier
     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;
+    # 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 =
-      $tax_object->taxline( $taxlisthash->{$tax},
+      $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);
 
+    # 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'};
@@ -673,10 +795,12 @@ sub calculate_taxes {
     $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 } ||= [];
-    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, 
@@ -686,9 +810,7 @@ sub calculate_taxes {
           '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 }  },
@@ -706,26 +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 ) {
-    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 ) {
-    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}++;
-      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 } };
@@ -733,9 +866,9 @@ sub calculate_taxes {
         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'     => '',
@@ -756,7 +889,7 @@ sub calculate_taxes {
 
     push @tax_line_items, new FS::cust_bill_pkg {
       'pkgnum'   => 0,
-      'setup'    => $tax,
+      'setup'    => $tax_total,
       'recur'    => 0,
       'sdate'    => '',
       'edate'    => '',
@@ -774,9 +907,11 @@ sub calculate_taxes {
 sub _make_lines {
   my ($self, %params) = @_;
 
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
   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";
@@ -784,13 +919,18 @@ sub _make_lines {
   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 @discounts = ();
   my $lineitems = 0;
 
   $cust_pkg->pkgpart($part_pkg->pkgpart);
@@ -801,29 +941,36 @@ sub _make_lines {
 
   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;
-    $lineitems++;
 
-    $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
-    return "$@ running calc_setup for $cust_pkg\n"
-      if $@;
+    unless ( $cust_pkg->waive_setup ) {
+        $lineitems++;
 
-    $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
+        $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
+    }
 
     $cust_pkg->setfield('setup', $time)
       unless $cust_pkg->setup;
@@ -839,19 +986,21 @@ 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->get('susp')
-       and ! $cust_pkg->get('start_date')
-       and ( $part_pkg->getfield('freq') ne '0'
-             && ( $cust_pkg->getfield('bill') || 0 ) <= $time
-           )
-        || ( $part_pkg->plan eq 'voip_cdr'
-              && $part_pkg->option('bill_every_call')
-           )
-        || ( $options{cancel} )
+  if (     ! $cust_pkg->start_date
+       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'
+               && $part_pkg->option('bill_every_call')
+            )
+         || $options{cancel}
   ) {
 
     # XXX should this be a package event?  probably.  events are called
@@ -871,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'
-                                && ( $cust_pkg->getfield('bill') || 0 ) <= $time
+                                && ( $cust_pkg->getfield('bill') || 0 ) <= day_end($time)
                                 && !$options{cancel}
                               );
-    my %param = ( 'precommit_hooks'     => $precommit_hooks,
+    my %param = ( %setup_param,
+                  'precommit_hooks'     => $precommit_hooks,
                   'increment_next_bill' => $increment_next_bill,
-                  'discounts'           => \@discounts,
+                  'discounts'           => \@recur_discounts,
                   '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.
+    # (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 ( $@ );
 
+    #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);
@@ -907,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);
@@ -918,7 +1092,7 @@ sub _make_lines {
   # If $cust_pkg has been modified, update it (if we're a real pkgpart)
   ###
 
-  if ( $lineitems || $options{has_hidden} ) {
+  if ( $lineitems ) {
 
     if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) {
       # hmm.. and if just the options are modified in some weird price plan?
@@ -927,6 +1101,7 @@ sub _make_lines {
         if $DEBUG >1;
   
       my $error = $cust_pkg->replace( $old_cust_pkg,
+                                      'depend_jobnum'=>$options{depend_jobnum},
                                       'options' => { $cust_pkg->options },
                                     )
         unless $options{no_commit};
@@ -943,9 +1118,18 @@ sub _make_lines {
       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"
@@ -966,16 +1150,21 @@ sub _make_lines {
         'unitrecur' => $unitrecur,
         'quantity'  => $cust_pkg->quantity,
         'details'   => \@details,
-        'discounts' => \@discounts,
+        'discounts' => [ @setup_discounts, @recur_discounts ],
         '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};
-      } 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};
@@ -991,9 +1180,11 @@ sub _make_lines {
       # 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;
 
@@ -1015,6 +1206,8 @@ sub _handle_taxes {
   my $real_pkgpart = shift;
   my $options = shift;
 
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
   my %cust_bill_pkg = ();
   my %taxes = ();
     
@@ -1024,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});
 
-  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)
@@ -1033,42 +1233,38 @@ sub _handle_taxes {
        )
     {
 
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        return "fatal: Can't (yet) use tax-pkg_address with taxproducts";
-      }
+      if ( !$exempt ) {
 
-      foreach my $class (@classes) {
-        my $err_or_ref = $self->_gather_taxes( $part_pkg, $class );
-        return $err_or_ref unless ref($err_or_ref);
-        $taxes{$class} = $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;
+        }
+
+        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;
+        }
 
-      unless (exists $taxes{''}) {
-        my $err_or_ref = $self->_gather_taxes( $part_pkg, '' );
-        return $err_or_ref unless ref($err_or_ref);
-        $taxes{''} = $err_or_ref;
       }
 
-    } else {
+    } else { # cust_main_county tax system
 
-      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;
-      }
+      # 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;
 
-      my @taxes = ();
+      warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2;
+
+      my @taxes = (); # entries are cust_main_county objects
       my %taxhash_elim = %taxhash;
-      my @elim = qw( city county state );
+      my @elim = qw( district city county state );
       do { 
 
         #first try a match with taxclass
@@ -1085,15 +1281,11 @@ sub _handle_taxes {
 
       } 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 ];
@@ -1110,61 +1302,38 @@ sub _handle_taxes {
 
     } #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 $self->payby eq 'COMP'
 
-    if ($separate || $usage_mandate) {
-      $hash{section} = $section if ($separate || $usage_mandate);
-      push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
-    }
-
-  }
-  $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) {
+    # $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 .= ' 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;
 
@@ -1211,9 +1380,18 @@ sub _gather_taxes {
   my $self = shift;
   my $part_pkg = shift;
   my $class = shift;
+  my $cust_pkg = shift;
+
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+  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 $geocode = $self->geocode('cch');
 
   my @taxclassnums = map { $_->taxclassnum }
                      $part_pkg->part_pkg_taxoverride($class);
@@ -1291,6 +1469,9 @@ Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in opt
 
 sub collect {
   my( $self, %options ) = @_;
+
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
   my $invoice_time = $options{'invoice_time'} || time;
 
   #put below somehow?
@@ -1372,8 +1553,12 @@ sub retry_realtime {
   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($_) 
-    . " 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)
    . ') ';
 
@@ -1389,17 +1574,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"
   });
 
@@ -1474,6 +1665,9 @@ Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in opt
 
 sub do_cust_event {
   my( $self, %options ) = @_;
+
+  local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
   my $time = $options{'time'} || time;
 
   #put below somehow?
@@ -1530,7 +1724,7 @@ sub do_cust_event {
     #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;
@@ -1544,7 +1738,7 @@ sub do_cust_event {
         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;
@@ -1601,171 +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.
 
-=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;
-  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
@@ -1778,6 +1807,7 @@ sub due_cust_event {
   #my $DEBUG = $opt{'debug'}
   local($DEBUG) = $opt{'debug'}
     if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
+  $DEBUG = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
   warn "$me due_cust_event called with options ".
        join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
@@ -1818,45 +1848,45 @@ sub due_cust_event {
 
       @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 $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';
 
@@ -1904,7 +1934,9 @@ sub due_cust_event {
              " 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;
 
     }
 
@@ -1938,8 +1970,7 @@ sub due_cust_event {
   
   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"
@@ -2192,11 +2223,14 @@ sub apply_payments {
 
     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,
-    } );
+    };
+    $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);
@@ -2216,6 +2250,29 @@ sub apply_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