correct invoice package address display and reduce false laziness
[freeside.git] / FS / FS / cust_main.pm
index 08c7a51..17dda9c 100644 (file)
@@ -1955,6 +1955,61 @@ sub cust_location {
   qsearch('cust_location', { 'custnum' => $self->custnum } );
 }
 
+=item location_label [ OPTION => VALUE ... ]
+
+Returns the label of the service location (see analog in L<FS::cust_location>) for this customer.
+
+Options are
+
+=over 4
+
+=item join_string
+
+used to separate the address elements (defaults to ', ')
+
+=item escape_function
+
+a callback used for escaping the text of the address elements
+
+=back
+
+=cut
+
+# false laziness with FS::cust_location::line
+
+sub location_label {
+  my $self = shift;
+  my %opt = @_;
+
+  my $separator = $opt{join_string} || ', ';
+  my $escape = $opt{escape_function} || sub{ shift };
+  my $line = '';
+  my $cydefault = FS::conf->new->config('countrydefault') || 'US';
+  my $prefix = length($self->ship_last) ? 'ship_' : '';
+
+  my $notfirst = 0;
+  foreach (qw ( address1 address2 ) ) {
+    my $method = "$prefix$_";
+    $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
+      if $self->$method;
+    $notfirst++;
+  }
+  $notfirst = 0;
+  foreach (qw ( city county state zip ) ) {
+    my $method = "$prefix$_";
+    if ( $self->$method ) {
+      $line .= '(' if $method eq 'county';
+      $line .= ($notfirst ? ' ' : $separator). &$escape($self->$method);
+      $line .= ')' if $method eq 'county';
+      $notfirst++;
+    }
+  }
+  $line .= $separator. &$escape(code2country($self->country))
+    if $self->country ne $cydefault;
+
+  $line;
+}
+
 =item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
