leading summary page invoices #RT5086
[freeside.git] / FS / FS / cust_main.pm
index cc39983..16ab0ee 100644 (file)
@@ -9,6 +9,7 @@ use Safe;
 use Carp;
 use Exporter;
 use Scalar::Util qw( blessed );
+use List::Util qw( min );
 use Time::Local qw(timelocal);
 use Data::Dumper;
 use Tie::IxHash;
@@ -30,6 +31,7 @@ use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_bill_pkg_display;
 use FS::cust_bill_pkg_tax_location;
+use FS::cust_bill_pkg_tax_rate_location;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::cust_pay_void;
@@ -39,7 +41,10 @@ use FS::cust_refund;
 use FS::part_referral;
 use FS::cust_main_county;
 use FS::cust_location;
+use FS::cust_main_exemption;
+use FS::cust_tax_adjustment;
 use FS::tax_rate;
+use FS::tax_rate_location;
 use FS::cust_tax_location;
 use FS::part_pkg_taxrate;
 use FS::agent;
@@ -76,6 +81,8 @@ $skip_fuzzyfiles = 0;
 $ignore_expired_card = 0;
 
 @encrypted_fields = ('payinfo', 'paycvv');
+sub nohistory_fields { ('paycvv'); }
+
 @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
 
 #ask FS::UID to run this stuff for us later
@@ -359,7 +366,7 @@ invoicing_list destination to the newly-created svc_acct.  Here's an example:
 
   $cust_main->insert( {}, [ $email, 'POST' ] );
 
-Currently available options are: I<depend_jobnum> and I<noexport>.
+Currently available options are: I<depend_jobnum>, I<noexport> and I<tax_exemption>.
 
 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
 on the supplied jobnum (they will not run until the specific job completes).
@@ -370,6 +377,9 @@ The I<noexport> option is deprecated.  If I<noexport> is set true, no
 provisioning jobs (exports) are scheduled.  (You can schedule them later with
 the B<reexport> method.)
 
