commit pkgpart exclusion for billing run, RT#5495
[freeside.git] / FS / FS / cust_main.pm
index e7cdd21..2957579 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;
@@ -41,6 +42,7 @@ 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;
@@ -1551,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.
@@ -2055,6 +2058,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
@@ -2186,12 +2201,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 ) = @_;
 
@@ -2217,6 +2236,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;
@@ -2305,6 +2331,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 {
@@ -2422,10 +2451,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
@@ -2439,6 +2479,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';
@@ -2464,7 +2506,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;
 
@@ -2522,6 +2567,8 @@ sub bill {
     } elsif ( $postal_pkg ) {
 
       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,
@@ -2531,7 +2578,7 @@ sub bill {
                               'recur'               => \$total_recur,
                               'tax_matrix'          => \%taxlisthash,
                               'time'                => $time,
-                              'options'             => \%options,
+                              'options'             => \%postal_options,
                             );
         if ($error) {
           $dbh->rollback if $oldAutoCommit;
@@ -2661,6 +2708,35 @@ sub bill {
 
   }
 
+  #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 );
 
   #create the new invoice
@@ -2730,14 +2806,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++;
@@ -2753,6 +2834,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;
+
   }
 
   ###
@@ -2770,6 +2854,7 @@ sub _make_lines {
         || ( $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
@@ -2783,18 +2868,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 ) {
@@ -2874,9 +2963,11 @@ sub _make_lines {
       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)
@@ -6688,33 +6779,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   = '';
@@ -6772,9 +6893,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;
@@ -7436,6 +7558,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
@@ -7543,6 +7691,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
   ##
 
@@ -8080,12 +8235,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;
 
 }