don't start recurring billing when a start date hasn't been reached yet either.....
[freeside.git] / FS / FS / cust_main.pm
index 3f23346..8cd03eb 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;
@@ -1552,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.
@@ -1569,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 {
@@ -1860,7 +1869,7 @@ sub check {
     $self->payname($1);
   }
 
-  foreach my $flag (qw( tax spool_cdr squelch_cdr archived )) {
+  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);
   }
@@ -2056,6 +2065,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
@@ -2187,12 +2208,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 ) = @_;
 
@@ -2218,6 +2243,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;
@@ -2306,6 +2338,9 @@ 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 {
@@ -2423,10 +2458,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
@@ -2440,6 +2486,8 @@ sub bill {
   my $time = $options{'time'} || time;
   my $invoice_time = $options{'invoice_time'} || $time;
 
+  $options{'not_pkgpart'} ||= {};
+
   #put below somehow?
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -2465,7 +2513,10 @@ sub bill {
   my %taxlisthash;
   my @precommit_hooks = ();
 
-  foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
+  $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ];  #param checks?
+  foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
+
+    next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
 
     warn "  bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
 
@@ -2491,6 +2542,7 @@ sub bill {
                             'recur'               => \$total_recur,
                             'tax_matrix'          => \%taxlisthash,
                             'time'                => $time,
+                            'real_pkgpart'        => $real_pkgpart,
                             'options'             => \%options,
                           );
       if ($error) {
@@ -2522,7 +2574,10 @@ sub bill {
 
     } 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,
@@ -2532,7 +2587,8 @@ sub bill {
                               'recur'               => \$total_recur,
                               'tax_matrix'          => \%taxlisthash,
                               'time'                => $time,
-                              'options'             => \%options,
+                              'real_pkgpart'        => $real_pkgpart,
+                              'options'             => \%postal_options,
                             );
         if ($error) {
           $dbh->rollback if $oldAutoCommit;
@@ -2744,7 +2800,7 @@ sub _make_lines {
   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;
 
@@ -2760,14 +2816,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++;
@@ -2783,6 +2844,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;
+
   }
 
   ###
@@ -2793,13 +2857,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
@@ -2813,18 +2879,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 ) {
@@ -2899,14 +2969,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)
@@ -2920,7 +2993,7 @@ sub _make_lines {
       ###
 
       my $error = 
-        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time});
+        $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;
@@ -2940,6 +3013,7 @@ sub _handle_taxes {
   my $cust_bill_pkg = shift;
   my $cust_pkg = shift;
   my $invoice_time = shift;
+  my $real_pkgpart = shift;
 
   my %cust_bill_pkg = ();
   my %taxes = ();
@@ -3030,20 +3104,29 @@ sub _handle_taxes {
   }
  
   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);
 
@@ -3879,6 +3962,7 @@ sub realtime_bop {
     'payinfo'           => $payinfo,
     'paydate'           => $paydate,
     'recurring_billing' => $content{recurring_billing},
+    'pkgnum'            => $options{'pkgnum'},
     'status'            => 'new',
     'gatewaynum'        => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
   };
@@ -4034,6 +4118,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} )
@@ -4547,7 +4632,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.
@@ -4920,6 +5005,7 @@ sub _new_realtime_bop {
     'payinfo'           => $options{payinfo},
     'paydate'           => $paydate,
     'recurring_billing' => $content{recurring_billing},
+    'pkgnum'            => $options{'pkgnum'},
     'status'            => 'new',
     'gatewaynum'        => $payment_gateway->gatewaynum || '',
     'session_id'        => $options{session_id} || '',
@@ -5166,6 +5252,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} )
@@ -6028,32 +6115,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');
 
   }
 
@@ -6101,33 +6208,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,
     } );
+    $cust_bill_pay->pkgnum( $payment->pkgnum )
+      if $conf->exists('pkg-balances') && $payment->pkgnum;
     my $error = $cust_bill_pay->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');
 
   }
 
@@ -6188,6 +6314,41 @@ sub total_owed_date {
 
 }
 
+=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
 
 Returns the total amount of all payments.
@@ -6224,6 +6385,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.
@@ -6238,6 +6414,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
@@ -6290,6 +6482,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 
@@ -6718,33 +6930,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   = '';
@@ -6802,9 +7044,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;
@@ -6888,6 +7131,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.
@@ -6900,6 +7159,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.
@@ -7265,6 +7540,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
@@ -7466,6 +7754,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
@@ -7573,6 +7887,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
   ##