@@ -2016,6 +2071,9 @@ sub _cust_pkg {
 # This should be generalized to use config options to determine order.
 sub sort_packages {
   
+  my $locationsort = $a->locationnum <=> $b->locationnum;
+  return $locationsort if $locationsort;
+
   if ( $a->get('cancel') xor $b->get('cancel') ) {
     return -1 if $b->get('cancel');
     return  1 if $a->get('cancel');
@@ -2357,9 +2415,11 @@ sub classname {
 =item bill_and_collect 
 
 Cancels and suspends any packages due, generates bills, applies payments and
-cred
+credits, and applies collection events to run cards, send bills and notices,
+etc.
 
-Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
+By default, warns on errors and continues with the next operation (but see the
+"fatal" flag below).
 
 Options are passed as name-value pairs.  Currently available options are:
 
@@ -2385,6 +2445,12 @@ Used in conjunction with the I<time> option, this option specifies the date of f
 
 If set true, re-charges setup fees.
 
+=item fatal
+
+If set any errors prevent subsequent operations from continusing.  If set
+specifically to "return", returns the error (or false, if there is no error).
+Any other true value causes errors to die.
+
 =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)
@@ -2399,34 +2465,70 @@ options of those methods are also available.
 sub bill_and_collect {
   my( $self, %options ) = @_;
 
+  my $error;
+
   #$options{actual_time} not $options{time} because freeside-daily -d is for
   #pre-printing invoices
-  $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;
+  $options{'actual_time'} ||= time;
 
-  $self->apply_payments_and_credits;
+  $error = $self->cancel_expired_pkgs( $options{actual_time} );
+  if ( $error ) {
+    $error = "Error expiring custnum ". $self->custnum. ": $error";
+    if    ( $options{'fatal'} eq 'return' ) { return $error; }
+    elsif ( $options{'fatal'}             ) { die    $error; }
+    else                                    { warn   $error; }
+  }
+
+  $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
+  if ( $error ) {
+    $error = "Error adjourning custnum ". $self->custnum. ": $error";
+    if    ( $options{'fatal'} eq 'return' ) { return $error; }
+    elsif ( $options{'fatal'}             ) { die    $error; }
+    else                                    { warn   $error; }
+  }
+
+  $error = $self->bill( %options );
+  if ( $error ) {
+    $error = "Error billing custnum ". $self->custnum. ": $error";
+    if    ( $options{'fatal'} eq 'return' ) { return $error; }
+    elsif ( $options{'fatal'}             ) { die    $error; }
+    else                                    { warn   $error; }
+  }
+
+  $error = $self->apply_payments_and_credits;
+  if ( $error ) {
+    $error = "Error applying custnum ". $self->custnum. ": $error";
+    if    ( $options{'fatal'} eq 'return' ) { return $error; }
+    elsif ( $options{'fatal'}             ) { die    $error; }
+    else                                    { warn   $error; }
+  }
 
   unless ( $conf->exists('cancelled_cust-noevents')
            && ! $self->num_ncancelled_pkgs
   ) {
-
     $error = $self->collect( %options );
-    warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
-
+    if ( $error ) {
+      $error = "Error collecting custnum ". $self->custnum. ": $error";
+      if    ( $options{'fatal'} eq 'return' ) { return $error; }
+      elsif ( $options{'fatal'}             ) { die    $error; }
+      else                                    { warn   $error; }
+    }
   }
 
+  '';
+
 }
 
 sub cancel_expired_pkgs {
-  my ( $self, $time ) = @_;
+  my ( $self, $time, %options ) = @_;
 
   my @cancel_pkgs = $self->ncancelled_pkgs( { 
     'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
   } );
 
+  my @errors = ();
+
   foreach my $cust_pkg ( @cancel_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
     my $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
@@ -2434,15 +2536,15 @@ sub cancel_expired_pkgs {
                                          )
                                        : ()
                                  );
-    warn "Error cancelling expired pkg ". $cust_pkg->pkgnum.
-         " for custnum ". $self->custnum. ": $error"
-      if $error;
+    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
+  scalar(@errors) ? join(' / ', @errors) : '';
+
 }
 
 sub suspend_adjourned_pkgs {
-  my ( $self, $time ) = @_;
+  my ( $self, $time, %options ) = @_;
 
   my @susp_pkgs = $self->ncancelled_pkgs( {
     'extra_sql' =>
@@ -2466,6 +2568,8 @@ sub suspend_adjourned_pkgs {
          }
          @susp_pkgs;
 
+  my @errors = ();
+
   foreach my $cust_pkg ( @susp_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
       if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
@@ -2474,12 +2578,11 @@ sub suspend_adjourned_pkgs {
                                           )
                                         : ()
                                   );
-
-    warn "Error suspending package ". $cust_pkg->pkgnum.
-         " for custnum ". $self->custnum. ": $error"
-      if $error;
+    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
+  scalar(@errors) ? join(' / ', @errors) : '';
+
 }
 
 =item bill OPTIONS
@@ -2528,7 +2631,7 @@ plans support this feature (they tend to charge 0).
 
 =item invoice_terms
 
-Options terms to be printed on this invocice.  Otherwise, customer-specific
+Optional terms to be printed on this invoice.  Otherwise, customer-specific
 terms or the default terms are used.
 
 =back
@@ -4774,6 +4877,9 @@ sub realtime_refund_bop {
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
     my $void = new Business::OnlinePayment( $processor, @bop_options );
+    $content{'card_number'} = $cust_pay->payinfo
+      if $cust_pay->payby eq 'CARD'
+      && $void->can('info') && $void->info('CC_void_requires_card');
     $void->content( 'action' => 'void', %content );
     $void->submit();
     if ( $void->is_success ) {
@@ -6112,6 +6218,9 @@ sub _new_realtime_refund_bop {
   ) {
     warn "  attempting void\n" if $DEBUG > 1;
     my $void = new Business::OnlinePayment( $processor, @bop_options );
+    $content{'card_number'} = $cust_pay->payinfo
+      if $cust_pay->payby eq 'CARD'
+      && $void->can('info') && $void->info('CC_void_requires_card');
     $void->content( 'action' => 'void', %content );
     $void->submit();
     if ( $void->is_success ) {
@@ -6847,6 +6956,36 @@ sub balance_date {
   );
 }
 
+=item balance_date_range START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+
+Returns the balance for this customer, only considering invoices with date
+earlier than START_TIME, and optionally not later than END_TIME
+(total_owed_date minus total_unapplied_credits minus total_unapplied_payments).
+
+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:
+
+=over 4
+
+=item unapplied_date
+
+set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
+
+=back
+
+=cut
+
+sub balance_date_range {
+  my $self = shift;
+  my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
+            ') FROM cust_main WHERE custnum='. $self->custnum;
+  sprintf( "%.2f", $self->scalar_sql($sql) );
+}
+
 =item balance_pkgnum PKGNUM
 
 Returns the balance for this customer's specific package when using
@@ -8258,7 +8397,7 @@ sub _money_table_where {
 
 }
 
-=item search_sql HASHREF
+=item search HASHREF
 
 (Class method)
 
@@ -8301,7 +8440,7 @@ bool
 
 =cut
 
-sub search_sql {
+sub search {
   my ($class, $params) = @_;
 
   my $dbh = dbh;
@@ -8368,24 +8507,41 @@ sub search_sql {
   # classnum
   ###
 
-  my @classnum = grep /^(\d*)$/, @{ $params->{'classnum'} };
-  if ( @classnum ) {
-    push @where, '( '. join(' OR ', map {
-                                          $_ ? "cust_main.classnum = $_"
-                                             : "cust_main.classnum IS NULL"
-                                        }
-                                        @classnum
-                           ).
-                 ' )';
+  if ( $params->{'classnum'} ) {
+
+    my @classnum = ref( $params->{'classnum'} )
+                     ? @{ $params->{'classnum'} }
+                     :  ( $params->{'classnum'} );
+
+    @classnum = grep /^(\d*)$/, @classnum;
+
+    if ( @classnum ) {
+      push @where, '( '. join(' OR ', map {
+                                            $_ ? "cust_main.classnum = $_"
+                                               : "cust_main.classnum IS NULL"
+                                          }
+                                          @classnum
+                             ).
+                   ' )';
+    }
+
   }
 
   ###
   # payby
   ###
 
-  my @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
-  if ( @payby ) {
-    push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )';
+  if ( $params->{'payby'} ) {
+
+    my @payby = ref( $params->{'payby'} )
+                  ? @{ $params->{'payby'} }
+                  :  ( $params->{'payby'} );
+
+    @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
+
+    push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'
+      if @payby;
+
   }
 
   ###
@@ -8425,11 +8581,20 @@ sub search_sql {
   # amounts
   ##
 
-  #my $balance_sql = $class->balance_sql();
-  my $balance_sql = FS::cust_main->balance_sql();
+  if ( $params->{'current_balance'} ) {
 
-  push @where, map { s/current_balance/$balance_sql/; $_ }
-                   @{ $params->{'current_balance'} };
+    #my $balance_sql = $class->balance_sql();
+    my $balance_sql = FS::cust_main->balance_sql();
+
+    my @current_balance =
+      ref( $params->{'current_balance'} )
+      ? @{ $params->{'current_balance'} }
+      :  ( $params->{'current_balance'} );
+
+    push @where, map { s/current_balance/$balance_sql/; $_ }
+                     @current_balance;
+
+  }
 
   ##
   # custbatch
@@ -8507,13 +8672,13 @@ sub search_sql {
 
 }
 
-=item email_search_sql HASHREF
+=item email_search_result HASHREF
 
 (Class method)
 
 Emails a notice to the specified customers.
 
-Valid parameters are those of the L<search_sql> method, plus the following:
+Valid parameters are those of the L<search> method, plus the following:
 
 =over 4
 
@@ -8547,7 +8712,7 @@ retrying everything.
 
 =cut
 
-sub email_search_sql {
+sub email_search_result {
   my($class, $params) = @_;
 
   my $from = delete $params->{from};
@@ -8560,7 +8725,7 @@ sub email_search_sql {
   $params->{'payby'} = [ split(/\0/, $params->{'payby'}) ]
     unless ref($params->{'payby'});
 
-  my $sql_query = $class->search_sql($params);
+  my $sql_query = $class->search($params);
 
   my $count_query   = delete($sql_query->{'count_query'});
   my $count_sth = dbh->prepare($count_query)
@@ -8612,7 +8777,7 @@ sub email_search_sql {
 use Storable qw(thaw);
 use Data::Dumper;
 use MIME::Base64;
-sub process_email_search_sql {
+sub process_email_search_result {
   my $job = shift;
   #warn "$me process_re_X $method for job $job\n" if $DEBUG;
 
@@ -8624,7 +8789,7 @@ sub process_email_search_sql {
   $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
     unless ref($param->{'payby'});
 
-  my $error = FS::cust_main->email_search_sql( $param );
+  my $error = FS::cust_main->email_search_result( $param );
   die $error if $error;
 
 }
@@ -8808,17 +8973,21 @@ sub smart_search {
 
     # "Company (Last, First)"
     #this is probably something a browser remembered,
-    #so just do an exact (but case-insensitive) search
+    #so just do an exact search (but case-insensitive, so USPS standardization
+    #doesn't throw a wrench in the works)
 
     foreach my $prefix ( '', 'ship_' ) {
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
-        'hashref'   => { $prefix.'first'   => $first,
-                         $prefix.'last'    => $last,
-                         $prefix.'company' => $company,
-                         %options,
-                       },
-        'extra_sql' => " AND $agentnums_sql",
+        'hashref'   => { %options },
+        'extra_sql' => 
+          ( keys(%options) ? ' AND ' : ' WHERE ' ).
+          join(' AND ',
+            " LOWER(${prefix}first)   = ". dbh->quote(lc($first)),
+            " LOWER(${prefix}last)    = ". dbh->quote(lc($last)),
+            " LOWER(${prefix}company) = ". dbh->quote(lc($company)),
+            $agentnums_sql,
+          ),
       } );
     }
 
@@ -8899,7 +9068,7 @@ sub smart_search {
     #getting complaints searches are not returning enough
     unless ( @cust_main  && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
 
-      #still some false laziness w/search_sql (was search/cust_main.cgi)
+      #still some false laziness w/search (was search/cust_main.cgi)
 
       #substring