add (unfinished) credit card surcharge, part 1
[freeside.git] / FS / FS / cust_bill.pm
index ca81c03..62ab87f 100644 (file)
@@ -1,7 +1,8 @@
 package FS::cust_bill;
 
 use strict;
-use vars qw( @ISA $DEBUG $me $conf $money_char $date_format );
+use vars qw( @ISA $DEBUG $me $conf
+             $money_char $date_format $rdate_format $date_format_long );
 use vars qw( $invoice_lines @buf ); #yuck
 use Fcntl qw(:flock); #for spool_csv
 use List::Util qw(min max);
@@ -34,6 +35,8 @@ use FS::cust_bill_pay;
 use FS::cust_bill_pay_batch;
 use FS::part_bill_event;
 use FS::payby;
+use FS::bill_batch;
+use FS::cust_bill_batch;
 
 @ISA = qw( FS::cust_main_Mixin FS::Record );
 
@@ -43,8 +46,10 @@ $me = '[FS::cust_bill]';
 #ask FS::UID to run this stuff for us later
 FS::UID->install_callback( sub { 
   $conf = new FS::Conf;
-  $money_char = $conf->config('money_char') || '$';  
-  $date_format = $conf->config('date_format') || '%x';  
+  $money_char       = $conf->config('money_char')       || '$';  
+  $date_format      = $conf->config('date_format')      || '%x'; #/YY
+  $rdate_format     = $conf->config('date_format')      || '%m/%d/%Y';  #/YYYY
+  $date_format_long = $conf->config('date_format_long') || '%b %o, %Y';
 } );
 
 =head1 NAME
