fix UI for package editing w/recur_show_zero, add setup_show_zero, RT#9777
[freeside.git] / FS / FS / cust_main / Billing.pm
index 09c0b64..e94cd84 100644 (file)
@@ -3,6 +3,7 @@ package FS::cust_main::Billing;
 use strict;
 use vars qw( $conf $DEBUG $me );
 use Carp;
+use Data::Dumper;
 use List::Util qw( min );
 use FS::UID qw( dbh );
 use FS::Record qw( qsearch qsearchs dbdef );
@@ -11,7 +12,6 @@ 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;
@@ -19,6 +19,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 POSIX;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -111,7 +113,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( $self->day_end( $options{actual_time} ) );
   if ( $error ) {
     $error = "Error expiring custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
@@ -119,7 +121,7 @@ sub bill_and_collect {
     else                                                     { warn   $error; }
   }
 
-  $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
+  $error = $self->suspend_adjourned_pkgs( $self->day_end( $options{actual_time} ) );
   if ( $error ) {
     $error = "Error adjourning custnum ". $self->custnum. ": $error";
     if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
@@ -163,9 +165,22 @@ sub bill_and_collect {
 
 }
 
+sub day_end {
+    # XXX: sometimes "incorrect" if crossing DST boundaries?
+
+    my $self = shift;
+    my $time = shift;
+
+    return $time unless $conf->exists('next-bill-ignore-time');
+
+    my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) =
+        localtime($time);
+    mktime(59,59,23,$mday,$mon,$year,$wday,$yday,$isdst);
+}
+
 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 "
   } );
@@ -188,7 +203,7 @@ sub cancel_expired_pkgs {
 
 sub suspend_adjourned_pkgs {
   my ( $self, $time, %options ) = @_;
-
+  
   my @susp_pkgs = $self->ncancelled_pkgs( {
     'extra_sql' =>
       " AND ( susp IS NULL OR susp = 0 )
@@ -243,6 +258,16 @@ Options are passed as name-value pairs.  Currently available options are:
 
 If set true, re-charges setup fees.
 
+=item recurring_only
+
+If set true then only bill recurring charges, not setup, usage, one time
+charges, etc.
+
+=item freq_override
+
+If set, then override the normal frequency and look for a part_pkg_discount
+to take at that frequency.
+
 =item time
 
 Bills the customer as if it were that time.  Specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.  For example:
@@ -272,6 +297,18 @@ typically might mean not charging the normal recurring fee but only usage
 fees since the last billing. Setup charges may be charged.  Not all package
 plans support this feature (they tend to charge 0).
 
+=item no_usage_reset
+
+Prevent the resetting of usage limits during this call.
+
+=item no_commit
+
+Do not save the generated bill in the database.  Useful with return_bill
+
+=item return_bill
+
+A list reference on which the generated bill(s) will be returned.
+
 =item invoice_terms
 
 Optional terms to be printed on this invoice.  Otherwise, customer-specific
@@ -283,7 +320,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;
 
@@ -320,9 +361,10 @@ sub bill {
     'time'       => $invoice_time,
     'check_freq' => $options{'check_freq'},
     'stage'      => 'pre-bill',
-  );
+  )
+    unless $options{no_commit};
   if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
+    $dbh->rollback if $oldAutoCommit && !$options{no_commit};
     return $error;
   }
 
@@ -374,20 +416,29 @@ 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;
+      while ( $next_bill <= $time ) {
+        $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, or if we're not incrementing 
+        # the bill date.
+        last if $error;
+        last if ($cust_pkg->getfield('bill') || 0) == $next_bill;
+        $next_bill = $cust_pkg->getfield('bill') || 0;
+      }
       if ($error) {
-        $dbh->rollback if $oldAutoCommit;
+        $dbh->rollback if $oldAutoCommit && !$options{no_commit};
         return $error;
       }
 
@@ -407,6 +458,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')
        )
@@ -415,7 +470,7 @@ sub bill {
       my $postal_pkg = $self->charge_postal_fee();
       if ( $postal_pkg && !ref( $postal_pkg ) ) {
 
-        $dbh->rollback if $oldAutoCommit;
+        $dbh->rollback if $oldAutoCommit && !$options{no_commit};
         return "can't charge postal invoice fee for customer ".
           $self->custnum. ": $postal_pkg";
 
@@ -444,7 +499,7 @@ sub bill {
                                 'options'             => \%postal_options,
                               );
           if ($error) {
-            $dbh->rollback if $oldAutoCommit;
+            $dbh->rollback if $oldAutoCommit && !$options{no_commit};
             return $error;
           }
         }
@@ -460,7 +515,7 @@ sub bill {
       $self->calculate_taxes( \@cust_bill_pkg, $taxlisthash{$pass}, $invoice_time);
 
     unless ( ref( $listref_or_error ) ) {
-      $dbh->rollback if $oldAutoCommit;
+      $dbh->rollback if $oldAutoCommit && !$options{no_commit};
       return $listref_or_error;
     }
 
@@ -511,63 +566,89 @@ sub bill {
     #my $balance_adjustments =
     #  sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
 
+    warn "creating the new invoice\n" if $DEBUG;
     #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,
       'invoice_terms'       => $options{'invoice_terms'},
+      'cust_bill_pkg'       => \@cust_bill_pkg,
     } );
-    $error = $cust_bill->insert;
+    $error = $cust_bill->insert unless $options{no_commit};
     if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
+      $dbh->rollback if $oldAutoCommit && !$options{no_commit};
       return "can't create invoice for customer #". $self->custnum. ": $error";
     }
-
-    foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
-      $cust_bill_pkg->invnum($cust_bill->invnum); 
-      my $error = $cust_bill_pkg->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "can't create invoice line item: $error";
-      }
-    }
+    push @{$options{return_bill}}, $cust_bill if $options{return_bill};
 
   } #foreach my $pass ( keys %cust_bill_pkg )
 
   foreach my $hook ( @precommit_hooks ) { 
     eval {
       &{$hook}; #($self) ?
-    };
+    } unless $options{no_commit};
     if ( $@ ) {
-      $dbh->rollback if $oldAutoCommit;
+      $dbh->rollback if $oldAutoCommit && !$options{no_commit};
       return "$@ running precommit hook $hook\n";
     }
   }
   
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit && !$options{no_commit};
+
   ''; #no error
 }
 
 #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);
 