+The I<tax_exemption> option can be set to an arrayref of tax names.
+FS::cust_main_exemption records will be created and inserted.
+
 =cut
 
 sub insert {
@@ -455,6 +465,24 @@ sub insert {
     $self->invoicing_list( $invoicing_list );
   }
 
+  warn "  setting cust_main_exemption\n"
+    if $DEBUG > 1;
+
+  my $tax_exemption = delete $options{'tax_exemption'};
+  if ( $tax_exemption ) {
+    foreach my $taxname ( @$tax_exemption ) {
+      my $cust_main_exemption = new FS::cust_main_exemption {
+        'custnum' => $self->custnum,
+        'taxname' => $taxname,
+      };
+      my $error = $cust_main_exemption->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "inserting cust_main_exemption (transaction rolled back): $error";
+      }
+    }
+  }
+
   if (    $conf->config('cust_main-skeleton_tables')
        && $conf->config('cust_main-skeleton_custnum') ) {
 
@@ -705,6 +733,14 @@ jobs will have a dependancy on the supplied job (they will not run until the
 specific job completes).  This can be used to defer provisioning until some
 action completes (such as running the customer's credit card successfully).
 
+=item ticket_subject
+
+Optional subject for a ticket created and attached to this customer
+
+=item ticket_subject
+
+Optional queue name for ticket additions
+
 =back
 
 =cut
@@ -724,6 +760,9 @@ sub order_pkg {
   $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'}
     if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'};
 
+  my %insert_params = map { $opt->{$_} ? ( $_ => $opt->{$_} ) : () }
+                          qw( ticket_subject ticket_queue );
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -747,7 +786,7 @@ sub order_pkg {
 
   $cust_pkg->custnum( $self->custnum );
 
-  my $error = $cust_pkg->insert;
+  my $error = $cust_pkg->insert( %insert_params );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "inserting cust_pkg (transaction rolled back): $error";
@@ -1280,6 +1319,16 @@ sub delete {
     }
   }
 
+  foreach my $cust_main_exemption (
+    qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } )
+  ) {
+    my $error = $cust_main_exemption->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   my $error = $self->SUPER::delete;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
@@ -1291,7 +1340,8 @@ sub delete {
 
 }
 
-=item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ]
+=item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
+
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
@@ -1303,6 +1353,11 @@ check_invoicing_list first.  Here's an example:
 
   $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
 
+Currently available options are: I<tax_exemption>.
+
+The I<tax_exemption> option can be set to an arrayref of tax names.
+FS::cust_main_exemption records will be deleted and inserted as appropriate.
+
 =cut
 
 sub replace {
@@ -1349,7 +1404,7 @@ sub replace {
     return $error;
   }
 
-  if ( @param ) { # INVOICING_LIST_ARYREF
+  if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
     my $invoicing_list = shift @param;
     $error = $self->check_invoicing_list( $invoicing_list );
     if ( $error ) {
@@ -1359,6 +1414,40 @@ sub replace {
     $self->invoicing_list( $invoicing_list );
   }
 
+  my %options = @param;
+
+  my $tax_exemption = delete $options{'tax_exemption'};
+  if ( $tax_exemption ) {
+
+    my %cust_main_exemption =
+      map { $_->taxname => $_ }
+          qsearch('cust_main_exemption', { 'custnum' => $old->custnum } );
+
+    foreach my $taxname ( @$tax_exemption ) {
+
+      next if delete $cust_main_exemption{$taxname};
+
+      my $cust_main_exemption = new FS::cust_main_exemption {
+        'custnum' => $self->custnum,
+        'taxname' => $taxname,
+      };
+      my $error = $cust_main_exemption->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "inserting cust_main_exemption (transaction rolled back): $error";
+      }
+    }
+
+    foreach my $cust_main_exemption ( values %cust_main_exemption ) {
+      my $error = $cust_main_exemption->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "deleting cust_main_exemption (transaction rolled back): $error";
+      }
+    }
+
+  }
+
   if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
        grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
     # card/check/lec info has changed, want to retry realtime_ invoice events
@@ -1464,6 +1553,7 @@ sub check {
     || $self->ut_textn('stateid_state')
     || $self->ut_textn('invoice_terms')
     || $self->ut_alphan('geocode')
+    || $self->ut_floatn('cdr_termination_percentage')
   ;
 
   #barf.  need message catalogs.  i18n.  etc.
@@ -1481,6 +1571,13 @@ sub check {
     unless ! $self->referral_custnum 
            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
 
+  if ( $self->censustract ne '' ) {
+    $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
+      or return "Illegal census tract: ". $self->censustract;
+    
+    $self->censustract("$1.$2");
+  }
+
   if ( $self->ss eq '' ) {
     $self->ss('');
   } else {
@@ -1748,6 +1845,8 @@ sub check {
     my( $m, $y );
     if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
       ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
+    } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
+      ( $m, $y ) = ( $2, "19$1" );
     } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
       ( $m, $y ) = ( $3, "20$2" );
     } else {
@@ -1772,7 +1871,7 @@ sub check {
     $self->payname($1);
   }
 
-  foreach my $flag (qw( tax spool_cdr squelch_cdr )) {
+  foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) {
     $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
     $self->$flag($1);
   }
@@ -1968,6 +2067,18 @@ sub unsuspended_pkgs {
   grep { ! $_->susp } $self->ncancelled_pkgs;
 }
 
+=item next_bill_date
+
+Returns the next date this customer will be billed, as a UNIX timestamp, or
+undef if no active package has a next bill date.
+
+=cut
+
+sub next_bill_date {
+  my $self = shift;
+  min( map $_->get('bill'), grep $_->get('bill'), $self->unsuspended_pkgs );
+}
+
 =item num_cancelled_pkgs
 
 Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
@@ -2099,12 +2210,16 @@ Available options are:
 
 =item ban - can be set true to ban this customer's credit card or ACH information, if present.
 
+=item nobill - can be set true to skip billing if it might otherwise be done.
+
 =back
 
 Always returns a list: an empty list on success or a list of errors.
 
 =cut
 
+# nb that dates are not specified as valid options to this method
+
 sub cancel {
   my( $self, %opt ) = @_;
 
@@ -2130,6 +2245,13 @@ sub cancel {
 
   my @pkgs = $self->ncancelled_pkgs;
 
+  if ( !$opt{nobill} && $conf->exists('bill_usage_on_cancel') ) {
+    $opt{nobill} = 1;
+    my $error = $self->bill( pkg_list => [ @pkgs ], cancel => 1 );
+    warn "Error billing during cancel, custnum ". $self->custnum. ": $error"
+      if $error;
+  }
+
   warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
        scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
     if $DEBUG;
@@ -2218,23 +2340,45 @@ Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in opt
 
 =back
 
+Options are passed to the B<bill> and B<collect> methods verbatim, so all
+options of those methods are also available.
+
 =cut
 
 sub bill_and_collect {
   my( $self, %options ) = @_;
 
-  ###
-  # cancel packages
-  ###
-
   #$options{actual_time} not $options{time} because freeside-daily -d is for
   #pre-printing invoices
-  my @cancel_pkgs = grep { $_->expire && $_->expire <= $options{actual_time} }
-                         $self->ncancelled_pkgs;
+  $self->cancel_expired_pkgs(    $options{actual_time} );
+  $self->suspend_adjourned_pkgs( $options{actual_time} );
+
+  my $error = $self->bill( %options );
+  warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
+
+  $self->apply_payments_and_credits;
+
+  unless ( $conf->exists('cancelled_cust-noevents')
+           && ! $self->num_ncancelled_pkgs
+  ) {
+
+    $error = $self->collect( %options );
+    warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
+
+  }
+
+}
+
+sub cancel_expired_pkgs {
+  my ( $self, $time ) = @_;
+
+  my @cancel_pkgs = $self->ncancelled_pkgs( { 
+    'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
+  } );
 
   foreach my $cust_pkg ( @cancel_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
-    my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
+    my $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
                                            'reason_otaker' => $cpr->otaker
                                          )
                                        : ()
@@ -2244,24 +2388,32 @@ sub bill_and_collect {
       if $error;
   }
 
-  ###
-  # suspend packages
-  ###
+}
 
-  #$options{actual_time} not $options{time} because freeside-daily -d is for
-  #pre-printing invoices
-  my @susp_pkgs = 
-    grep { ! $_->susp
-           && (    (    $_->part_pkg->is_prepaid
-                     && $_->bill
-                     && $_->bill < $options{actual_time}
-                   )
-                || (    $_->adjourn
-                    && $_->adjourn <= $options{actual_time}
-                  )
-              )
+sub suspend_adjourned_pkgs {
+  my ( $self, $time ) = @_;
+
+  my @susp_pkgs = $self->ncancelled_pkgs( {
+    'extra_sql' =>
+      " AND ( susp IS NULL OR susp = 0 )
+        AND (    ( bill    IS NOT NULL AND bill    != 0 AND bill    <  $time )
+              OR ( adjourn IS NOT NULL AND adjourn != 0 AND adjourn <= $time )
+            )
+      ",
+  } );
+
+  #only because there's no SQL test for is_prepaid :/
+  @susp_pkgs = 
+    grep {     (    $_->part_pkg->is_prepaid
+                 && $_->bill
+                 && $_->bill < $time
+               )
+            || (    $_->adjourn
+                 && $_->adjourn <= $time
+               )
+           
          }
-         $self->ncancelled_pkgs;
+         @susp_pkgs;
 
   foreach my $cust_pkg ( @susp_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
@@ -2277,18 +2429,6 @@ sub bill_and_collect {
       if $error;
   }
 
-  ###
-  # bill and collect
-  ###
-
-  my $error = $self->bill( %options );
-  warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
-
-  $self->apply_payments_and_credits;
-
-  $error = $self->collect( %options );
-  warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
-
 }
 
 =item bill OPTIONS
@@ -2320,10 +2460,21 @@ An array ref of specific packages (objects) to attempt billing, instead trying a
 
  $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
 
+=item not_pkgpart
+
+A hashref of pkgparts to exclude from this billing run.
+
 =item invoice_time
 
 Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices.  Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
 
+=item cancel
+
+This boolean value informs the us that the package is being cancelled.  This
+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).
+
 =back
 
 =cut
@@ -2337,7 +2488,8 @@ sub bill {
   my $time = $options{'time'} || time;
   my $invoice_time = $options{'invoice_time'} || $time;
 
-  #put below somehow?
+  $options{'not_pkgpart'} ||= {};
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -2351,6 +2503,17 @@ sub bill {
 
   $self->select_for_update; #mutex
 
+  my $error = $self->do_cust_event(
+    'debug'      => ( $options{'debug'} || 0 ),
+    'time'       => $invoice_time,
+    'check_freq' => $options{'check_freq'},
+    'stage'      => 'pre-bill',
+  );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
   my @cust_bill_pkg = ();
 
   ###
@@ -2362,11 +2525,10 @@ sub bill {
   my %taxlisthash;
   my @precommit_hooks = ();
 
-  my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } );
-  foreach my $cust_pkg (@cust_pkgs) {
+  $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ];  #param checks?
+  foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
 
-    #NO!! next if $cust_pkg->cancel;  
-    next if $cust_pkg->getfield('cancel');  
+    next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
 
     warn "  bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
 
@@ -2392,6 +2554,7 @@ sub bill {
                             'recur'               => \$total_recur,
                             'tax_matrix'          => \%taxlisthash,
                             'time'                => $time,
+                            'real_pkgpart'        => $real_pkgpart,
                             'options'             => \%options,
                           );
       if ($error) {
@@ -2409,35 +2572,44 @@ sub bill {
     return '';
   }
 
-  my $postal_pkg = $self->charge_postal_fee();
-  if ( $postal_pkg && !ref( $postal_pkg ) ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "can't charge postal invoice fee for customer ".
-      $self->custnum. ": $postal_pkg";
-  }
-  if ( $postal_pkg &&
-       ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
+  if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
          !$conf->exists('postal_invoice-recurring_only')
-       )
      )
   {
-    foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
-      my $error =
-        $self->_make_lines( 'part_pkg'            => $part_pkg,
-                            'cust_pkg'            => $postal_pkg,
-                            'precommit_hooks'     => \@precommit_hooks,
-                            'line_items'          => \@cust_bill_pkg,
-                            'setup'               => \$total_setup,
-                            'recur'               => \$total_recur,
-                            'tax_matrix'          => \%taxlisthash,
-                            'time'                => $time,
-                            'options'             => \%options,
-                          );
-      if ($error) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
+
+    my $postal_pkg = $self->charge_postal_fee();
+    if ( $postal_pkg && !ref( $postal_pkg ) ) {
+
+      $dbh->rollback if $oldAutoCommit;
+      return "can't charge postal invoice fee for customer ".
+        $self->custnum. ": $postal_pkg";
+
+    } elsif ( $postal_pkg ) {
+
+      my $real_pkgpart = $postal_pkg->pkgpart;
+      foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
+        my %postal_options = %options;
+        delete $postal_options{cancel};
+        my $error =
+          $self->_make_lines( 'part_pkg'            => $part_pkg,
+                              'cust_pkg'            => $postal_pkg,
+                              'precommit_hooks'     => \@precommit_hooks,
+                              'line_items'          => \@cust_bill_pkg,
+                              'setup'               => \$total_setup,
+                              'recur'               => \$total_recur,
+                              'tax_matrix'          => \%taxlisthash,
+                              'time'                => $time,
+                              'real_pkgpart'        => $real_pkgpart,
+                              'options'             => \%postal_options,
+                            );
+        if ($error) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
       }
+
     }
+
   }
 
   warn "having a look at the taxes we found...\n" if $DEBUG > 2;
@@ -2454,9 +2626,14 @@ sub bill {
   # values are listrefs 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
+  my %tax_rate_location = ();
+
   foreach my $tax ( keys %taxlisthash ) {
     my $tax_object = shift @{ $taxlisthash{$tax} };
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
+    warn " ". join('/', @{ $taxlisthash{$tax} } ). "\n" if $DEBUG > 2;
     my $hashref_or_error =
       $tax_object->taxline( $taxlisthash{$tax},
                             'custnum'      => $self->custnum,
@@ -2489,72 +2666,40 @@ sub bill {
         };
     }
 
+    $tax_rate_location{ $tax } ||= [];
+    if ( ref($tax_object) eq 'FS::tax_rate' ) {
+      my $taxratelocationnum =
+        $tax_object->tax_rate_location->taxratelocationnum;
+      push @{ $tax_rate_location{ $tax }  },
+        {
+          'taxnum'             => $tax_object->taxnum, 
+          'taxtype'            => ref($tax_object),
+          'amount'             => sprintf('%.2f', $amount ),
+          'locationtaxid'      => $tax_object->location,
+          'taxratelocationnum' => $taxratelocationnum,
+        };
+    }
+
   }
 
   #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
   my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg;
   foreach my $tax ( keys %taxlisthash ) {
     foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) {
-      next unless ref($_) eq 'FS::cust_bill_pkg'; # shouldn't happen
+      next unless ref($_) eq 'FS::cust_bill_pkg';
 
       push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, 
         splice( @{ $_->_cust_tax_exempt_pkg } );
     }
   }
 
-  #some taxes are taxed
-  my %totlisthash;
-  
-  warn "finding taxed taxes...\n" if $DEBUG > 2;
-  foreach my $tax ( keys %taxlisthash ) {
-    my $tax_object = shift @{ $taxlisthash{$tax} };
-    warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
-      if $DEBUG > 2;
-    next unless $tax_object->can('tax_on_tax');
-
-    foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
-      my $totname = ref( $tot ). ' '. $tot->taxnum;
-
-      warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
-        if $DEBUG > 2;
-      next unless exists( $taxlisthash{ $totname } ); # only increase
-                                                      # existing taxes
-      warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
-      if ( exists( $totlisthash{ $totname } ) ) {
-        push @{ $totlisthash{ $totname  } }, $tax{ $tax };
-      }else{
-        $totlisthash{ $totname } = [ $tot, $tax{ $tax } ];
-      }
-    }
-  }
-
-  warn "having a look at taxed taxes...\n" if $DEBUG > 2;
-  foreach my $tax ( keys %totlisthash ) {
-    my $tax_object = shift @{ $totlisthash{$tax} };
-    warn "found previously found taxed tax ". $tax_object->taxname. "\n"
-      if $DEBUG > 2;
-    my $listref_or_error =
-      $tax_object->taxline( $totlisthash{$tax},
-                            'custnum'      => $self->custnum,
-                            'invoice_time' => $invoice_time
-                          );
-    unless (ref($listref_or_error)) {
-      $dbh->rollback if $oldAutoCommit;
-      return $listref_or_error;
-    }
-
-    warn "adding taxed tax amount ". $listref_or_error->[1].
-         " as ". $tax_object->taxname. "\n"
-      if $DEBUG;
-    $tax{ $tax } += $listref_or_error->[1];
-  }
-  
   #consolidate and create tax line items
   warn "consolidating and generating...\n" if $DEBUG > 2;
   foreach my $taxname ( keys %taxname ) {
     my $tax = 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}++;
@@ -2563,12 +2708,32 @@ sub bill {
       push @cust_bill_pkg_tax_location,
         map { new FS::cust_bill_pkg_tax_location $_ }
             @{ $tax_location{ $taxitem } };
+      push @cust_bill_pkg_tax_rate_location,
+        map { new FS::cust_bill_pkg_tax_rate_location $_ }
+            @{ $tax_rate_location{ $taxitem } };
     }
     next unless $tax;
 
     $tax = sprintf('%.2f', $tax );
     $total_setup = sprintf('%.2f', $total_setup+$tax );
   
+    my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
+                                                   'disabled'     => '',
+                                                 },
+                               );
+
+    my @display = ();
+    if ( $pkg_category and
+         $conf->config('invoice_latexsummary') ||
+         $conf->config('invoice_htmlsummary')
+       )
+    {
+
+      my %hash = (  'section' => $pkg_category->categoryname );
+      push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+
+    }
+
     push @cust_bill_pkg, new FS::cust_bill_pkg {
       'pkgnum'   => 0,
       'setup'    => $tax,
@@ -2576,20 +2741,64 @@ sub bill {
       'sdate'    => '',
       'edate'    => '',
       'itemdesc' => $taxname,
+      'display'  => \@display,
       'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
+      'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
+    };
+
+  }
+
+  #add tax adjustments
+  warn "adding tax adjustments...\n" if $DEBUG > 2;
+  foreach my $cust_tax_adjustment (
+    qsearch('cust_tax_adjustment', { 'custnum'    => $self->custnum,
+                                     'billpkgnum' => '',
+                                   }
+           )
+  ) {
+
+    my $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
+    $total_setup = sprintf('%.2f', $total_setup+$tax );
+
+    my $itemdesc = $cust_tax_adjustment->taxname;
+    $itemdesc = '' if $itemdesc eq 'Tax';
+
+    push @cust_bill_pkg, new FS::cust_bill_pkg {
+      'pkgnum'      => 0,
+      'setup'       => $tax,
+      'recur'       => 0,
+      'sdate'       => '',
+      'edate'       => '',
+      'itemdesc'    => $itemdesc,
+      'itemcomment' => $cust_tax_adjustment->comment,
+      'cust_tax_adjustment' => $cust_tax_adjustment,
+      #'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
     };
 
   }
 
   my $charged = sprintf('%.2f', $total_setup + $total_recur );
 
+  my @cust_bill = $self->cust_bill;
+  my $balance = $self->balance;
+  my $previous_balance = scalar(@cust_bill)
+                           ?  $cust_bill[$#cust_bill]->billing_balance
+                           :  0;
+
+  $previous_balance += $cust_bill[$#cust_bill]->charged
+    if scalar(@cust_bill);
+  #my $balance_adjustments =
+  #  sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
+
   #create the new invoice
   my $cust_bill = new FS::cust_bill ( {
-    'custnum' => $self->custnum,
-    '_date'   => ( $invoice_time ),
-    'charged' => $charged,
+    'custnum'             => $self->custnum,
+    '_date'               => ( $invoice_time ),
+    'charged'             => $charged,
+    'billing_balance'     => $balance,
+    'previous_balance'    => $previous_balance,
   } );
-  my $error = $cust_bill->insert;
+  $error = $cust_bill->insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "can't create invoice for customer #". $self->custnum. ": $error";
@@ -2631,10 +2840,10 @@ sub _make_lines {
   my $total_recur = $params{recur} or die "no recur accumulator specified";
   my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified";
   my $time = $params{'time'} or die "no time specified";
-  my (%options) = %{$params{options}};  #hmmm  only for 'resetup'
+  my (%options) = %{$params{options}};
 
   my $dbh = dbh;
-  my $real_pkgpart = $cust_pkg->pkgpart;
+  my $real_pkgpart = $params{real_pkgpart};
   my %hash = $cust_pkg->hash;
   my $old_cust_pkg = new FS::cust_pkg \%hash;
 
@@ -2650,14 +2859,19 @@ sub _make_lines {
 
   my $setup = 0;
   my $unitsetup = 0;
-  if ( ! $cust_pkg->setup &&
-       (
-         ( $conf->exists('disable_setup_suspended_pkgs') &&
-          ! $cust_pkg->getfield('susp')
-        ) || ! $conf->exists('disable_setup_suspended_pkgs')
-       )
-    || $options{'resetup'}
-  ) {
+  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')
+                    )
+               )
+          )
+    )
+  {
     
     warn "    bill setup\n" if $DEBUG > 1;
     $lineitems++;
@@ -2673,6 +2887,9 @@ sub _make_lines {
           #do need it, but it won't get written to the db
           #|| $cust_pkg->pkgpart != $real_pkgpart;
 
+    $cust_pkg->setfield('start_date', '')
+      if $cust_pkg->start_date;
+
   }
 
   ###
@@ -2683,13 +2900,15 @@ sub _make_lines {
   my $recur = 0;
   my $unitrecur = 0;
   my $sdate;
-  if ( ! $cust_pkg->getfield('susp') and
-           ( $part_pkg->getfield('freq') ne '0' &&
-             ( $cust_pkg->getfield('bill') || 0 ) <= $time
+  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} )
   ) {
 
     # XXX should this be a package event?  probably.  events are called
@@ -2703,18 +2922,22 @@ sub _make_lines {
     $lineitems++;
 
     # XXX shared with $recur_prog
-    $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
+    $sdate = ( $options{cancel} ? $cust_pkg->last_bill : $cust_pkg->bill )
+             || $cust_pkg->setup
+             || $time;
 
     #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
+                                && !$options{cancel}
                               );
     my %param = ( 'precommit_hooks'     => $precommit_hooks,
                   'increment_next_bill' => $increment_next_bill,
                 );
 
-    $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) };
-    return "$@ running calc_recur for $cust_pkg\n"
+    my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
+    $recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) };
+    return "$@ running $method for $cust_pkg\n"
       if ( $@ );
 
     if ( $increment_next_bill ) {
@@ -2789,14 +3012,17 @@ sub _make_lines {
         'unitrecur' => $unitrecur,
         'quantity'  => $cust_pkg->quantity,
         'details'   => \@details,
+        'hidden'    => $part_pkg->hidden,
       };
 
       if ( $part_pkg->option('recur_temporality', 1) 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' ) {
         $cust_bill_pkg->sdate( $sdate );
         $cust_bill_pkg->edate( $cust_pkg->bill );
+        #$cust_bill_pkg->edate( $time ) if $options{cancel};
       }
 
       $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart)
@@ -2810,7 +3036,7 @@ sub _make_lines {
       ###
 
       my $error = 
-        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg);
+        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart);
       return $error if $error;
 
       push @$cust_bill_pkgs, $cust_bill_pkg;
@@ -2829,6 +3055,8 @@ sub _handle_taxes {
   my $taxlisthash = shift;
   my $cust_bill_pkg = shift;
   my $cust_pkg = shift;
+  my $invoice_time = shift;
+  my $real_pkgpart = shift;
 
   my %cust_bill_pkg = ();
   my %taxes = ();
@@ -2891,6 +3119,10 @@ sub _handle_taxes {
         @taxes = qsearch( 'cust_main_county', \%taxhash_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 );
@@ -2903,32 +3135,41 @@ sub _handle_taxes {
       $taxes{'recur'} = [ @taxes ];
       $taxes{$_} = [ @taxes ] foreach (@classes);
 
-      # maybe eliminate this entirely, along with all the 0% records
-      unless ( @taxes ) {
-        return
-          "fatal: can't find tax rate for state/county/country/taxclass ".
-          join('/', map $taxhash{$_}, qw(state county country taxclass) );
-      }
+      # maybe eliminate this entirely, along with all the 0% records
+      unless ( @taxes ) {
+        return
+          "fatal: can't find tax rate for state/county/country/taxclass ".
+          join('/', map $taxhash{$_}, qw(state county country taxclass) );
+      }
 
     } #if $conf->exists('enable_taxproducts') ...
 
   }
  
   my @display = ();
-  if ( $conf->exists('separate_usage') ) {
+  if ( $conf->exists('separate_usage') || $cust_bill_pkg->hidden ) {
+
+    my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
+    my %hash = $cust_bill_pkg->hidden  # maybe for all bill linked?
+               ? (  'section' => $temp_pkg->part_pkg->categoryname )
+               : ();
+
     my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!');
     my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
-    push @display, new FS::cust_bill_pkg_display { type    => 'S' };
-    push @display, new FS::cust_bill_pkg_display { type    => 'R' };
-    push @display, new FS::cust_bill_pkg_display { type    => 'U',
-                                                   section => $section
-                                                 };
+    push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+    push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
+
     if ($section && $summary) {
-      $display[2]->post_total('Y');
       push @display, new FS::cust_bill_pkg_display { type    => 'U',
                                                      summary => 'Y',
-                                                   }
+                                                     %hash,
+                                                   };
+      $hash{post_total} = 'Y';
     }
+
+    $hash{section} = $section if $conf->exists('separate_usage');
+    push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
+
   }
   $cust_bill_pkg->set('display', \@display);
 
@@ -2937,19 +3178,51 @@ sub _handle_taxes {
     my @taxes = @{ $taxes{$key} || [] };
     my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
 
+    my %localtaxlisthash = ();
     foreach my $tax ( @taxes ) {
 
-      my $taxname = ref( $tax ). ' taxnum'. $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;
 
-      if ( exists( $taxlisthash->{ $taxname } ) ) {
-        push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
-      }else{
-        $taxlisthash->{ $taxname } = [ $tax, $tax_cust_bill_pkg ];
+      $taxlisthash->{ $taxname } ||= [ $tax ];
+      push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
+
+      $localtaxlisthash{ $taxname } ||= [ $tax ];
+      push @{ $localtaxlisthash{ $taxname  } }, $tax_cust_bill_pkg;
+
+    }
+
+    warn "finding taxed taxes...\n" if $DEBUG > 2;
+    foreach my $tax ( keys %localtaxlisthash ) {
+      my $tax_object = shift @{ $localtaxlisthash{$tax} };
+      warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
+        if $DEBUG > 2;
+      next unless $tax_object->can('tax_on_tax');
+
+      foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
+        my $totname = ref( $tot ). ' '. $tot->taxnum;
+
+        warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
+          if $DEBUG > 2;
+        next unless exists( $localtaxlisthash{ $totname } ); # only increase
+                                                             # existing taxes
+        warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
+        my $hashref_or_error = 
+          $tax_object->taxline( $localtaxlisthash{$tax},
+                                'custnum'      => $self->custnum,
+                                'invoice_time' => $invoice_time,
+                              );
+        return $hashref_or_error
+          unless ref($hashref_or_error);
+        
+        $taxlisthash->{ $totname } ||= [ $tot ];
+        push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount};
+
       }
     }
+
   }
 
   '';
@@ -2968,6 +3241,7 @@ sub _gather_taxes {
 
   unless (@taxclassnums) {
     @taxclassnums = map { $_->taxclassnum }
+                    grep { $_->taxable eq 'Y' }
                     $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
   }
   warn "Found taxclassnum values of ". join(',', @taxclassnums)
@@ -2991,7 +3265,7 @@ sub _gather_taxes {
 
 }
 
-=item collect OPTIONS
+=item collect [ HASHREF | OPTION => VALUE ... ]
 
 (Attempt to) collect money for this customer's outstanding invoices (see
 L<FS::cust_bill>).  Usually used after the bill method.
@@ -3016,25 +3290,24 @@ Use this time when deciding when to print invoices and late notices on those inv
 
 Retry card/echeck/LEC transactions even when not scheduled by invoice events.
 
-=item quiet
-
-set true to surpress email card/ACH decline notices.
-
 =item check_freq
 
 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
 
-=item payby
+=item quiet
 
-allows for one time override of normal customer billing method
+set true to surpress email card/ACH decline notices.
 
 =item debug
 
 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
 
+# =item payby
+#
+# allows for one time override of normal customer billing method
+
 =cut
 
 sub collect {
@@ -3072,12 +3345,107 @@ sub collect {
     }
   }
 
+  my $error = $self->do_cust_event(
+    'debug'      => ( $options{'debug'} || 0 ),
+    'time'       => $invoice_time,
+    'check_freq' => $options{'check_freq'},
+    'stage'      => 'collect',
+  );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+=item do_cust_event [ HASHREF | OPTION => VALUE ... ]
+
+Runs billing events; see L<FS::part_event> and the billing events web
+interface.
+
+If there is an error, returns the error, otherwise returns false.
+
+Options are passed as name-value pairs.
+
+Currently available options are:
+
+=over 4
+
+=item time
+
+Use this time when deciding when to print invoices and late notices on those invoices.  The default is now.  It is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=item check_freq
+
+"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
+
+=item stage
+
+"collect" (the default) or "pre-bill"
+
+=item quiet
+set true to surpress email card/ACH decline notices.
+
+=item debug
+
+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)
+
+=cut
+
+# =item payby
+#
+# allows for one time override of normal customer billing method
+
+# =item retry
+#
+# Retry card/echeck/LEC transactions even when not scheduled by invoice events.
+
+sub do_cust_event {
+  my( $self, %options ) = @_;
+  my $time = $options{'time'} || time;
+
+  #put below somehow?
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  $self->select_for_update; #mutex
+
+  if ( $DEBUG ) {
+    my $balance = $self->balance;
+    warn "$me do_cust_event customer ". $self->custnum. ": balance $balance\n"
+  }
+
+#  if ( exists($options{'retry_card'}) ) {
+#    carp 'retry_card option passed to collect is deprecated; use retry';
+#    $options{'retry'} ||= $options{'retry_card'};
+#  }
+#  if ( exists($options{'retry'}) && $options{'retry'} ) {
+#    my $error = $self->retry_realtime;
+#    if ( $error ) {
+#      $dbh->rollback if $oldAutoCommit;
+#      return $error;
+#    }
+#  }
+
   # false laziness w/pay_batch::import_results
 
   my $due_cust_event = $self->due_cust_event(
     'debug'      => ( $options{'debug'} || 0 ),
-    'time'       => $invoice_time,
+    'time'       => $time,
     'check_freq' => $options{'check_freq'},
+    'stage'      => ( $options{'stage'} || 'collect' ),
   );
   unless( ref($due_cust_event) ) {
     $dbh->rollback if $oldAutoCommit;
@@ -3089,7 +3457,7 @@ sub collect {
     #XXX lock event
     
     #re-eval event conditions (a previous event could have changed things)
-    unless ( $cust_event->test_conditions( 'time' => $invoice_time ) ) {
+    unless ( $cust_event->test_conditions( 'time' => $time ) ) {
       #don't leave stray "new/locked" records around
       my $error = $cust_event->delete;
       if ( $error ) {
@@ -3142,6 +3510,10 @@ options are:
 
 Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized.
 
+=item stage
+
+"collect" (the default) or "pre-bill"
+
 =item time
 
 "Current time" for the events.
@@ -3197,7 +3569,7 @@ sub due_cust_event {
     unless $opt{testonly};
 
   ###
-  # 1: find possible events (initial search)
+  # find possible events (initial search)
   ###
   
   my @cust_event = ();
@@ -3288,8 +3660,20 @@ sub due_cust_event {
        " total possible cust events found in initial search\n"
     if $DEBUG; # > 1;
 
+
+  ##
+  # test stage
+  ##
+
+  $opt{stage} ||= 'collect';
+  @cust_event =
+    grep { my $stage = $_->part_event->event_stage;
+           $opt{stage} eq $stage or ( ! $stage && $opt{stage} eq 'collect' )
+         }
+         @cust_event;
+
   ##
-  # 2: test conditions
+  # test conditions
   ##
   
   my %unsat = ();
@@ -3306,7 +3690,7 @@ sub due_cust_event {
     if $DEBUG; # > 1;
 
   ##
-  # 3: insert
+  # insert
   ##
 
   unless( $opt{testonly} ) {
@@ -3324,7 +3708,7 @@ sub due_cust_event {
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   ##
-  # 4: return
+  # return
   ##
 
   warn "  returning events: ". Dumper(@cust_event). "\n"
@@ -3500,24 +3884,35 @@ sub realtime_bop {
   return "Banned credit card" if $ban;
 
   ###
-  # select a gateway
+  # set taxclass and trans_is_recur based on invnum if there is one
   ###
 
   my $taxclass = '';
+  my $trans_is_recur = 0;
   if ( $options{'invnum'} ) {
+
     my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
     die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
-    my @taxclasses =
-      map  { $_->part_pkg->taxclass }
+
+    my @part_pkg =
+      map  { $_->part_pkg }
       grep { $_ }
       map  { $_->cust_pkg }
       $cust_bill->cust_bill_pkg;
-    unless ( grep { $taxclasses[0] ne $_ } @taxclasses ) { #unless there are
-                                                           #different taxclasses
-      $taxclass = $taxclasses[0];
-    }
+
+    my @taxclasses = map $_->taxclass, @part_pkg;
+    $taxclass = $taxclasses[0]
+      unless grep { $taxclasses[0] ne $_ } @taxclasses; #unless there are
+                                                        #different taxclasses
+    $trans_is_recur = 1
+      if grep { $_->freq ne '0' } @part_pkg;
+
   }
 
+  ###
+  # select a gateway
+  ###
+
   #look for an agent gateway override first
   my $cardtype;
   if ( $method eq 'CC' ) {
@@ -3645,16 +4040,15 @@ sub realtime_bop {
                            : $self->payissue;
     $content{issue_number} = $payissue if $payissue;
 
-    $content{recurring_billing} = 'YES'
-      if qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'payinfo' => $payinfo,
-                             } )
-      || qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'paymask' => $self->mask_payinfo('CARD', $payinfo),
-                             } );
-
+    if ( $self->_bop_recurring_billing( 'payinfo'        => $payinfo,
+                                        'trans_is_recur' => $trans_is_recur,
+                                      )
+       )
+    {
+      $content{recurring_billing} = 'YES';
+      $content{acct_code} = 'rebill'
+        if $conf->exists('credit_card-recurring_billing_acct_code');
+    }
 
   } elsif ( $method eq 'ECHECK' ) {
     ( $content{account_number}, $content{routing_code} ) =
@@ -3713,15 +4107,17 @@ sub realtime_bop {
   #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
 
   my $cust_pay_pending = new FS::cust_pay_pending {
-    'custnum'    => $self->custnum,
-    #'invnum'     => $options{'invnum'},
-    'paid'       => $amount,
-    '_date'      => '',
-    'payby'      => $method2payby{$method},
-    'payinfo'    => $payinfo,
-    'paydate'    => $paydate,
-    'status'     => 'new',
-    'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
+    'custnum'           => $self->custnum,
+    #'invnum'            => $options{'invnum'},
+    'paid'              => $amount,
+    '_date'             => '',
+    'payby'             => $method2payby{$method},
+    'payinfo'           => $payinfo,
+    'paydate'           => $paydate,
+    'recurring_billing' => $content{recurring_billing},
+    'pkgnum'            => $options{'pkgnum'},
+    'status'            => 'new',
+    'gatewaynum'        => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
@@ -3875,6 +4271,7 @@ sub realtime_bop {
        'payinfo'  => $payinfo,
        'paybatch' => $paybatch,
        'paydate'  => $paydate,
+       'pkgnum'   => $options{'pkgnum'},
     } );
     #doesn't hurt to know, even though the dup check is in cust_pay_pending now
     $cust_pay->payunique( $options{payunique} )
@@ -3977,7 +4374,13 @@ sub realtime_bop {
       $template->compile()
         or return "($perror) can't compile template: $Text::Template::ERROR";
 
-      my $templ_hash = { error => $transaction->error_message };
+      my $templ_hash = {
+        'company_name'    =>
+          scalar( $conf->config('company_name', $self->agentnum ) ),
+        'company_address' =>
+          join("\n", $conf->config('company_address', $self->agentnum ) ),
+        'error'           => $transaction->error_message,
+      };
 
       my $error = send_email(
         'from'    => $conf->config('invoice_from', $self->agentnum ),
@@ -4007,6 +4410,34 @@ sub realtime_bop {
 
 }
 
+sub _bop_recurring_billing {
+  my( $self, %opt ) = @_;
+
+  my $method = $conf->config('credit_card-recurring_billing_flag');
+
+  if ( $method eq 'transaction_is_recur' ) {
+
+    return 1 if $opt{'trans_is_recur'};
+
+  } else {
+
+    my %hash = ( 'custnum' => $self->custnum,
+                 'payby'   => 'CARD',
+               );
+
+    return 1 
+      if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } )
+      || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD',
+                                                               $opt{'payinfo'} )
+                             } );
+
+  }
+
+  return 0;
+
+}
+
+
 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
 
 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
@@ -4360,7 +4791,7 @@ On failure returns an error message.
 
 Returns false or a hashref upon success.  The hashref contains keys popup_url reference, and collectitems.  The first is a URL to which a browser should be redirected for completion of collection.  The second is a reference id for the transaction suitable for the end user.  The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url.
 
-Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
+Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>, I<pkgnum>
 
 I<method> is one of: I<CC>, I<ECHECK> and I<LEC>.  If none is specified
 then it is deduced from the customer record.
@@ -4559,6 +4990,27 @@ sub _new_realtime_bop {
   $self->_bop_defaults(\%options);
 
   ###
+  # set trans_is_recur based on invnum if there is one
+  ###
+
+  my $trans_is_recur = 0;
+  if ( $options{'invnum'} ) {
+
+    my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
+    die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
+
+    my @part_pkg =
+      map  { $_->part_pkg }
+      grep { $_ }
+      map  { $_->cust_pkg }
+      $cust_bill->cust_bill_pkg;
+
+    $trans_is_recur = 1
+      if grep { $_->freq ne '0' } @part_pkg;
+
+  }
+
+  ###
   # select a gateway
   ###
 
@@ -4634,16 +5086,15 @@ sub _new_realtime_bop {
                            : $self->payissue;
     $content{issue_number} = $payissue if $payissue;
 
-    $content{recurring_billing} = 'YES'
-      if qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'payinfo' => $options{payinfo},
-                             } )
-      || qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'paymask' => $self->mask_payinfo('CARD', $options{payinfo}),
-                             } );
-
+    if ( $self->_bop_recurring_billing( 'payinfo'        => $options{'payinfo'},
+                                        'trans_is_recur' => $trans_is_recur,
+                                      )
+       )
+    {
+      $content{recurring_billing} = 'YES';
+      $content{acct_code} = 'rebill'
+        if $conf->exists('credit_card-recurring_billing_acct_code');
+    }
 
   } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){
     ( $content{account_number}, $content{routing_code} ) =
@@ -4705,17 +5156,19 @@ sub _new_realtime_bop {
   #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
 
   my $cust_pay_pending = new FS::cust_pay_pending {
-    'custnum'    => $self->custnum,
-    #'invnum'     => $options{'invnum'},
-    'paid'       => $options{amount},
-    '_date'      => '',
-    'payby'      => $bop_method2payby{$options{method}},
-    'payinfo'    => $options{payinfo},
-    'paydate'    => $paydate,
-    'status'     => 'new',
-    'gatewaynum' => $payment_gateway->gatewaynum || '',
-    'session_id' => $options{session_id} || '',
-    'jobnum'     => $options{depend_jobnum} || '',
+    'custnum'           => $self->custnum,
+    #'invnum'            => $options{'invnum'},
+    'paid'              => $options{amount},
+    '_date'             => '',
+    'payby'             => $bop_method2payby{$options{method}},
+    'payinfo'           => $options{payinfo},
+    'paydate'           => $paydate,
+    'recurring_billing' => $content{recurring_billing},
+    'pkgnum'            => $options{'pkgnum'},
+    'status'            => 'new',
+    'gatewaynum'        => $payment_gateway->gatewaynum || '',
+    'session_id'        => $options{session_id} || '',
+    'jobnum'            => $options{depend_jobnum} || '',
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
@@ -4958,6 +5411,7 @@ sub _realtime_bop_result {
        #'payinfo'  => $payinfo,
        'paybatch' => $paybatch,
        'paydate'  => $cust_pay_pending->paydate,
+       'pkgnum'   => $cust_pay_pending->pkgnum,
     } );
     #doesn't hurt to know, even though the dup check is in cust_pay_pending now
     $cust_pay->payunique( $options{payunique} )
@@ -5106,7 +5560,13 @@ sub _realtime_bop_result {
       $template->compile()
         or return "($perror) can't compile template: $Text::Template::ERROR";
 
-      my $templ_hash = { error => $transaction->error_message };
+      my $templ_hash = {
+        'company_name'    =>
+          scalar( $conf->config('company_name', $self->agentnum ) ),
+        'company_address' =>
+          join("\n", $conf->config('company_address', $self->agentnum ) ),
+        'error'           => $transaction->error_message,
+      };
 
       my $error = send_email(
         'from'    => $conf->config('invoice_from', $self->agentnum ),
@@ -5739,19 +6199,23 @@ sub batch_card {
   '';
 }
 
-=item apply_payments_and_credits
+=item apply_payments_and_credits [ OPTION => VALUE ... ]
 
 Applies unapplied payments and credits.
 
 In most cases, this new method should be used in place of sequential
 apply_payments and apply_credits methods.
 
+A hash of optional arguments may be passed.  Currently "manual" is supported.
+If true, a payment receipt is sent instead of a statement when
+'payment_receipt_email' configuration option is set.
+
 If there is an error, returns the error, otherwise returns false.
 
 =cut
 
 sub apply_payments_and_credits {
-  my $self = shift;
+  my( $self, %options ) = @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -5767,7 +6231,7 @@ sub apply_payments_and_credits {
   $self->select_for_update; #mutex
 
   foreach my $cust_bill ( $self->open_cust_bill ) {
-    my $error = $cust_bill->apply_payments_and_credits;
+    my $error = $cust_bill->apply_payments_and_credits(%options);
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "Error applying: $error";
@@ -5820,32 +6284,52 @@ sub apply_credits {
   @invoices = sort { $b->_date <=> $a->_date } @invoices
     if defined($opt{'order'}) && $opt{'order'} eq 'newest';
 
+  if ( $conf->exists('pkg-balances') ) {
+    # limit @credits to those w/ a pkgnum grepped from $self
+    my %pkgnums = ();
+    foreach my $i (@invoices) {
+      foreach my $li ( $i->cust_bill_pkg ) {
+        $pkgnums{$li->pkgnum} = 1;
+      }
+    }
+    @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
+  }
+
   my $credit;
+
   foreach my $cust_bill ( @invoices ) {
-    my $amount;
 
     if ( !defined($credit) || $credit->credited == 0) {
       $credit = pop @credits or last;
     }
 
-    if ($cust_bill->owed >= $credit->credited) {
-      $amount=$credit->credited;
-    }else{
-      $amount=$cust_bill->owed;
+    my $owed;
+    if ( $conf->exists('pkg-balances') && $credit->pkgnum ) {
+      $owed = $cust_bill->owed_pkgnum($credit->pkgnum);
+    } else {
+      $owed = $cust_bill->owed;
     }
+    unless ( $owed > 0 ) {
+      push @credits, $credit;
+      next;
+    }
+
+    my $amount = min( $credit->credited, $owed );
     
     my $cust_credit_bill = new FS::cust_credit_bill ( {
       'crednum' => $credit->crednum,
       'invnum'  => $cust_bill->invnum,
       'amount'  => $amount,
     } );
+    $cust_credit_bill->pkgnum( $credit->pkgnum )
+      if $conf->exists('pkg-balances') && $credit->pkgnum;
     my $error = $cust_credit_bill->insert;
     if ( $error ) {
       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
       die $error;
     }
     
-    redo if ($cust_bill->owed > 0);
+    redo if ($cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
 
   }
 
@@ -5856,19 +6340,24 @@ sub apply_credits {
   return $total_unapplied_credits;
 }
 
-=item apply_payments
+=item apply_payments  [ OPTION => VALUE ... ]
 
 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
 to outstanding invoice balances in chronological order.
 
  #and returns the value of any remaining unapplied payments.
 
+A hash of optional arguments may be passed.  Currently "manual" is supported.
+If true, a payment receipt is sent instead of a statement when
+'payment_receipt_email' configuration option is set.
+
+
 Dies if there is an error.
 
 =cut
 
 sub apply_payments {
-  my $self = shift;
+  my( $self, %options ) = @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -5893,33 +6382,52 @@ sub apply_payments {
                  grep { $_->owed > 0 }
                  $self->cust_bill;
 
+  if ( $conf->exists('pkg-balances') ) {
+    # limit @payments to those w/ a pkgnum grepped from $self
+    my %pkgnums = ();
+    foreach my $i (@invoices) {
+      foreach my $li ( $i->cust_bill_pkg ) {
+        $pkgnums{$li->pkgnum} = 1;
+      }
+    }
+    @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
+  }
+
   my $payment;
 
   foreach my $cust_bill ( @invoices ) {
-    my $amount;
 
     if ( !defined($payment) || $payment->unapplied == 0 ) {
       $payment = pop @payments or last;
     }
 
-    if ( $cust_bill->owed >= $payment->unapplied ) {
-      $amount = $payment->unapplied;
+    my $owed;
+    if ( $conf->exists('pkg-balances') && $payment->pkgnum ) {
+      $owed = $cust_bill->owed_pkgnum($payment->pkgnum);
     } else {
-      $amount = $cust_bill->owed;
+      $owed = $cust_bill->owed;
+    }
+    unless ( $owed > 0 ) {
+      push @payments, $payment;
+      next;
     }
 
+    my $amount = min( $payment->unapplied, $owed );
+
     my $cust_bill_pay = new FS::cust_bill_pay ( {
       'paynum' => $payment->paynum,
       'invnum' => $cust_bill->invnum,
       'amount' => $amount,
     } );
-    my $error = $cust_bill_pay->insert;
+    $cust_bill_pay->pkgnum( $payment->pkgnum )
+      if $conf->exists('pkg-balances') && $payment->pkgnum;
+    my $error = $cust_bill_pay->insert(%options);
     if ( $error ) {
       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
       die $error;
     }
 
-    redo if ( $cust_bill->owed > 0);
+    redo if ( $cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
 
   }
 
@@ -5953,6 +6461,22 @@ see L<Time::Local> and L<Date::Parse> for conversion functions.
 sub total_owed_date {
   my $self = shift;
   my $time = shift;
+
+#  my $custnum = $self->custnum;
+#
+#  my $owed_sql = FS::cust_bill->owed_sql;
+#
+#  my $sql = "
+#    SELECT SUM($owed_sql) FROM cust_bill
+#      WHERE custnum = $custnum
+#        AND _date <= $time
+#  ";
+#
+#  my $sth = dbh->prepare($sql) or die dbh->errstr;
+#  $sth->execute() or die $sth->errstr;
+#
+#  return sprintf( '%.2f', $sth->fetchrow_arrayref->[0] );
+
   my $total_bill = 0;
   foreach my $cust_bill (
     grep { $_->_date <= $time }
@@ -5961,6 +6485,42 @@ sub total_owed_date {
     $total_bill += $cust_bill->owed;
   }
   sprintf( "%.2f", $total_bill );
+
+}
+
+=item total_owed_pkgnum PKGNUM
+
+Returns the total owed on all invoices for this customer's specific package
+when using experimental package balances (see L<FS::cust_bill/owed_pkgnum>).
+
+=cut
+
+sub total_owed_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  $self->total_owed_date_pkgnum(2145859200, $pkgnum); #12/31/2037
+}
+
+=item total_owed_date_pkgnum TIME PKGNUM
+
+Returns the total owed for this customer's specific package when using
+experimental package balances on all invoices with date earlier than
+TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
+see L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=cut
+
+sub total_owed_date_pkgnum {
+  my( $self, $time, $pkgnum ) = @_;
+
+  my $total_bill = 0;
+  foreach my $cust_bill (
+    grep { $_->_date <= $time }
+      qsearch('cust_bill', { 'custnum' => $self->custnum, } )
+  ) {
+    $total_bill += $cust_bill->owed_pkgnum($pkgnum);
+  }
+  sprintf( "%.2f", $total_bill );
+
 }
 
 =item total_paid
@@ -5999,6 +6559,21 @@ sub total_unapplied_credits {
   sprintf( "%.2f", $total_credit );
 }
 
+=item total_unapplied_credits_pkgnum PKGNUM
+
+Returns the total outstanding credit (see L<FS::cust_credit>) for this
+customer.  See L<FS::cust_credit/credited>.
+
+=cut
+
+sub total_unapplied_credits_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  my $total_credit = 0;
+  $total_credit += $_->credited foreach $self->cust_credit_pkgnum($pkgnum);
+  sprintf( "%.2f", $total_credit );
+}
+
+
 =item total_unapplied_payments
 
 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
@@ -6013,6 +6588,22 @@ sub total_unapplied_payments {
   sprintf( "%.2f", $total_unapplied );
 }
 
+=item total_unapplied_payments_pkgnum PKGNUM
+
+Returns the total unapplied payments (see L<FS::cust_pay>) for this customer's
+specific package when using experimental package balances.  See
+L<FS::cust_pay/unapplied>.
+
+=cut
+
+sub total_unapplied_payments_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  my $total_unapplied = 0;
+  $total_unapplied += $_->unapplied foreach $self->cust_pay_pkgnum($pkgnum);
+  sprintf( "%.2f", $total_unapplied );
+}
+
+
 =item total_unapplied_refunds
 
 Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
@@ -6065,6 +6656,26 @@ sub balance_date {
   );
 }
 
+=item balance_pkgnum PKGNUM
+
+Returns the balance for this customer's specific package when using
+experimental package balances (total_owed plus total_unrefunded, minus
+total_unapplied_credits minus total_unapplied_payments)
+
+=cut
+
+sub balance_pkgnum {
+  my( $self, $pkgnum ) = @_;
+
+  sprintf( "%.2f",
+      $self->total_owed_pkgnum($pkgnum)
+# n/a - refunds aren't part of pkg-balances since they don't apply to invoices
+#    + $self->total_unapplied_refunds_pkgnum($pkgnum)
+    - $self->total_unapplied_credits_pkgnum($pkgnum)
+    - $self->total_unapplied_payments_pkgnum($pkgnum)
+  );
+}
+
 =item in_transit_payments
 
 Returns the total of requests for payments for this customer pending in 
@@ -6186,6 +6797,28 @@ sub paydate_monthyear {
   }
 }
 
+=item tax_exemption TAXNAME
+
+=cut
+
+sub tax_exemption {
+  my( $self, $taxname ) = @_;
+
+  qsearchs( 'cust_main_exemption', { 'custnum' => $self->custnum,
+                                     'taxname' => $taxname,
+                                   },
+          );
+}
+
+=item cust_main_exemption
+
+=cut
+
+sub cust_main_exemption {
+  my $self = shift;
+  qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } );
+}
+
 =item invoicing_list [ ARRAYREF ]
 
 If an arguement is given, sets these email addresses as invoice recipients
@@ -6358,6 +6991,24 @@ sub invoicing_list_emailonly_scalar {
   join(', ', $self->invoicing_list_emailonly);
 }
 
+=item referral_custnum_cust_main
+
+Returns the customer who referred this customer (or the empty string, if
+this customer was not referred).
+
+Note the difference with referral_cust_main method: This method,
+referral_custnum_cust_main returns the single customer (if any) who referred
+this customer, while referral_cust_main returns an array of customers referred
+BY this customer.
+
+=cut
+
+sub referral_custnum_cust_main {
+  my $self = shift;
+  return '' unless $self->referral_custnum;
+  qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
+}
+
 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
 
 Returns an array of customers referred by this customer (referral_custnum set
@@ -6365,6 +7016,11 @@ to this custnum).  If DEPTH is given, recurses up to the given depth, returning
 customers referred by customers referred by this customer and so on, inclusive.
 The default behavior is DEPTH 1 (no recursion).
 
+Note the difference with referral_custnum_cust_main method: This method,
+referral_cust_main, returns an array of customers referred BY this customer,
+while referral_custnum_cust_main returns the single customer (if any) who
+referred this customer.
+
 =cut
 
 sub referral_cust_main {
@@ -6471,33 +7127,63 @@ sub credit {
 
 }
 
-=item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
+=item charge HASHREF || AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
 
 Creates a one-time charge for this customer.  If there is an error, returns
 the error, otherwise returns false.
 
+New-style, with a hashref of options:
+
+  my $error = $cust_main->charge(
+                                  {
+                                    'amount'     => 54.32,
+                                    'quantity'   => 1,
+                                    'start_date' => str2time('7/4/2009'),
+                                    'pkg'        => 'Description',
+                                    'comment'    => 'Comment',
+                                    'additional' => [], #extra invoice detail
+                                    'classnum'   => 1,  #pkg_class
+
+                                    'setuptax'   => '', # or 'Y' for tax exempt
+
+                                    #internal taxation
+                                    'taxclass'   => 'Tax class',
+
+                                    #vendor taxation
+                                    'taxproduct' => 2,  #part_pkg_taxproduct
+                                    'override'   => {}, #XXX describe
+                                  }
+                                );
+
+Old-style:
+
+  my $error = $cust_main->charge( 54.32, 'Description', 'Comment', 'Tax class' );
+
 =cut
 
 sub charge {
   my $self = shift;
-  my ( $amount, $quantity, $pkg, $comment, $classnum, $additional );
+  my ( $amount, $quantity, $start_date, $classnum );
+  my ( $pkg, $comment, $additional );
   my ( $setuptax, $taxclass );   #internal taxes
   my ( $taxproduct, $override ); #vendor (CCH) taxes
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
+    $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
                                            : '$'. sprintf("%.2f",$amount);
     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
-    $additional = $_[0]->{additional};
+    $additional = $_[0]->{additional} || [];
     $taxproduct = $_[0]->{taxproductnum};
     $override   = { '' => $_[0]->{tax_override} };
-  }else{
+  } else {
     $amount     = shift;
     $quantity   = 1;
+    $start_date = '';
     $pkg        = @_ ? shift : 'One-time charge';
     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
     $setuptax   = '';
@@ -6555,9 +7241,10 @@ sub charge {
   }
 
   my $cust_pkg = new FS::cust_pkg ( {
-    'custnum'  => $self->custnum,
-    'pkgpart'  => $pkgpart,
-    'quantity' => $quantity,
+    'custnum'    => $self->custnum,
+    'pkgpart'    => $pkgpart,
+    'quantity'   => $quantity,
+    'start_date' => $start_date,
   } );
 
   $error = $cust_pkg->insert;
@@ -6619,7 +7306,26 @@ customer.
 
 sub open_cust_bill {
   my $self = shift;
-  grep { $_->owed > 0 } $self->cust_bill;
+
+  qsearch({
+    'table'     => 'cust_bill',
+    'hashref'   => { 'custnum' => $self->custnum, },
+    'extra_sql' => ' AND '. FS::cust_bill->owed_sql. ' > 0',
+    'order_by'  => 'ORDER BY _date ASC',
+  });
+
+}
+
+=item cust_statements
+
+Returns all the statements (see L<FS::cust_statement>) for this customer.
+
+=cut
+
+sub cust_statement {
+  my $self = shift;
+  sort { $a->_date <=> $b->_date }
+    qsearch('cust_statement', { 'custnum' => $self->custnum, } )
 }
 
 =item cust_credit
@@ -6634,6 +7340,22 @@ sub cust_credit {
     qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
 }
 
+=item cust_credit_pkgnum
+
+Returns all the credits (see L<FS::cust_credit>) for this customer's specific
+package when using experimental package balances.
+
+=cut
+
+sub cust_credit_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_credit', { 'custnum' => $self->custnum,
+                              'pkgnum'  => $pkgnum,
+                            }
+    );
+}
+
 =item cust_pay
 
 Returns all the payments (see L<FS::cust_pay>) for this customer.
@@ -6646,6 +7368,22 @@ sub cust_pay {
     qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
 }
 
+=item cust_pay_pkgnum
+
+Returns all the payments (see L<FS::cust_pay>) for this customer's specific
+package when using experimental package balances.
+
+=cut
+
+sub cust_pay_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_pay', { 'custnum' => $self->custnum,
+                           'pkgnum'  => $pkgnum,
+                         }
+    );
+}
+
 =item cust_pay_void
 
 Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
@@ -6666,7 +7404,7 @@ Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
 
 sub cust_pay_batch {
   my $self = shift;
-  sort { $a->_date <=> $b->_date }
+  sort { $a->paybatchnum <=> $b->paybatchnum }
     qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
 }
 
@@ -7011,6 +7749,19 @@ sub support_services {
 
 }
 
+# Return a list of latitude/longitude for one of the services (if any)
+sub service_coordinates {
+  my $self = shift;
+
+  my @svc_X = 
+    grep { $_->latitude && $_->longitude }
+    map { $_->svc_x }
+    map { $_->cust_svc }
+    $self->ncancelled_pkgs;
+
+  scalar(@svc_X) ? ( $svc_X[0]->latitude, $svc_X[0]->longitude ) : ()
+}
+
 =back
 
 =head1 CLASS METHODS
@@ -7212,6 +7963,32 @@ sub balance_date_sql {
 
 }
 
+=item unapplied_payments_date_sql START_TIME [ END_TIME ]
+
+Returns an SQL fragment to retreive the total unapplied payments for this
+customer, only considering invoices with date earlier than START_TIME, and
+optionally not later than END_TIME.
+
+Times are specified as SQL fragments or numeric
+UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
+L<Date::Parse> for conversion functions.  The empty string can be passed
+to disable that time constraint completely.
+
+Available options are:
+
+=cut
+
+sub unapplied_payments_date_sql {
+  my( $class, $start, $end, ) = @_;
+
+  my $unapp_pay    = FS::cust_pay->unapplied_sql;
+
+  my $pay_where = $class->_money_table_where( 'cust_pay', $start, $end,
+                                                          'unapplied_date'=>1 );
+
+  " ( SELECT COALESCE(SUM($unapp_pay), 0) FROM cust_pay $pay_where ) ";
+}
+
 =item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
 
 Helper method for balance_date_sql; name (and usage) subject to change
@@ -7319,6 +8096,13 @@ sub search_sql {
     unless $params->{'cancelled_pkgs'};
 
   ##
+  # parse without census tract checkbox
+  ##
+
+  push @where, "(censustract = '' or censustract is null)"
+    if $params->{'no_censustract'};
+
+  ##
   # dates
   ##
 
@@ -7482,6 +8266,9 @@ sub email_search_sql {
 
   my $job = delete $params->{'job'};
 
+  $params->{'payby'} = [ split(/\0/, $params->{'payby'}) ]
+    unless ref($params->{'payby'});
+
   my $sql_query = $class->search_sql($params);
 
   my $count_query   = delete($sql_query->{'count_query'});
@@ -7543,6 +8330,9 @@ sub process_email_search_sql {
 
   $param->{'job'} = $job;
 
+  $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
+    unless ref($param->{'payby'});
+
   my $error = FS::cust_main->email_search_sql( $param );
   die $error if $error;
 
@@ -7856,12 +8646,12 @@ sub smart_search {
 
     }
 
-    #eliminate duplicates
-    my %saw = ();
-    @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
-
   }
 
+  #eliminate duplicates
+  my %saw = ();
+  @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+
   @cust_main;
 
 }
@@ -8429,6 +9219,15 @@ sub queued_bill {
       );
 }
 
+sub _upgrade_data { #class method
+  my ($class, %opts) = @_;
+
+  my $sql = 'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL';
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute or die $sth->errstr;
+
+}
+
 =back
 
 =head1 BUGS