@@ -159,6 +164,45 @@ sub cust_unlinked_msg {
 Adds this invoice to the database ("Posts" the invoice).  If there is an error,
 returns the error, otherwise returns false.
 
+=cut
+
+sub insert {
+  my $self = shift;
+  warn "$me insert called\n" if $DEBUG;
+
+  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;
+
+  my $error = $self->SUPER::insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  if ( $self->get('cust_bill_pkg') ) {
+    foreach my $cust_bill_pkg ( @{$self->get('cust_bill_pkg')} ) {
+      $cust_bill_pkg->invnum($self->invnum);
+      my $error = $cust_bill_pkg->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "can't create invoice line item: $error";
+      }
+    }
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
 =item delete
 
 This method now works but you probably shouldn't use it.  Instead, apply a
@@ -221,13 +265,13 @@ sub delete {
 
 }
 
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
 
-Replaces the OLD_RECORD with this one in the database.  If there is an error,
-returns the error, otherwise returns false.
+You can, but probably shouldn't modify invoices...
 
-Only printed may be changed.  printed is normally updated by calling the
-collect method of a customer object (see L<FS::cust_main>).
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record.  If there is an error, returns the error,
+otherwise returns false.
 
 =cut
 
@@ -238,11 +282,11 @@ collect method of a customer object (see L<FS::cust_main>).
 
 sub replace_check {
   my( $new, $old ) = ( shift, shift );
-  return "Can't change custnum!" unless $old->custnum == $new->custnum;
+  return "Can't modify closed invoice" if $old->closed =~ /^Y/i;
   #return "Can't change _date!" unless $old->_date eq $new->_date;
-  return "Can't change _date!" unless $old->_date == $new->_date;
-  return "Can't change charged!" unless $old->charged == $new->charged
-                                     || $old->charged == 0;
+  return "Can't change _date" unless $old->_date == $new->_date;
+  return "Can't change charged" unless $old->charged == $new->charged
+                                    || $old->charged == 0;
 
   '';
 }
@@ -355,11 +399,24 @@ this invoice.
 
 sub cust_pkg {
   my $self = shift;
-  my @cust_pkg = map { $_->cust_pkg } $self->cust_bill_pkg;
+  my @cust_pkg = map { $_->pkgnum > 0 ? $_->cust_pkg : () }
+                     $self->cust_bill_pkg;
   my %saw = ();
   grep { ! $saw{$_->pkgnum}++ } @cust_pkg;
 }
 
+=item no_auto
+
+Returns true if any of the packages (or their definitions) corresponding to the
+line items for this invoice have the no_auto flag set.
+
+=cut
+
+sub no_auto {
+  my $self = shift;
+  grep { $_->no_auto || $_->part_pkg->no_auto } $self->cust_pkg;
+}
+
 =item open_cust_bill_pkg
 
 Returns the open line items for this invoice.
@@ -1184,8 +1241,14 @@ sub email {
   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } 
                             $self->cust_main->invoicing_list;
 
-  #better to notify this person than silence
-  @invoicing_list = ($invoice_from) unless @invoicing_list;
+  if ( ! @invoicing_list ) { #no recipients
+    if ( $conf->exists('cust_bill-no_recipients-error') ) {
+      die 'No recipients for customer #'. $self->custnum;
+    } else {
+      #default: better to notify this person than silence
+      @invoicing_list = ($invoice_from);
+    }
+  }
 
   my $subject = $self->email_subject($template);
 
@@ -1286,7 +1349,13 @@ sub print {
     'notice_name' => $notice_name,
   );
 
-  do_print $self->lpr_data(\%opt);
+  if($conf->exists('invoice_print_pdf')) {
+    # Add the invoice to the current batch.
+    $self->batch_invoice(\%opt);
+  }
+  else {
+    do_print $self->lpr_data(\%opt);
+  }
 }
 
 =item fax_invoice HASHREF | [ TEMPLATE ] 
@@ -1332,6 +1401,23 @@ sub fax_invoice {
 
 }
 
+=item batch_invoice [ HASHREF ]
+
+Place this invoice into the open batch (see C<FS::bill_batch>).  If there 
+isn't an open batch, one will be created.
+
+=cut
+
+sub batch_invoice {
+  my ($self, $opt) = @_;
+  my $batch = FS::bill_batch->get_open_batch;
+  my $cust_bill_batch = FS::cust_bill_batch->new({
+      batchnum => $batch->batchnum,
+      invnum   => $self->invnum,
+  });
+  return $cust_bill_batch->insert($opt);
+}
+
 =item ftp_invoice [ TEMPLATENAME ] 
 
 Sends this invoice data via FTP.
@@ -1872,7 +1958,8 @@ sub realtime_lec {
 }
 
 sub realtime_bop {
-  my( $self, $method ) = @_;
+  my( $self, $method ) = (shift,shift);
+  my %opt = @_;
 
   my $cust_main = $self->cust_main;
   my $balance = $cust_main->balance;
@@ -1898,6 +1985,15 @@ sub realtime_bop {
   $cust_main->realtime_bop($method, $amount,
     'description' => $description,
     'invnum'      => $self->invnum,
+#this didn't do what we want, it just calls apply_payments_and_credits
+#    'apply'       => 1,
+    'apply_to_invoice' => 1,
+    %opt,
+ #what we want:
+ #this changes application behavior: auto payments
+                        #triggered against a specific invoice are now applied
+                        #to that invoice instead of oldest open.
+                        #seem okay to me...
   );
 
 }
@@ -2205,15 +2301,19 @@ sub print_generic {
   my $nbsp = $nbsps{$format};
 
   my %escape_functions = ( 'latex'    => \&_latex_escape,
-                           'html'     => \&encode_entities,
+                           'html'     => \&_html_escape_nbsp,#\&encode_entities,
                            'template' => sub { shift },
                          );
   my $escape_function = $escape_functions{$format};
+  my $escape_function_nonbsp = ($format eq 'html')
+                                 ? \&_html_escape : $escape_function;
 
-  my %date_formats = ( 'latex'    => '%b %o, %Y',
-                       'html'     => '%b&nbsp;%o,&nbsp;%Y',
+  my %date_formats = ( 'latex'    => $date_format_long,
+                       'html'     => $date_format_long,
                        'template' => '%s',
                      );
+  $date_formats{'html'} =~ s/ /&nbsp;/g;
+
   my $date_format = $date_formats{$format};
 
   my %embolden_functions = ( 'latex'    => sub { return '\textbf{'. shift(). '}'
@@ -2277,23 +2377,25 @@ sub print_generic {
 
   }
 
+  my $agentnum = $self->cust_main->agentnum;
+
   my %invoice_data = (
 
     #invoice from info
-    'company_name'    => scalar( $conf->config('company_name', $self->cust_main->agentnum) ),
-    'company_address' => join("\n", $conf->config('company_address', $self->cust_main->agentnum) ). "\n",
+    'company_name'    => scalar( $conf->config('company_name', $agentnum) ),
+    'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
     'returnaddress'   => $returnaddress,
     'agent'           => &$escape_function($cust_main->agent->agent),
 
     #invoice info
     'invnum'          => $self->invnum,
     'date'            => time2str($date_format, $self->_date),
-    'today'           => time2str('%b %o, %Y', $today),
+    'today'           => time2str($date_format_long, $today),
     'terms'           => $self->terms,
     'template'        => $template, #params{'template'},
     'notice_name'     => ($params{'notice_name'} || 'Invoice'),#escape_function?
     'current_charges' => sprintf("%.2f", $self->charged),
-    'duedate'         => $self->due_date2str('%m/%d/%Y'), #date_format?
+    'duedate'         => $self->due_date2str($rdate_format), #date_format?
 
     #customer info
     'custnum'         => $cust_main->display_custnum,
@@ -2307,7 +2409,21 @@ sub print_generic {
     'unitprices'      => $conf->exists('invoice-unitprice'),
     'smallernotes'    => $conf->exists('invoice-smallernotes'),
     'smallerfooter'   => $conf->exists('invoice-smallerfooter'),
+    'balance_due_below_line' => $conf->exists('balance_due_below_line'),
    
+    #layout info -- would be fancy to calc some of this and bury the template
+    #               here in the code
+    'topmargin'             => scalar($conf->config('invoice_latextopmargin', $agentnum)),
+    'headsep'               => scalar($conf->config('invoice_latexheadsep', $agentnum)),
+    'textheight'            => scalar($conf->config('invoice_latextextheight', $agentnum)),
+    'extracouponspace'      => scalar($conf->config('invoice_latexextracouponspace', $agentnum)),
+    'couponfootsep'         => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)),
+    'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum),
+    'addresssep'            => scalar($conf->config('invoice_latexaddresssep', $agentnum)),
+    'amountenclosedsep'     => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
+    'coupontoaddresssep'    => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
+    'addcompanytoaddress'   => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
+
     # better hang on to conf_dir for a while (for old templates)
     'conf_dir'        => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
 
@@ -2323,7 +2439,8 @@ sub print_generic {
       qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
     $invoice_data{finance_section} = $pkg_class->categoryname;
   } 
- $invoice_data{finance_amount} = '0.00';
+  $invoice_data{finance_amount} = '0.00';
+  $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
 
   my $countrydefault = $conf->config('countrydefault') || 'US';
   my $prefix = $cust_main->has_ship_address ? 'ship_' : '';
@@ -2376,8 +2493,6 @@ sub print_generic {
   $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
   $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
 
-  my $agentnum = $self->cust_main->agentnum;
-
   my $summarypage = '';
   if ( $conf->exists('invoice_usesummary', $agentnum) ) {
     $summarypage = 1;
@@ -2491,12 +2606,13 @@ sub print_generic {
 
   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
   my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum);
+  $invoice_data{'multisection'} = $multisection;
   my $late_sections = [];
   my $extra_sections = [];
   my $extra_lines = ();
   if ( $multisection ) {
     ($extra_sections, $extra_lines) =
-      $self->_items_extra_usage_sections($escape_function, $format)
+      $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
       if $conf->exists('usage_class_as_a_section', $cust_main->agentnum);
 
     push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
@@ -2505,13 +2621,13 @@ sub print_generic {
     push @sections,
       $self->_items_sections( $late_sections,      # this could stand a refactor
                               $summarypage,
-                              $escape_function,
+                              $escape_function_nonbsp,
                               $extra_sections,
                               $format,             #bah
                             );
     if ($conf->exists('svc_phone_sections')) {
       my ($phone_sections, $phone_lines) =
-        $self->_items_svc_phone_sections($escape_function, $format);
+        $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
       push @{$late_sections}, @$phone_sections;
       push @detail_items, @$phone_lines;
     }
@@ -2556,9 +2672,25 @@ sub print_generic {
                  $money_char. sprintf("%10.2f", $pr_total) ];
     push @buf, ['',''];
   }
+  if ( $conf->exists('svc_phone-did-summary') ) {
+      my ($didsummary,$minutes) = $self->_did_summary;
+      my $didsummary_desc = 'DID Activity Summary (Past 30 days)';
+      push @detail_items, 
+       { 'description' => $didsummary_desc,
+           'ext_description' => [ $didsummary, $minutes ],
+       }
+       if !$multisection;
+  }
 
   foreach my $section (@sections, @$late_sections) {
 
+    # begin some normalization
+    $section->{'subtotal'} = $section->{'amount'}
+      if $multisection
+         && !exists($section->{subtotal})
+         && exists($section->{amount});
+
     $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
       if ( $invoice_data{finance_section} &&
            $section->{'description'} eq $invoice_data{finance_section} );
@@ -2567,7 +2699,7 @@ sub print_generic {
                              sprintf('%.2f', $section->{'subtotal'})
       if $multisection;
 
-    # begin some normalization
+    # continue some normalization
     $section->{'amount'}   = $section->{'subtotal'}
       if $multisection;
 
@@ -2589,6 +2721,7 @@ sub print_generic {
     $options{'skip_usage'} =
       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
     $options{'multilocation'} = $multilocation;
+    $options{'multisection'} = $multisection;
 
     foreach my $line_item ( $self->_items_pkg(%options) ) {
       my $detail = {
@@ -2630,7 +2763,9 @@ sub print_generic {
   $invoice_data{current_less_finance} =
     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
 
-  if ( $multisection && !$conf->exists('disable_previous_balance') ) {
+  if ( $multisection && !$conf->exists('disable_previous_balance')
+    || $conf->exists('previous_balance-summary_only') )
+  {
     unshift @sections, $previous_section if $pr_total;
   }
 
@@ -2697,17 +2832,19 @@ sub print_generic {
 
   {
     my $total = {};
-    $total->{'total_item'} = &$embolden_function('Total');
+    my $item = 'Total';
+    $item = $conf->config('previous_balance-exclude_from_total')
+         || 'Total New Charges'
+      if $conf->exists('previous_balance-exclude_from_total');
+    my $amount = $self->charged +
+                   ( $conf->exists('disable_previous_balance') ||
+                     $conf->exists('previous_balance-exclude_from_total')
+                     ? 0
+                     : $pr_total
+                   );
+    $total->{'total_item'} = &$embolden_function($item);
     $total->{'total_amount'} =
-      &$embolden_function(
-        $other_money_char.
-        sprintf( '%.2f',
-                 $self->charged + ( $conf->exists('disable_previous_balance')
-                                    ? 0
-                                    : $pr_total
-                                  )
-               )
-      );
+      &$embolden_function( $other_money_char.  sprintf( '%.2f', $amount ) );
     if ( $multisection ) {
       if ( $adjust_section->{'sort_weight'} ) {
         $adjust_section->{'posttotal'} = 'Balance Forward '. $other_money_char.
@@ -2720,14 +2857,9 @@ sub print_generic {
       push @total_items, $total;
     }
     push @buf,['','-----------'];
-    push @buf,['Total Charges',
+    push @buf,[$item,
                $money_char.
-               sprintf( '%10.2f', $self->charged +
-                                    ( $conf->exists('disable_previous_balance')
-                                        ? 0
-                                        : $pr_total
-                                    )
-                      )
+               sprintf( '%10.2f', $amount )
               ];
     push @buf,['',''];
   }
@@ -3055,6 +3187,18 @@ sub _latex_escape {
   $value;
 }
 
+sub _html_escape {
+  my $value = shift;
+  encode_entities($value);
+  $value;
+}
+
+sub _html_escape_nbsp {
+  my $value = _html_escape(shift);
+  $value =~ s/ +/&nbsp;/g;
+  $value;
+}
+
 #utility methods for print_*
 
 sub _translate_old_latex_format {
@@ -3151,7 +3295,7 @@ sub balance_due_msg {
   my $msg = 'Balance Due';
   return $msg unless $self->terms;
   if ( $self->due_date ) {
-    $msg .= ' - Please pay by '. $self->due_date2str('%x');
+    $msg .= ' - Please pay by '. $self->due_date2str($date_format);
   } elsif ( $self->terms ) {
     $msg .= ' - '. $self->terms;
   }
@@ -3163,7 +3307,7 @@ sub balance_due_date {
   my $duedate = '';
   if (    $conf->exists('invoice_default_terms') 
        && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
-    $duedate = time2str("%m/%d/%Y", $self->_date + ($1*86400) );
+    $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
   }
   $duedate;
 }
@@ -3188,7 +3332,7 @@ Returns a string with the date, for example: "3/20/2008"
 
 sub _date_pretty {
   my $self = shift;
-  time2str('%x', $self->_date);
+  time2str($date_format, $self->_date);
 }
 
 use vars qw(%pkg_category_cache);
@@ -3289,6 +3433,7 @@ sub _items_sections {
   if ( $summarypage ) {
     @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
                 map { $_->categoryname } qsearch('pkg_category', {});
+    push @sections, '' if exists($subtotal{''});
   } else {
     @sections = keys %subtotal;
   }
@@ -3330,7 +3475,9 @@ my %condensed_format = (
   'fields' => [
                 sub { shift->{description} },
                 sub { shift->{quantity} },
-                sub { shift->{amount} },
+                sub { my($href, %opt) = @_;
+                      ($opt{dollar} || ''). $href->{amount};
+                    },
               ],
   'align'  => [ qw( l r r ) ],
   'span'   => [ qw( 5 1 1 ) ],            # unitprices?
@@ -3404,6 +3551,7 @@ sub _condensed_description_generator {
   my ( $f, $prefix, $suffix, $separator, $column ) =
     _condensed_generator_defaults($format);
 
+  my $money_char = '$';
   if ($format eq 'latex') {
     $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
     $suffix = '\\\\';
@@ -3412,6 +3560,7 @@ sub _condensed_description_generator {
       sub { my ($d,$a,$s,$w) = @_;
             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
           };
+    $money_char = '\\dollar';
   }elsif ( $format eq 'html' ) {
     $prefix = '"><td align="center"></td>';
     $suffix = '';
@@ -3420,16 +3569,22 @@ sub _condensed_description_generator {
       sub { my ($d,$a,$s,$w) = @_;
             return qq!<td align="$html_align{$a}">$d</td>!;
       };
+    #$money_char = $conf->config('money_char') || '$';
+    $money_char = '';  # this is madness
   }
 
   sub {
-    my @args = @_;
+    #my @args = @_;
+    my $href = shift;
     my @result = ();
 
     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
-      push @result, &{$column}( &{$f->{fields}->[$i]}(@args),
-                                map { $f->{$_}->[$i] } qw(align span width)
-                              );
+      my $dollar = '';
+      $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
+      push @result,
+        &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
+                    map { $f->{$_}->[$i] } qw(align span width)
+                  );
     }
 
     $prefix. join( $separator, @result ). $suffix;
@@ -3660,6 +3815,72 @@ sub _items_extra_usage_sections {
 
 }
 
+sub _did_summary {
+    my $self = shift;
+    my $end = $self->_date;
+    my $start = $end - 2592000; # 30 days
+    my $cust_main = $self->cust_main;
+    my @pkgs = $cust_main->all_pkgs;
+    my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes)
+       = (0,0,0,0,0);
+    my @seen = ();
+    foreach my $pkg ( @pkgs ) {
+       my @h_cust_svc = $pkg->h_cust_svc($end);
+       foreach my $h_cust_svc ( @h_cust_svc ) {
+           next if grep {$_ eq $h_cust_svc->svcnum} @seen;
+           next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone';
+
+           my $inserted = $h_cust_svc->date_inserted;
+           my $deleted = $h_cust_svc->date_deleted;
+           my $phone_inserted = $h_cust_svc->h_svc_x($inserted);
+           my $phone_deleted;
+           $phone_deleted =  $h_cust_svc->h_svc_x($deleted) if $deleted;
+           
+# DID either activated or ported in; cannot be both for same DID simultaneously
+           if ($inserted >= $start && $inserted <= $end && $phone_inserted
+               && (!$phone_inserted->lnp_status 
+                   || $phone_inserted->lnp_status eq ''
+                   || $phone_inserted->lnp_status eq 'native')) {
+               $num_activated++;
+           }
+           else { # this one not so clean, should probably move to (h_)svc_phone
+                my $phone_portedin = qsearchs( 'h_svc_phone',
+                     { 'svcnum' => $h_cust_svc->svcnum, 
+                       'lnp_status' => 'portedin' },  
+                     FS::h_svc_phone->sql_h_searchs($end),  
+                   );
+                $num_portedin++ if $phone_portedin;
+           }
+
+# DID either deactivated or ported out;        cannot be both for same DID simultaneously
+           if($deleted >= $start && $deleted <= $end && $phone_deleted
+               && (!$phone_deleted->lnp_status 
+                   || $phone_deleted->lnp_status ne 'portingout')) {
+               $num_deactivated++;
+           } 
+           elsif($deleted >= $start && $deleted <= $end && $phone_deleted 
+               && $phone_deleted->lnp_status 
+               && $phone_deleted->lnp_status eq 'portingout') {
+               $num_portedout++;
+           }
+
+           # increment usage minutes
+           my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end);
+           foreach my $cdr ( @cdrs ) {
+               $minutes += $cdr->billsec/60;
+           }
+
+           # don't look at this service again
+           push @seen, $h_cust_svc->svcnum;
+       }
+    }
+
+    $minutes = sprintf("%d", $minutes);
+    ("Activated: $num_activated  Ported-In: $num_portedin  Deactivated: "
+       . "$num_deactivated  Ported-Out: $num_portedout ",
+           "Total Minutes: $minutes");
+}
+
 sub _items_svc_phone_sections {
   my $self = shift;
   my $escape = shift;
@@ -3670,10 +3891,14 @@ sub _items_svc_phone_sections {
   my %lines = ();
 
   my %usage_class =  map { $_->classnum => $_ } qsearch( 'usage_class', {} );
+  $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
 
   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
     next unless $cust_bill_pkg->pkgnum > 0;
 
+    my @header = $cust_bill_pkg->details_header;
+    next unless scalar(@header);
+
     foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) {
 
       my $phonenum = $detail->phonenum;
@@ -3722,6 +3947,7 @@ sub _items_svc_phone_sections {
           'duration' => 0,
           'sort_weight' => $usage_class{$detail->classnum}->weight,
           'phonenum' => $phonenum,
+          'header'  => [ @header ],
         };
       $sections{"$phonenum $line"}{amount} += $amount;  #subtotal
       $sections{"$phonenum $line"}{calls}++;
@@ -3752,11 +3978,17 @@ sub _items_svc_phone_sections {
 
   my %sectionmap = ();
   my $simple = new FS::usage_class { format => 'simple' }; #bleh
-  my $usage_simple = new FS::usage_class { format => 'usage_simple' }; #bleh
   foreach ( keys %sections ) {
+    my @header = @{ $sections{$_}{header} || [] };
+    my $usage_simple =
+      new FS::usage_class { format => 'usage_'. (scalar(@header) || 6). 'col' };
     my $summary = $sections{$_}{sort_weight} < 0 ? 1 : 0;
     my $usage_class = $summary ? $simple : $usage_simple;
     my $ending = $summary ? ' usage charges' : '';
+    my %gen_opt = ();
+    unless ($summary) {
+      $gen_opt{label} = [ map{ &{$escape}($_) } @header ];
+    }
     $sectionmap{$_} = { 'description' => &{$escape}($_. $ending),
                         'amount'    => $sections{$_}{amount},    #subtotal
                         'calls'       => $sections{$_}{calls},
@@ -3767,7 +3999,7 @@ sub _items_svc_phone_sections {
                         'sort_weight' => $sections{$_}{sort_weight},
                         'post_total'  => $summary, #inspire pagebreak
                         (
-                          ( map { $_ => $usage_class->$_($format) }
+                          ( map { $_ => $usage_class->$_($format, %gen_opt) }
                             qw( description_generator
                                 header_generator
                                 total_generator
@@ -3823,9 +4055,7 @@ sub _items_previous {
   foreach ( @pr_cust_bill ) {
     my $date = $conf->exists('invoice_show_prior_due_date')
                ? 'due '. $_->due_date2str($date_format)
-               : time2str('%x', $_->_date); # date_format here, too,
-                                            # but fix _items_cust_bill_pkg,
-                                            # header, others?
+               : time2str($date_format, $_->_date);
     push @b, {
       'description' => 'Previous Balance, Invoice #'. $_->invnum. " ($date)",
       #'pkgpart'     => 'N/A',
@@ -3878,12 +4108,12 @@ sub _items_pkg {
 }
 
 sub _taxsort {
-  return 0 unless $a cmp $b;
-  return -1 if $b eq 'Tax';
-  return 1 if $a eq 'Tax';
-  return -1 if $b eq 'Other surcharges';
-  return 1 if $a eq 'Other surcharges';
-  $a cmp $b;
+  return 0 unless $a->itemdesc cmp $b->itemdesc;
+  return -1 if $b->itemdesc eq 'Tax';
+  return 1 if $a->itemdesc eq 'Tax';
+  return -1 if $b->itemdesc eq 'Other surcharges';
+  return 1 if $a->itemdesc eq 'Other surcharges';
+  $a->itemdesc cmp $b->itemdesc;
 }
 
 sub _items_tax {
@@ -3904,19 +4134,24 @@ sub _items_cust_bill_pkg {
   my $section = $opt{section}->{description} if $opt{section};
   my $summary_page = $opt{summary_page} || '';
   my $multilocation = $opt{multilocation} || '';
+  my $multisection = $opt{multisection} || '';
+  my $discount_show_always = 0;
 
   my @b = ();
   my ($s, $r, $u) = ( undef, undef, undef );
   foreach my $cust_bill_pkg ( @$cust_bill_pkg )
   {
 
+    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
+                               && $conf->exists('discount-show-always'));
+
     foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
       if ( $_ && !$cust_bill_pkg->hidden ) {
         $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
         $_->{amount}      =~ s/^\-0\.00$/0.00/;
         $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
         push @b, { %$_ }
-          unless $_->{amount} == 0;
+          unless ( $_->{amount} == 0 && !$discount_show_always );
         $_ = undef;
       }
     }
@@ -3925,7 +4160,8 @@ sub _items_cust_bill_pkg {
                                  ? $_->section eq $section
                                  : 1
                                }
-                          grep { !$_->summary || !$summary_page }
+                          #grep { !$_->summary || !$summary_page } # bunk!
+                          grep { !$_->summary || $multisection }
                           $cust_bill_pkg->cust_bill_pkg_display
                         )
     {
@@ -3954,12 +4190,20 @@ sub _items_cust_bill_pkg {
           unless ( $cust_pkg->part_pkg->hide_svc_detail
                 || $cust_bill_pkg->hidden )
           {
+
             push @d, map &{$escape_function}($_),
-                         $cust_pkg->h_labels_short($self->_date);
-            push @d, map &{$escape_function}($_),
-                         $cust_pkg->location_label_short
-              if $multilocation;
+                         $cust_pkg->h_labels_short($self->_date, undef, 'I')
+              unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+
+            if ( $multilocation ) {
+              my $loc = $cust_pkg->location_label;
+              $loc = substr($loc, 0, 50). '...'
+                if $format eq 'latex' && length($loc) > 50;
+              push @d, &{$escape_function}($loc);
+            }
+
           }
+
           push @d, $cust_bill_pkg->details(%details_opt)
             if $cust_bill_pkg->recur == 0;
 
@@ -3981,7 +4225,8 @@ sub _items_cust_bill_pkg {
 
         }
 
-        if ( $cust_bill_pkg->recur != 0 &&
+        if ( ( $cust_bill_pkg->recur != 0  || $cust_bill_pkg->setup == 0 || 
+               ($discount_show_always && $cust_bill_pkg->recur == 0) ) &&
              ( !$type || $type eq 'R' || $type eq 'U' )
            )
         {
@@ -3991,8 +4236,8 @@ sub _items_cust_bill_pkg {
                             ? "Usage charges" : $desc;
 
           unless ( $conf->exists('disable_line_item_date_ranges') ) {
-            $description .= " (" . time2str("%x", $cust_bill_pkg->sdate).
-                            " - ". time2str("%x", $cust_bill_pkg->edate). ")";
+            $description .= " (" . time2str($date_format, $cust_bill_pkg->sdate).
+                            " - ". time2str($date_format, $cust_bill_pkg->edate). ")";
           }
 
           my @d = ();
@@ -4002,20 +4247,27 @@ sub _items_cust_bill_pkg {
           my @dates = ( $self->_date );
           my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
           push @dates, $prev->sdate if $prev;
+          push @dates, undef if !$prev;
 
           unless ( $cust_pkg->part_pkg->hide_svc_detail
                 || $cust_bill_pkg->itemdesc
                 || $cust_bill_pkg->hidden
                 || $is_summary && $type && $type eq 'U' )
           {
+
             push @d, map &{$escape_function}($_),
-                         $cust_pkg->h_labels_short(@dates)
+                         $cust_pkg->h_labels_short(@dates, 'I')
                                                    #$cust_bill_pkg->edate,
                                                    #$cust_bill_pkg->sdate)
-            ;
-            push @d, map &{$escape_function}($_),
-                         $cust_pkg->location_label_short
-              if $multilocation;
+              unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+
+            if ( $multilocation ) {
+              my $loc = $cust_pkg->location_label;
+              $loc = substr($loc, 0, 50). '...'
+                if $format eq 'latex' && length($loc) > 50;
+              push @d, &{$escape_function}($loc);
+            }
+
           }
 
           push @d, $cust_bill_pkg->details(%details_opt)
@@ -4048,7 +4300,7 @@ sub _items_cust_bill_pkg {
               };
             }
 
-          } elsif ( $amount ) {  # && $type eq 'U'
+          } else {  # $type eq 'U'
 
             if ( $cust_bill_pkg->hidden ) {
               $u->{amount}      += $amount;
@@ -4081,8 +4333,8 @@ sub _items_cust_bill_pkg {
         if ( $cust_bill_pkg->recur != 0 ) {
           push @b, {
             'description' => "$desc (".
-                             time2str("%x", $cust_bill_pkg->sdate). ' - '.
-                             time2str("%x", $cust_bill_pkg->edate). ')',
+                             time2str($date_format, $cust_bill_pkg->sdate). ' - '.
+                             time2str($date_format, $cust_bill_pkg->edate). ')',
             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
           };
         }
@@ -4099,7 +4351,7 @@ sub _items_cust_bill_pkg {
       $_->{amount}      =~ s/^\-0\.00$/0.00/;
       $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
       push @b, { %$_ }
-        unless $_->{amount} == 0;
+           unless ( $_->{amount} == 0 && !$discount_show_always );
     }
   }
 
@@ -4126,7 +4378,7 @@ sub _items_credits {
       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
       #                 $reason,
       'description' => 'Credit applied '.
-                       time2str("%x",$_->cust_credit->_date). $reason,
+                       time2str($date_format,$_->cust_credit->_date). $reason,
       'amount'      => sprintf("%.2f",$_->amount),
     };
   }
@@ -4146,7 +4398,7 @@ sub _items_payments {
 
     push @b, {
       'description' => "Payment received ".
-                       time2str("%x",$_->cust_pay->_date ),
+                       time2str($date_format,$_->cust_pay->_date ),
       'amount'      => sprintf("%.2f", $_->amount )
     };
   }
@@ -4314,8 +4566,10 @@ Returns an SQL fragment to retreive the amount owed (charged minus credited and
 =cut
 
 sub owed_sql {
-  my $class = shift;
-  'charged - '. $class->paid_sql. ' - '. $class->credited_sql;
+  my ($class, $start, $end) = @_;
+  'charged - '. 
+    $class->paid_sql($start, $end). ' - '. 
+    $class->credited_sql($start, $end);
 }
 
 =item net_sql
@@ -4325,8 +4579,8 @@ Returns an SQL fragment to retreive the net amount (charged minus credited).
 =cut
 
 sub net_sql {
-  my $class = shift;
-  'charged - '. $class->credited_sql;
+  my ($class, $start, $end) = @_;
+  'charged - '. $class->credited_sql($start, $end);
 }
 
 =item paid_sql
@@ -4336,9 +4590,13 @@ Returns an SQL fragment to retreive the amount paid against this invoice.
 =cut
 
 sub paid_sql {
-  #my $class = shift;
+  my ($class, $start, $end) = @_;
+  $start &&= "AND cust_bill_pay._date <= $start";
+  $end   &&= "AND cust_bill_pay._date > $end";
+  $start = '' unless defined($start);
+  $end   = '' unless defined($end);
   "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay
-       WHERE cust_bill.invnum = cust_bill_pay.invnum   )";
+       WHERE cust_bill.invnum = cust_bill_pay.invnum $start $end  )";
 }
 
 =item credited_sql
@@ -4348,9 +4606,32 @@ Returns an SQL fragment to retreive the amount credited against this invoice.
 =cut
 
 sub credited_sql {
-  #my $class = shift;
+  my ($class, $start, $end) = @_;
+  $start &&= "AND cust_credit_bill._date <= $start";
+  $end   &&= "AND cust_credit_bill._date >  $end";
+  $start = '' unless defined($start);
+  $end   = '' unless defined($end);
   "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill
-       WHERE cust_bill.invnum = cust_credit_bill.invnum   )";
+       WHERE cust_bill.invnum = cust_credit_bill.invnum $start $end  )";
+}
+
+=item due_date_sql
+
+Returns an SQL fragment to retrieve the due date of an invoice.
+Currently only supported on PostgreSQL.
+
+=cut
+
+sub due_date_sql {
+'COALESCE(
+  SUBSTRING(
+    COALESCE(
+      cust_bill.invoice_terms,
+      cust_main.invoice_terms,
+      \''.($conf->config('invoice_default_terms') || '').'\'
+    ), E\'Net (\\\\d+)\'
+  )::INTEGER, 0
+) * 86400 + cust_bill._date'
 }
 
 =item search_sql_where HASHREF