@@ -610,12 +691,17 @@ jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
 =back
 
 =cut
+
 sub calculate_taxes {
   my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
 
-  my @tax_line_items = ();
+  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;
 
-  warn "having a look at the taxes we found...\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)
@@ -688,9 +774,16 @@ sub calculate_taxes {
   foreach my $tax ( keys %$taxlisthash ) {
     foreach ( @{ $taxlisthash->{$tax} }[1 ... scalar(@{ $taxlisthash->{$tax} })] ) {
       next unless ref($_) eq 'FS::cust_bill_pkg';
+     
+      my @cust_tax_exempt_pkg = splice( @{ $_->_cust_tax_exempt_pkg } );
+
+      next unless @cust_tax_exempt_pkg; #just avoiding the prob when irrelevant?
+      die "can't distribute tax exemptions: no line item for ".  Dumper($_).
+          " in packagemap ". join(',', sort {$a<=>$b} keys %packagemap). "\n"
+        unless $packagemap{$_->pkgnum};
 
-      push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, 
-        splice( @{ $_->_cust_tax_exempt_pkg } );
+      push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
+           @cust_tax_exempt_pkg;
     }
   }
 
@@ -754,6 +847,8 @@ 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";
@@ -781,28 +876,35 @@ 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')
-                    )
-               )
-          )
-    )
+  my %setup_param = ();
+  if (     ! $options{recurring_only}
+       and ! $options{cancel}
+       and ( $options{'resetup'}
+             || ( ! $cust_pkg->setup
+                  && ( ! $cust_pkg->start_date
+                       || $cust_pkg->start_date <= $self->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++;
+
+        $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;
@@ -822,21 +924,20 @@ sub _make_lines {
   my $recur = 0;
   my $unitrecur = 0;
   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 || $part_pkg->option('suspend_bill', 1) )
+       and
+            ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= $self->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
     # at collection time at the moment, though...
     $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG)
-      if $part_pkg->can('reset_usage');
+      if $part_pkg->can('reset_usage') && !$options{'no_usage_reset'};
       #don't want to reset usage just cause we want a line item??
       #&& $part_pkg->pkgpart == $real_pkgpart;
 
@@ -850,23 +951,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 ) <= $self->day_end($time)
                                 && !$options{cancel}
                               );
     my %param = ( 'precommit_hooks'     => $precommit_hooks,
                   'increment_next_bill' => $increment_next_bill,
                   'discounts'           => \@discounts,
                   'real_pkgpart'        => $real_pkgpart,
+                  'freq_override'      => $options{freq_override} || '',
+                  'setup_fee'           => 0,
+                  %setup_param,
                 );
 
     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 ( $@ );
 
     if ( $increment_next_bill ) {
 
-      my $next_bill = $part_pkg->add_freq($sdate);
+      my $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
       return "unparsable frequency: ". $part_pkg->freq
         if $next_bill == -1;
   
@@ -881,6 +995,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);
@@ -892,7 +1020,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?
@@ -901,8 +1029,10 @@ 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};
       return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"
         if $error; #just in case
     }
@@ -916,9 +1046,16 @@ 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 = ($recur == 0 && scalar(@discounts) 
+                               && $conf->exists('discount-show-always'));
+
+    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"
@@ -941,13 +1078,14 @@ sub _make_lines {
         'details'   => \@details,
         'discounts' => \@discounts,
         'hidden'    => $part_pkg->hidden,
+        'freq'      => $part_pkg->freq,
       };
 
-      if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
+      if ( $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};
@@ -963,9 +1101,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;
 
@@ -987,6 +1127,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 = ();
     
@@ -1005,18 +1147,14 @@ sub _handle_taxes {
        )
     {
 
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        return "fatal: Can't (yet) use tax-pkg_address with taxproducts";
-      }
-
       foreach my $class (@classes) {
-        my $err_or_ref = $self->_gather_taxes( $part_pkg, $class );
+        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, '' );
+        my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg );
         return $err_or_ref unless ref($err_or_ref);
         $taxes{''} = $err_or_ref;
       }
@@ -1183,9 +1321,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);
@@ -1263,6 +1410,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?
@@ -1433,6 +1583,7 @@ set true to surpress email card/ACH decline notices.
 
 Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
 
+=back
 =cut
 
 # =item payby
@@ -1445,6 +1596,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?
@@ -1515,7 +1669,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;
@@ -1584,6 +1738,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"
@@ -1998,11 +2153,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);
@@ -2022,6 +2180,28 @@ sub apply_payments {
   return $total_unapplied_payments;
 }
 
+=back
+
+=head1 FLOW
+
+  bill_and_collect
+
+    cancel_expired_pkgs
+    suspend_adjourned_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