add (unfinished) credit card surcharge, part 1
[freeside.git] / FS / FS / cust_bill.pm
index a6a8e68..62ab87f 100644 (file)
@@ -1,7 +1,8 @@
 package FS::cust_bill;
 
 use strict;
-use vars qw( @ISA $DEBUG $me $conf $money_char );
+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);
@@ -11,13 +12,16 @@ use File::Temp 0.14;
 use String::ShellQuote;
 use HTML::Entities;
 use Locale::Country;
+use Storable qw( freeze thaw );
 use FS::UID qw( datasrc );
 use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print );
 use FS::Record qw( qsearch qsearchs dbh );
 use FS::cust_main_Mixin;
 use FS::cust_main;
+use FS::cust_statement;
 use FS::cust_bill_pkg;
 use FS::cust_bill_pkg_display;
+use FS::cust_bill_pkg_detail;
 use FS::cust_credit;
 use FS::cust_pay;
 use FS::cust_pkg;
@@ -31,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 );
 
@@ -40,7 +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') || '$';  
+  $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
@@ -82,6 +91,8 @@ owes you money.  The specific charges are itemized as B<cust_bill_pkg> records
 (see L<FS::cust_bill_pkg>).  FS::cust_bill inherits from FS::Record.  The
 following fields are currently supported:
 
+Regular fields
+
 =over 4
 
 =item invnum - primary key (assigned automatically for new invoices)
@@ -93,10 +104,38 @@ L<Time::Local> and L<Date::Parse> for conversion functions.
 
 =item charged - amount of this invoice
 
+=item invoice_terms - optional terms override for this specific invoice
+
+=back
+
+Customer info at invoice generation time
+
+=over 4
+
+=item previous_balance
+
+=item billing_balance
+
+=back
+
+Deprecated
+
+=over 4
+
 =item printed - deprecated
 
+=back
+
+Specific use cases
+
+=over 4
+
 =item closed - books closed flag, empty or `Y'
 
+=item statementnum - invoice aggregation (see L<FS::cust_statement>)
+
+=item agent_invid - legacy invoice number
+
 =back
 
 =head1 METHODS
@@ -125,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
@@ -141,16 +219,59 @@ Really, don't use it.
 sub delete {
   my $self = shift;
   return "Can't delete closed invoice" if $self->closed =~ /^Y/i;
-  $self->SUPER::delete(@_);
+
+  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;
+
+  foreach my $table (qw(
+    cust_bill_event
+    cust_event
+    cust_credit_bill
+    cust_bill_pay
+    cust_bill_pay
+    cust_credit_bill
+    cust_pay_batch
+    cust_bill_pay_batch
+    cust_bill_pkg
+  )) {
+
+    foreach my $linked ( $self->$table() ) {
+      my $error = $linked->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+  }
+
+  my $error = $self->SUPER::delete(@_);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
 }
 
-=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
 
@@ -161,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;
 
   '';
 }
@@ -183,17 +304,16 @@ sub check {
 
   my $error =
     $self->ut_numbern('invnum')
-    || $self->ut_number('custnum')
+    || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' )
     || $self->ut_numbern('_date')
     || $self->ut_money('charged')
     || $self->ut_numbern('printed')
     || $self->ut_enum('closed', [ '', 'Y' ])
+    || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum' )
+    || $self->ut_numbern('agent_invid') #varchar?
   ;
   return $error if $error;
 
-  return "Unknown customer"
-    unless qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
-
   $self->_date(time) unless $self->_date;
 
   $self->printed(0) if $self->printed eq '';
@@ -201,6 +321,22 @@ sub check {
   $self->SUPER::check;
 }
 
+=item display_invnum
+
+Returns the displayed invoice number for this invoice: agent_invid if
+cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
+
+=cut
+
+sub display_invnum {
+  my $self = shift;
+  if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){
+    return $self->agent_invid;
+  } else {
+    return $self->invnum;
+  }
+}
+
 =item previous
 
 Returns a list consisting of the total previous balance for this customer, 
@@ -235,6 +371,25 @@ sub cust_bill_pkg {
   );
 }
 
+=item cust_bill_pkg_pkgnum PKGNUM
+
+Returns the line items (see L<FS::cust_bill_pkg>) for this invoice and
+specified pkgnum.
+
+=cut
+
+sub cust_bill_pkg_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  qsearch(
+    { 'table'    => 'cust_bill_pkg',
+      'hashref'  => { 'invnum' => $self->invnum,
+                      'pkgnum' => $pkgnum,
+                    },
+      'order_by' => 'ORDER BY billpkgnum',
+    }
+  );
+}
+
 =item cust_pkg
 
 Returns the packages (see L<FS::cust_pkg>) corresponding to the line items for
@@ -244,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.
@@ -407,6 +575,16 @@ sub cust_pay {
   #;
 }
 
+sub cust_pay_batch {
+  my $self = shift;
+  qsearch('cust_pay_batch', { 'invnum' => $self->invnum } );
+}
+
+sub cust_bill_pay_batch {
+  my $self = shift;
+  qsearch('cust_bill_pay_batch', { 'invnum' => $self->invnum } );
+}
+
 =item cust_bill_pay
 
 Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice.
@@ -415,23 +593,71 @@ Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice.
 
 sub cust_bill_pay {
   my $self = shift;
+  map { $_ } #return $self->num_cust_bill_pay unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } );
 }
 
 =item cust_credited
 
+=item cust_credit_bill
+
 Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice.
 
 =cut
 
 sub cust_credited {
   my $self = shift;
+  map { $_ } #return $self->num_cust_credit_bill unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } )
   ;
 }
 
+sub cust_credit_bill {
+  shift->cust_credited(@_);
+}
+
+=item cust_bill_pay_pkgnum PKGNUM
+
+Returns all payment applications (see L<FS::cust_bill_pay>) for this invoice
+with matching pkgnum.
+
+=cut
+
+sub cust_bill_pay_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum,
+                                'pkgnum' => $pkgnum,
+                              }
+           );
+}
+
+=item cust_credited_pkgnum PKGNUM
+
+=item cust_credit_bill_pkgnum PKGNUM
+
+Returns all applied credits (see L<FS::cust_credit_bill>) for this invoice
+with matching pkgnum.
+
+=cut
+
+sub cust_credited_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum,
+                                   'pkgnum' => $pkgnum,
+                                 }
+           );
+}
+
+sub cust_credit_bill_pkgnum {
+  shift->cust_credited_pkgnum(@_);
+}
+
 =item tax
 
 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
@@ -465,12 +691,35 @@ sub owed {
   $balance;
 }
 
-=item apply_payments_and_credits
+sub owed_pkgnum {
+  my( $self, $pkgnum ) = @_;
+
+  #my $balance = $self->charged;
+  my $balance = 0;
+  $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum);
+
+  $balance -= $_->amount            for $self->cust_bill_pay_pkgnum($pkgnum);
+  $balance -= $_->amount            for $self->cust_credited_pkgnum($pkgnum);
+
+  $balance = sprintf( "%.2f", $balance);
+  $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
+  $balance;
+}
+
+=item apply_payments_and_credits [ OPTION => VALUE ... ]
+
+Applies unapplied payments and credits to this invoice.
+
+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';
@@ -488,6 +737,13 @@ sub apply_payments_and_credits {
   my @payments = grep { $_->unapplied > 0 } $self->cust_main->cust_pay;
   my @credits  = grep { $_->credited > 0 } $self->cust_main->cust_credit;
 
+  if ( $conf->exists('pkg-balances') ) {
+    # limit @payments & @credits to those w/ a pkgnum grepped from $self
+    my %pkgnums = map { $_ => 1 } map $_->pkgnum, $self->cust_bill_pkg;
+    @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
+    @credits  = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
+  }
+
   while ( $self->owed > 0 and ( @payments || @credits ) ) {
 
     my $app = '';
@@ -525,31 +781,42 @@ sub apply_payments_and_credits {
       die "guru meditation #12 and 35";
     }
 
+    my $unapp_amount;
     if ( $app eq 'pay' ) {
 
       my $payment = shift @payments;
-
-      $app = new FS::cust_bill_pay {
-        'paynum'  => $payment->paynum,
-       'amount'  => sprintf('%.2f', min( $payment->unapplied, $self->owed ) ),
-      };
+      $unapp_amount = $payment->unapplied;
+      $app = new FS::cust_bill_pay { 'paynum'  => $payment->paynum };
+      $app->pkgnum( $payment->pkgnum )
+        if $conf->exists('pkg-balances') && $payment->pkgnum;
 
     } elsif ( $app eq 'credit' ) {
 
       my $credit = shift @credits;
-
-      $app = new FS::cust_credit_bill {
-        'crednum' => $credit->crednum,
-       'amount'  => sprintf('%.2f', min( $credit->credited, $self->owed ) ),
-      };
+      $unapp_amount = $credit->credited;
+      $app = new FS::cust_credit_bill { 'crednum' => $credit->crednum };
+      $app->pkgnum( $credit->pkgnum )
+        if $conf->exists('pkg-balances') && $credit->pkgnum;
 
     } else {
       die "guru meditation #12 and 35";
     }
 
+    my $owed;
+    if ( $conf->exists('pkg-balances') && $app->pkgnum ) {
+      warn "owed_pkgnum ". $app->pkgnum;
+      $owed = $self->owed_pkgnum($app->pkgnum);
+    } else {
+      $owed = $self->owed;
+    }
+    next unless $owed > 0;
+
+    warn "min ( $unapp_amount, $owed )\n" if $DEBUG;
+    $app->amount( sprintf('%.2f', min( $unapp_amount, $owed ) ) );
+
     $app->invnum( $self->invnum );
 
-    my $error = $app->insert;
+    my $error = $app->insert(%options);
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "Error inserting ". $app->table. " record: $error";
@@ -585,6 +852,10 @@ text attachment arrayref, optional
 
 email subject, optional
 
+=item notice_name
+
+notice name instead of "Invoice", optional
+
 =back
 
 Returns an argument list to be passed to L<FS::Misc::send_email>.
@@ -605,11 +876,19 @@ sub generate_email {
     'subject'   => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
   );
 
+  my %opt = (
+    'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
+    'template'      => $args{'template'},
+    'notice_name'   => ( $args{'notice_name'} || 'Invoice' ),
+  );
+
+  my $cust_main = $self->cust_main;
+
   if (ref($args{'to'}) eq 'ARRAY') {
     $return{'to'} = $args{'to'};
   } else {
     $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
-                           $self->cust_main->invoicing_list
+                           $cust_main->invoicing_list
                     ];
   }
 
@@ -643,7 +922,7 @@ sub generate_email {
       if ( ref($args{'print_text'}) eq 'ARRAY' ) {
         $data = $args{'print_text'};
       } else {
-        $data = [ $self->print_text('', $args{'template'}) ];
+        $data = [ $self->print_text(\%opt) ];
       }
 
     }
@@ -661,7 +940,7 @@ sub generate_email {
     my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
 
     my $logo;
-    my $agentnum = $self->cust_main->agentnum;
+    my $agentnum = $cust_main->agentnum;
     if ( defined($args{'template'}) && length($args{'template'})
          && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
        )
@@ -690,7 +969,7 @@ sub generate_email {
                          '    </title>',
                          '  </head>',
                          '  <body bgcolor="#e8e8e8">',
-                         $self->print_html('', $args{'template'}, $content_id),
+                         $self->print_html({ 'cid'=>$content_id, %opt }),
                          '  </body>',
                          '</html>',
                        ],
@@ -698,6 +977,21 @@ sub generate_email {
       #'Filename'    => 'invoice.pdf',
     );
 
+    my @otherparts = ();
+    if ( $cust_main->email_csv_cdr ) {
+
+      push @otherparts, build MIME::Entity
+        'Type'        => 'text/csv',
+        'Encoding'    => '7bit',
+        'Data'        => [ map { "$_\n" }
+                             $self->call_details('prepend_billed_number' => 1)
+                         ],
+        'Disposition' => 'attachment',
+        'Filename'    => 'usage-'. $self->invnum. '.csv',
+      ;
+
+    }
+
     if ( $conf->exists('invoice_email_pdf') ) {
 
       #attaching pdf too:
@@ -723,9 +1017,9 @@ sub generate_email {
 
       $related->add_part($image);
 
-      my $pdf = build MIME::Entity $self->mimebuild_pdf('', $args{'template'});
+      my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
 
-      $return{'mimeparts'} = [ $related, $pdf ];
+      $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
 
     } else {
 
@@ -737,7 +1031,7 @@ sub generate_email {
       #   image/png
 
       $return{'content-type'} = 'multipart/related';
-      $return{'mimeparts'} = [ $alternative, $image ];
+      $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
       $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
       #$return{'disposition'} = 'inline';
 
@@ -751,7 +1045,7 @@ sub generate_email {
 
       #mime parts arguments a la MIME::Entity->build().
       $return{'mimeparts'} = [
-        { $self->mimebuild_pdf('', $args{'template'}) }
+        { $self->mimebuild_pdf(\%opt) }
       ];
     }
   
@@ -771,7 +1065,7 @@ sub generate_email {
       if ( ref($args{'print_text'}) eq 'ARRAY' ) {
         $return{'body'} = $args{'print_text'};
       } else {
-        $return{'body'} = [ $self->print_text('', $args{'template'}) ];
+        $return{'body'} = [ $self->print_text(\%opt) ];
       }
 
     }
@@ -800,22 +1094,27 @@ sub mimebuild_pdf {
   );
 }
 
-=item send [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
+=item send HASHREF | [ TEMPLATE [ , AGENTNUM [ , INVOICE_FROM [ , AMOUNT ] ] ] ]
 
 Sends this invoice to the destinations configured for this customer: sends
 email, prints and/or faxes.  See L<FS::cust_main_invoice>.
 
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+Options can be passed as a hashref (recommended) or as a list of up to 
+four values for templatename, agentnum, invoice_from and amount.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
 
-AGENTNUM, if specified, means that this invoice will only be sent for customers
+I<agentnum>, if specified, means that this invoice will only be sent for customers
 of the specified agent or agent(s).  AGENTNUM can be a scalar agentnum (for a
 single agent) or an arrayref of agentnums.
 
-INVOICE_FROM, if specified, overrides the default email invoice From: address.
+I<invoice_from>, if specified, overrides the default email invoice From: address.
 
-AMOUNT, if specified, only sends the invoice if the total amount owed on this
+I<amount>, if specified, only sends the invoice if the total amount owed on this
 invoice and all older invoices is greater than the specified amount.
 
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
 =cut
 
 sub queueable_send {
@@ -835,48 +1134,73 @@ sub queueable_send {
 
 sub send {
   my $self = shift;
-  my $template = scalar(@_) ? shift : '';
-  if ( scalar(@_) && $_[0]  ) {
-    my $agentnums = ref($_[0]) ? shift : [ shift ];
-    return 'N/A' unless grep { $_ == $self->cust_main->agentnum } @$agentnums;
-  }
 
-  my $invoice_from =
-    scalar(@_)
-      ? shift
-      : ( $self->_agent_invoice_from ||    #XXX should go away
-          $conf->config('invoice_from', $self->cust_main->agentnum )
-        );
+  my( $template, $invoice_from, $notice_name );
+  my $agentnums = '';
+  my $balance_over = 0;
+
+  if ( ref($_[0]) ) {
+    my $opt = shift;
+    $template = $opt->{'template'} || '';
+    if ( $agentnums = $opt->{'agentnum'} ) {
+      $agentnums = [ $agentnums ] unless ref($agentnums);
+    }
+    $invoice_from = $opt->{'invoice_from'};
+    $balance_over = $opt->{'balance_over'} if $opt->{'balance_over'};
+    $notice_name = $opt->{'notice_name'};
+  } else {
+    $template = scalar(@_) ? shift : '';
+    if ( scalar(@_) && $_[0]  ) {
+      $agentnums = ref($_[0]) ? shift : [ shift ];
+    }
+    $invoice_from = shift if scalar(@_);
+    $balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/;
+  }
 
-  my $balance_over = ( scalar(@_) && $_[0] !~ /^\s*$/ ) ? shift : 0;
+  return 'N/A' unless ! $agentnums
+                   or grep { $_ == $self->cust_main->agentnum } @$agentnums;
 
   return ''
     unless $self->cust_main->total_owed_date($self->_date) > $balance_over;
 
+  $invoice_from ||= $self->_agent_invoice_from ||    #XXX should go away
+                    $conf->config('invoice_from', $self->cust_main->agentnum );
+
+  my %opt = (
+    'template'     => $template,
+    'invoice_from' => $invoice_from,
+    'notice_name'  => ( $notice_name || 'Invoice' ),
+  );
+
   my @invoicing_list = $self->cust_main->invoicing_list;
 
-  #$self->email_invoice($template, $invoice_from)
-  $self->email($template, $invoice_from)
+  #$self->email_invoice(\%opt)
+  $self->email(\%opt)
     if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list;
 
-  #$self->print_invoice($template)
-  $self->print($template)
+  #$self->print_invoice(\%opt)
+  $self->print(\%opt)
     if grep { $_ eq 'POST' } @invoicing_list; #postal
 
-  $self->fax_invoice($template)
+  $self->fax_invoice(\%opt)
     if grep { $_ eq 'FAX' } @invoicing_list; #fax
 
   '';
 
 }
 
-=item email [ TEMPLATENAME  [ , INVOICE_FROM ] ] 
+=item email HASHREF | [ TEMPLATE [ , INVOICE_FROM ] ] 
 
 Emails this invoice.
 
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+Options can be passed as a hashref (recommended) or as a list of up to 
+two values for templatename and invoice_from.
 
-INVOICE_FROM, if specified, overrides the default email invoice From: address.
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<invoice_from>, if specified, overrides the default email invoice From: address.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
 =cut
 
@@ -898,29 +1222,43 @@ sub queueable_email {
 #sub email_invoice {
 sub email {
   my $self = shift;
-  my $template = scalar(@_) ? shift : '';
-  my $invoice_from =
-    scalar(@_)
-      ? shift
-      : ( $self->_agent_invoice_from ||    #XXX should go away
-          $conf->config('invoice_from', $self->cust_main->agentnum )
-        );
 
+  my( $template, $invoice_from, $notice_name );
+  if ( ref($_[0]) ) {
+    my $opt = shift;
+    $template = $opt->{'template'} || '';
+    $invoice_from = $opt->{'invoice_from'};
+    $notice_name = $opt->{'notice_name'} || 'Invoice';
+  } else {
+    $template = scalar(@_) ? shift : '';
+    $invoice_from = shift if scalar(@_);
+    $notice_name = 'Invoice';
+  }
+
+  $invoice_from ||= $self->_agent_invoice_from ||    #XXX should go away
+                    $conf->config('invoice_from', $self->cust_main->agentnum );
 
   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);
 
   my $error = send_email(
     $self->generate_email(
-      'from'       => $invoice_from,
-      'to'         => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
-      'subject'    => $subject,
-      'template'   => $template,
+      'from'        => $invoice_from,
+      'to'          => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
+      'subject'     => $subject,
+      'template'    => $template,
+      'notice_name' => $notice_name,
     )
   );
   die "can't email invoice: $error\n" if $error;
@@ -946,48 +1284,104 @@ sub email_subject {
   eval qq("$subject");
 }
 
-=item lpr_data [ TEMPLATENAME ]
+=item lpr_data HASHREF | [ TEMPLATE ]
 
 Returns the postscript or plaintext for this invoice as an arrayref.
 
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+Options can be passed as a hashref (recommended) or as a single optional value
+for template.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
 =cut
 
 sub lpr_data {
-  my( $self, $template) = @_;
-  $conf->exists('invoice_latex')
-    ? [ $self->print_ps('', $template) ]
-    : [ $self->print_text('', $template) ];
+  my $self = shift;
+  my( $template, $notice_name );
+  if ( ref($_[0]) ) {
+    my $opt = shift;
+    $template = $opt->{'template'} || '';
+    $notice_name = $opt->{'notice_name'} || 'Invoice';
+  } else {
+    $template = scalar(@_) ? shift : '';
+    $notice_name = 'Invoice';
+  }
+
+  my %opt = (
+    'template'    => $template,
+    'notice_name' => $notice_name,
+  );
+
+  my $method = $conf->exists('invoice_latex') ? 'print_ps' : 'print_text';
+  [ $self->$method( \%opt ) ];
 }
 
-=item print [ TEMPLATENAME ]
+=item print HASHREF | [ TEMPLATE ]
 
 Prints this invoice.
 
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+Options can be passed as a hashref (recommended) or as a single optional
+value for template.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
 =cut
 
 #sub print_invoice {
 sub print {
   my $self = shift;
-  my $template = scalar(@_) ? shift : '';
+  my( $template, $notice_name );
+  if ( ref($_[0]) ) {
+    my $opt = shift;
+    $template = $opt->{'template'} || '';
+    $notice_name = $opt->{'notice_name'} || 'Invoice';
+  } else {
+    $template = scalar(@_) ? shift : '';
+    $notice_name = 'Invoice';
+  }
+
+  my %opt = (
+    'template'    => $template,
+    'notice_name' => $notice_name,
+  );
 
-  do_print $self->lpr_data($template);
+  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 [ TEMPLATENAME ] 
+=item fax_invoice HASHREF | [ TEMPLATE ] 
 
 Faxes this invoice.
 
-TEMPLATENAME, if specified, is the name of a suffix for alternate invoices.
+Options can be passed as a hashref (recommended) or as a single optional
+value for template.
+
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
 =cut
 
 sub fax_invoice {
   my $self = shift;
-  my $template = scalar(@_) ? shift : '';
+  my( $template, $notice_name );
+  if ( ref($_[0]) ) {
+    my $opt = shift;
+    $template = $opt->{'template'} || '';
+    $notice_name = $opt->{'notice_name'} || 'Invoice';
+  } else {
+    $template = scalar(@_) ? shift : '';
+    $notice_name = 'Invoice';
+  }
 
   die 'FAX invoice destination not (yet?) supported with plain text invoices.'
     unless $conf->exists('invoice_latex');
@@ -995,13 +1389,35 @@ sub fax_invoice {
   my $dialstring = $self->cust_main->getfield('fax');
   #Check $dialstring?
 
-  my $error = send_fax( 'docdata'    => $self->lpr_data($template),
+  my %opt = (
+    'template'    => $template,
+    'notice_name' => $notice_name,
+  );
+
+  my $error = send_fax( 'docdata'    => $self->lpr_data(\%opt),
                         'dialstring' => $dialstring,
                       );
   die $error if $error;
 
 }
 
+=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.
@@ -1457,11 +1873,9 @@ sub print_csv {
   
       } else { #pkgnum tax
         next unless $cust_bill_pkg->setup != 0;
-        my $itemdesc = defined $cust_bill_pkg->dbdef_table->column('itemdesc')
-                         ? ( $cust_bill_pkg->itemdesc || 'Tax' )
-                         : 'Tax';
-        ($pkg, $setup, $recur, $sdate, $edate) =
-          ( $itemdesc, sprintf("%10.2f",$cust_bill_pkg->setup), '', '', '' );
+        $pkg = $cust_bill_pkg->desc;
+        $setup = sprintf('%10.2f', $cust_bill_pkg->setup );
+        ( $sdate, $edate ) = ( '', '' );
       }
   
       $csv->combine(
@@ -1544,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;
@@ -1570,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...
   );
 
 }
@@ -1601,28 +2025,45 @@ sub _agent_invoice_from {
   $self->cust_main->agent_invoice_from;
 }
 
-=item print_text [ TIME [ , TEMPLATE ] ]
+=item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
 
 Returns an text invoice, as a list of lines.
 
-TIME an optional value used to control the printing of overdue messages.  The
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time>, if specified, is used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
 =cut
 
 sub print_text {
-  my( $self, $today, $template ) = @_;
+  my $self = shift;
+  my( $today, $template, %opt );
+  if ( ref($_[0]) ) {
+    %opt = %{ shift() };
+    $today = delete($opt{'time'}) || '';
+    $template = delete($opt{template}) || '';
+  } else {
+    ( $today, $template, %opt ) = @_;
+  }
 
   my %params = ( 'format' => 'template' );
   $params{'time'} = $today if $today;
   $params{'template'} = $template if $template;
+  $params{$_} = $opt{$_} 
+    foreach grep $opt{$_}, qw( unsquealch_cdr notice_name );
 
   $self->print_generic( %params );
 }
 
-=item print_latex [ TIME [ , TEMPLATE ] ]
+=item print_latex HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
 
 Internal method - returns a filename of a filled-in LaTeX template for this
 invoice (Note: add ".tex" to get the actual filename), and a filename of
@@ -1630,19 +2071,36 @@ an associated logo (with the .eps extension included).
 
 See print_ps and print_pdf for methods that return PostScript and PDF output.
 
-TIME an optional value used to control the printing of overdue messages.  The
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time>, if specified, is used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
 =cut
 
 sub print_latex {
-  my( $self, $today, $template ) = @_;
+  my $self = shift;
+  my( $today, $template, %opt );
+  if ( ref($_[0]) ) {
+    %opt = %{ shift() };
+    $today = delete($opt{'time'}) || '';
+    $template = delete($opt{template}) || '';
+  } else {
+    ( $today, $template, %opt ) = @_;
+  }
 
   my %params = ( 'format' => 'latex' );
   $params{'time'} = $today if $today;
   $params{'template'} = $template if $template;
+  $params{$_} = $opt{$_} 
+    foreach grep $opt{$_}, qw( unsquealch_cdr notice_name );
 
   $template ||= $self->_agent_template;
 
@@ -1680,7 +2138,7 @@ sub print_latex {
 
 }
 
-=item print_generic OPTIONS_HASH
+=item print_generic OPTION => VALUE ...
 
 Internal method - returns a filled-in template for this invoice as a scalar.
 
@@ -1702,15 +2160,18 @@ cid -
 
 unsquelch_cdr - overrides any per customer cdr squelching when true
 
+notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
 =cut
 
 #what's with all the sprintf('%10.2f')'s in here?  will it cause any
-# (alignment?) problems to change them all to '%.2f' ?
+# (alignment in text invoice?) problems to change them all to '%.2f' ?
+# yes: fixed width (dot matrix) text printing will be borked
 sub print_generic {
 
   my( $self, %params ) = @_;
   my $today = $params{today} ? $params{today} : time;
-  warn "FS::cust_bill::print_generic called on $self with suffix $params{template}\n"
+  warn "$me print_generic called on $self with suffix $params{template}\n"
     if $DEBUG;
 
   my $format = $params{format};
@@ -1762,6 +2223,7 @@ sub print_generic {
                  'smallfooter'   => sub { map "$_", @_ },
                  'returnaddress' => sub { map "$_", @_ },
                  'coupon'        => sub { map "$_", @_ },
+                 'summary'       => sub { map "$_", @_ },
                },
     'html'  => {
                  'notes' =>
@@ -1795,6 +2257,7 @@ sub print_generic {
                      }  @_
                    },
                  'coupon'        => sub { "" },
+                 'summary'       => sub { "" },
                },
     'template' => {
                  'notes' =>
@@ -1825,6 +2288,7 @@ sub print_generic {
                      }  @_
                    },
                  'coupon'        => sub { "" },
+                 'summary'       => sub { "" },
                },
   );
 
@@ -1837,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(). '}'
@@ -1909,38 +2377,71 @@ sub print_generic {
 
   }
 
+  my $agentnum = $self->cust_main->agentnum;
+
   my %invoice_data = (
-    'company_name'    => scalar( $conf->config('company_name', $self->cust_main->agentnum) ),
-    'company_address' => join("\n", $conf->config('company_address', $self->cust_main->agentnum) ). "\n",
-    'custnum'         => $cust_main->display_custnum,
+
+    #invoice from info
+    '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),
-    'agent'           => &$escape_function($cust_main->agent->agent),
-    'agent_custid'    => &$escape_function($cust_main->agent_custid),
-    'payname'         => &$escape_function($cust_main->payname),
-    'company'         => &$escape_function($cust_main->company),
-    'address1'        => &$escape_function($cust_main->address1),
-    'address2'        => &$escape_function($cust_main->address2),
-    'city'            => &$escape_function($cust_main->city),
-    'state'           => &$escape_function($cust_main->state),
-    'zip'             => &$escape_function($cust_main->zip),
-    'fax'             => &$escape_function($cust_main->fax),
-    'returnaddress'   => $returnaddress,
-    #'quantity'        => 1,
+    'today'           => time2str($date_format_long, $today),
     'terms'           => $self->terms,
     'template'        => $template, #params{'template'},
-    #'notes'           => join("\n", $conf->config('invoice_latexnotes') ),
-    # better hang on to conf_dir for a while
-    'conf_dir'        => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
-    'page'            => 1,
-    'total_pages'     => 1,
+    '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,
+    'agent_custid'    => &$escape_function($cust_main->agent_custid),
+    ( map { $_ => &$escape_function($cust_main->$_()) } qw(
+      payname company address1 address2 city state zip fax
+    )),
+
+    #global config
     'ship_enable'     => $conf->exists('invoice-ship_address'),
     '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",
+
+    #these are only used when doing paged plaintext
+    'page'            => 1,
+    'total_pages'     => 1,
+
   );
 
+  $invoice_data{finance_section} = '';
+  if ( $conf->config('finance_pkgclass') ) {
+    my $pkg_class =
+      qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
+    $invoice_data{finance_section} = $pkg_class->categoryname;
+  } 
+  $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_' : '';
   foreach ( qw( contact company address1 address2 city state zip country fax) ){
@@ -1987,10 +2488,16 @@ sub print_generic {
 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
   #my $balance_due = $self->owed + $pr_total - $cr_total;
   my $balance_due = $self->owed + $pr_total;
+  $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
+  $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
   $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;
+  }
+  $invoice_data{'summarypage'} = $summarypage;
 
   #do variable substitution in notes, footer, smallfooter
   foreach my $include (qw( notes footer smallfooter coupon )) {
@@ -2051,6 +2558,7 @@ sub print_generic {
                             'template' => '',
                           );
   my $other_money_char = $other_money_chars{$format};
+  $invoice_data{'dollar'} = $other_money_char;
 
   my @detail_items = ();
   my @total_items = ();
@@ -2061,25 +2569,68 @@ sub print_generic {
   $invoice_data{'total_items'} = \@total_items;
   $invoice_data{'buf'} = \@buf;
   $invoice_data{'sections'} = \@sections;
-  
+
   my $previous_section = { 'description' => 'Previous Charges',
                            'subtotal'    => $other_money_char.
                                             sprintf('%.2f', $pr_total),
+                           'summarized'  => $summarypage ? 'Y' : '',
                          };
+  $previous_section->{posttotal} = '0 / 30 / 60/ 90 days overdue '. 
+    join(' / ', map { $cust_main->balance_date_range(@$_) }
+                $self->_prior_month30s
+        )
+    if $conf->exists('invoice_include_aging');
 
   my $taxtotal = 0;
   my $tax_section = { 'description' => 'Taxes, Surcharges, and Fees',
-                      'subtotal'    => $taxtotal }; # adjusted below
+                      'subtotal'    => $taxtotal,   # adjusted below
+                      'summarized'  => $summarypage ? 'Y' : '',
+                    };
+  my $tax_weight = _pkg_category($tax_section->{description})
+                        ? _pkg_category($tax_section->{description})->weight
+                        : 0;
+  $tax_section->{'summarized'} = $summarypage && !$tax_weight ? 'Y' : '';
+  $tax_section->{'sort_weight'} = $tax_weight;
+
 
   my $adjusttotal = 0;
   my $adjust_section = { 'description' => 'Credits, Payments, and Adjustments',
-                         'subtotal'    => 0 }; # adjusted below
+                         'subtotal'    => 0,   # adjusted below
+                         'summarized'  => $summarypage ? 'Y' : '',
+                       };
+  my $adjust_weight = _pkg_category($adjust_section->{description})
+                        ? _pkg_category($adjust_section->{description})->weight
+                        : 0;
+  $adjust_section->{'summarized'} = $summarypage && !$adjust_weight ? 'Y' : '';
+  $adjust_section->{'sort_weight'} = $adjust_weight;
 
   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 ) {
-    push @sections, $self->_items_sections( $late_sections );
+    ($extra_sections, $extra_lines) =
+      $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};
+
+    push @detail_items, @$extra_lines if $extra_lines;
+    push @sections,
+      $self->_items_sections( $late_sections,      # this could stand a refactor
+                              $summarypage,
+                              $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_nonbsp, $format);
+      push @{$late_sections}, @$phone_sections;
+      push @detail_items, @$phone_lines;
+    }
   }else{
     push @sections, { 'description' => '', 'subtotal' => '' };
   }
@@ -2121,25 +2672,56 @@ 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} );
+
     $section->{'subtotal'} = $other_money_char.
                              sprintf('%.2f', $section->{'subtotal'})
       if $multisection;
 
+    # continue some normalization
+    $section->{'amount'}   = $section->{'subtotal'}
+      if $multisection;
+
+
     if ( $section->{'description'} ) {
       push @buf, ( [ &$escape_function($section->{'description'}), '' ],
                    [ '', '' ],
                  );
     }
 
+    my $multilocation = scalar($cust_main->cust_location); #too expensive?
     my %options = ();
     $options{'section'} = $section if $multisection;
     $options{'format'} = $format;
     $options{'escape_function'} = $escape_function;
     $options{'format_function'} = sub { () } unless $unsquelched;
     $options{'unsquelched'} = $unsquelched;
+    $options{'summary_page'} = $summarypage;
+    $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 = {
@@ -2178,7 +2760,12 @@ sub print_generic {
   
   }
   
-  if ( $multisection && !$conf->exists('disable_previous_balance') ) {
+  $invoice_data{current_less_finance} =
+    sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
+
+  if ( $multisection && !$conf->exists('disable_previous_balance')
+    || $conf->exists('previous_balance-summary_only') )
+  {
     unshift @sections, $previous_section if $pr_total;
   }
 
@@ -2234,7 +2821,7 @@ sub print_generic {
     }
   }
   $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
-  
+
   push @buf,['','-----------'];
   push @buf,[( $conf->exists('disable_previous_balance') 
                ? 'Total Charges'
@@ -2245,32 +2832,34 @@ 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 ) {
-      $adjust_section->{'pretotal'} = 'New charges total '. $other_money_char.
-                                      sprintf('%.2f', $self->charged );
+      if ( $adjust_section->{'sort_weight'} ) {
+        $adjust_section->{'posttotal'} = 'Balance Forward '. $other_money_char.
+          sprintf("%.2f", ($self->billing_balance || 0) );
+      } else {
+        $adjust_section->{'pretotal'} = 'New charges total '. $other_money_char.
+                                        sprintf('%.2f', $self->charged );
+      } 
     }else{
       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,['',''];
   }
@@ -2309,7 +2898,7 @@ sub print_generic {
     foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
       push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
     }
-  
+
     # payments
     my $paymenttotal = 0;
     foreach my $payment ( $self->_items_payments ) {
@@ -2341,7 +2930,8 @@ sub print_generic {
     if ( $multisection ) {
       $adjust_section->{'subtotal'} = $other_money_char.
                                       sprintf('%.2f', $adjusttotal);
-      push @sections, $adjust_section;
+      push @sections, $adjust_section
+        unless $adjust_section->{sort_weight};
     }
 
     { 
@@ -2349,9 +2939,13 @@ sub print_generic {
       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
       $total->{'total_amount'} =
         &$embolden_function(
-          $other_money_char. sprintf('%.2f', $self->owed + $pr_total )
+          $other_money_char. sprintf('%.2f', $summarypage 
+                                               ? $self->charged +
+                                                 $self->billing_balance
+                                               : $self->owed + $pr_total
+                                    )
         );
-      if ( $multisection ) {
+      if ( $multisection && !$adjust_section->{sort_weight} ) {
         $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
                                          $total->{'total_amount'};
       }else{
@@ -2364,10 +2958,65 @@ sub print_generic {
   }
 
   if ( $multisection ) {
+    if ($conf->exists('svc_phone_sections')) {
+      my $total;
+      $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
+      $total->{'total_amount'} =
+        &$embolden_function(
+          $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
+        );
+      my $last_section = pop @sections;
+      $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
+                                     $total->{'total_amount'};
+      push @sections, $last_section;
+    }
     push @sections, @$late_sections
       if $unsquelched;
   }
 
+  my @includelist = ();
+  push @includelist, 'summary' if $summarypage;
+  foreach my $include ( @includelist ) {
+
+    my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
+    my @inc_src;
+
+    if ( length( $conf->config($inc_file, $agentnum) ) ) {
+
+      @inc_src = $conf->config($inc_file, $agentnum);
+
+    } else {
+
+      $inc_file = $conf->key_orbase("invoice_latex$include", $template);
+
+      my $convert_map = $convert_maps{$format}{$include};
+
+      @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
+                       s/--\@\]/$delimiters{$format}[1]/g;
+                       $_;
+                     } 
+                 &$convert_map( $conf->config($inc_file, $agentnum) );
+
+    }
+
+    my $inc_tt = new Text::Template (
+      TYPE       => 'ARRAY',
+      SOURCE     => [ map "$_\n", @inc_src ],
+      DELIMITERS => $delimiters{$format},
+    ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
+
+    unless ( $inc_tt->compile() ) {
+      my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
+      warn $error. "Template:\n". join('', map "$_\n", @inc_src);
+      die $error;
+    }
+
+    $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
+
+    $invoice_data{$include} =~ s/\n+$//
+      if ($format eq 'latex');
+  }
+
   $invoice_lines = 0;
   my $wasfunc = 0;
   foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
@@ -2418,15 +3067,36 @@ sub print_generic {
   }
 }
 
-=item print_ps [ TIME [ , TEMPLATE ] ]
+# helper routine for generating date ranges
+sub _prior_month30s {
+  my $self = shift;
+  my @ranges = (
+   [ 1,       2592000 ], # 0-30 days ago
+   [ 2592000, 5184000 ], # 30-60 days ago
+   [ 5184000, 7776000 ], # 60-90 days ago
+   [ 7776000, 0       ], # 90+   days ago
+  );
+
+  map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
+          $_->[1] ? $self->_date - $_->[1] - 1 : '',
+      ] }
+  @ranges;
+}
+
+=item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
 
 Returns an postscript invoice, as a scalar.
 
-TIME an optional value used to control the printing of overdue messages.  The
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time> an optional value used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
 =cut
 
 sub print_ps {
@@ -2439,15 +3109,22 @@ sub print_ps {
   $ps;
 }
 
-=item print_pdf [ TIME [ , TEMPLATE ] ]
+=item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
 
 Returns an PDF invoice, as a scalar.
 
-TIME an optional value used to control the printing of overdue messages.  The
+Options can be passed as a hashref (recommended) or as a list of time, template
+and then any key/value pairs for any other options.
+
+I<time> an optional value used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
 =cut
 
 sub print_pdf {
@@ -2460,16 +3137,20 @@ sub print_pdf {
   $pdf;
 }
 
-=item print_html [ TIME [ , TEMPLATE [ , CID ] ] ]
+=item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
 
 Returns an HTML invoice, as a scalar.
 
-TIME an optional value used to control the printing of overdue messages.  The
+I<time> an optional value used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
-CID is a MIME Content-ID used to create a "cid:" URL for the logo image, used
+I<template>, if specified, is the name of a suffix for alternate invoices.
+
+I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+
+I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
 when emailing the invoice as part of a multipart/related MIME email.
 
 =cut
@@ -2477,7 +3158,7 @@ when emailing the invoice as part of a multipart/related MIME email.
 sub print_html {
   my $self = shift;
   my %params;
-  if ( ref $_[0]  ) {
+  if ( ref($_[0]) ) {
     %params = %{ shift() }; 
   }else{
     $params{'time'} = shift;
@@ -2506,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 {
@@ -2535,10 +3228,10 @@ sub _translate_old_latex_format {
         $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
         push @template, "    \$OUT .= '$line_item_line';";
       }
-  
+
       push @template, '}',
                       '--@]';
-
+      #' doh, gvim
     } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
 
       push @template, '[@--',
@@ -2572,14 +3265,15 @@ sub _translate_old_latex_format {
 sub terms {
   my $self = shift;
 
-  #check for an invoice- specific override (eventually)
+  #check for an invoice-specific override
+  return $self->invoice_terms if $self->invoice_terms;
   
   #check for a customer- specific override
-  return $self->cust_main->invoice_terms
-    if $self->cust_main->invoice_terms;
+  my $cust_main = $self->cust_main;
+  return $cust_main->invoice_terms if $cust_main->invoice_terms;
 
   #use configured default
-  $conf->config('invoice_default_terms');
+  $conf->config('invoice_default_terms') || '';
 }
 
 sub due_date {
@@ -2601,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;
   }
@@ -2613,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;
 }
@@ -2638,80 +3332,701 @@ 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);
 sub _items_sections {
   my $self = shift;
   my $late = shift;
+  my $summarypage = shift;
+  my $escape = shift;
+  my $extra_sections = shift;
+  my $format = shift;
 
-  my %s = ();
-  my %l = ();
+  my %subtotal = ();
+  my %late_subtotal = ();
+  my %not_tax = ();
 
   foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
   {
 
-    if ( $cust_bill_pkg->pkgnum > 0 ) {
       my $usage = $cust_bill_pkg->usage;
 
       foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
-        my $desc = $display->section;
-        my $type = $display->type;
+        next if ( $display->summary && $summarypage );
+
+        my $section = $display->section;
+        my $type    = $display->type;
 
-        if ( $display->post_total ) {
+        $not_tax{$section} = 1
+          unless $cust_bill_pkg->pkgnum == 0;
+
+        if ( $display->post_total && !$summarypage ) {
           if (! $type || $type eq 'S') {
-            $l{$desc} += $cust_bill_pkg->setup
-              if ( $cust_bill_pkg->setup != 0 );
+            $late_subtotal{$section} += $cust_bill_pkg->setup
+              if $cust_bill_pkg->setup != 0;
           }
 
           if (! $type) {
-            $l{$desc} += $cust_bill_pkg->recur
-              if ( $cust_bill_pkg->recur != 0 );
+            $late_subtotal{$section} += $cust_bill_pkg->recur
+              if $cust_bill_pkg->recur != 0;
           }
 
           if ($type && $type eq 'R') {
-            $l{$desc} += $cust_bill_pkg->recur - $usage
-              if ( $cust_bill_pkg->recur != 0 );
+            $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
+              if $cust_bill_pkg->recur != 0;
           }
           
           if ($type && $type eq 'U') {
-            $l{$desc} += $usage;
+            $late_subtotal{$section} += $usage
+              unless scalar(@$extra_sections);
           }
 
         } else {
+
+          next if $cust_bill_pkg->pkgnum == 0 && ! $section;
+
           if (! $type || $type eq 'S') {
-            $s{$desc} += $cust_bill_pkg->setup
-              if ( $cust_bill_pkg->setup != 0 );
+            $subtotal{$section} += $cust_bill_pkg->setup
+              if $cust_bill_pkg->setup != 0;
           }
 
           if (! $type) {
-            $s{$desc} += $cust_bill_pkg->recur
-              if ( $cust_bill_pkg->recur != 0 );
+            $subtotal{$section} += $cust_bill_pkg->recur
+              if $cust_bill_pkg->recur != 0;
           }
 
           if ($type && $type eq 'R') {
-            $s{$desc} += $cust_bill_pkg->recur - $usage
-              if ( $cust_bill_pkg->recur != 0 );
+            $subtotal{$section} += $cust_bill_pkg->recur - $usage
+              if $cust_bill_pkg->recur != 0;
           }
           
           if ($type && $type eq 'U') {
-            $s{$desc} += $usage;
+            $subtotal{$section} += $usage
+              unless scalar(@$extra_sections);
           }
 
         }
 
       }
 
+  }
+
+  %pkg_category_cache = ();
+
+  push @$late, map { { 'description' => &{$escape}($_),
+                       'subtotal'    => $late_subtotal{$_},
+                       'post_total'  => 1,
+                       'sort_weight' => ( _pkg_category($_)
+                                            ? _pkg_category($_)->weight
+                                            : 0
+                                       ),
+                       ((_pkg_category($_) && _pkg_category($_)->condense)
+                                           ? $self->_condense_section($format)
+                                           : ()
+                       ),
+                   } }
+                 sort _sectionsort keys %late_subtotal;
+
+  my @sections;
+  if ( $summarypage ) {
+    @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
+                map { $_->categoryname } qsearch('pkg_category', {});
+    push @sections, '' if exists($subtotal{''});
+  } else {
+    @sections = keys %subtotal;
+  }
+
+  my @early = map { { 'description' => &{$escape}($_),
+                      'subtotal'    => $subtotal{$_},
+                      'summarized'  => $not_tax{$_} ? '' : 'Y',
+                      'tax_section' => $not_tax{$_} ? '' : 'Y',
+                      'sort_weight' => ( _pkg_category($_)
+                                           ? _pkg_category($_)->weight
+                                           : 0
+                                       ),
+                       ((_pkg_category($_) && _pkg_category($_)->condense)
+                                           ? $self->_condense_section($format)
+                                           : ()
+                       ),
+                    }
+                  } @sections;
+  push @early, @$extra_sections if $extra_sections;
+  sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
+
+}
+
+#helper subs for above
+
+sub _sectionsort {
+  _pkg_category($a)->weight <=> _pkg_category($b)->weight;
+}
+
+sub _pkg_category {
+  my $categoryname = shift;
+  $pkg_category_cache{$categoryname} ||=
+    qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
+}
+
+my %condensed_format = (
+  'label' => [ qw( Description Qty Amount ) ],
+  'fields' => [
+                sub { shift->{description} },
+                sub { shift->{quantity} },
+                sub { my($href, %opt) = @_;
+                      ($opt{dollar} || ''). $href->{amount};
+                    },
+              ],
+  'align'  => [ qw( l r r ) ],
+  'span'   => [ qw( 5 1 1 ) ],            # unitprices?
+  'width'  => [ qw( 10.7cm 1.4cm 1.6cm ) ],   # don't like this
+);
+
+sub _condense_section {
+  my ( $self, $format ) = ( shift, shift );
+  ( 'condensed' => 1,
+    map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
+      qw( description_generator
+          header_generator
+          total_generator
+          total_line_generator
+        )
+  );
+}
+
+sub _condensed_generator_defaults {
+  my ( $self, $format ) = ( shift, shift );
+  return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
+}
+
+my %html_align = (
+  'c' => 'center',
+  'l' => 'left',
+  'r' => 'right',
+);
+
+sub _condensed_header_generator {
+  my ( $self, $format ) = ( shift, shift );
+
+  my ( $f, $prefix, $suffix, $separator, $column ) =
+    _condensed_generator_defaults($format);
+
+  if ($format eq 'latex') {
+    $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
+    $suffix = "\\\\\n\\hline";
+    $separator = "&\n";
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
+          };
+  } elsif ( $format eq 'html' ) {
+    $prefix = '<th></th>';
+    $suffix = '';
+    $separator = '';
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return qq!<th align="$html_align{$a}">$d</th>!;
+      };
+  }
+
+  sub {
+    my @args = @_;
+    my @result = ();
+
+    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
+      push @result,
+        &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
     }
 
+    $prefix. join($separator, @result). $suffix;
+  };
+
+}
+
+sub _condensed_description_generator {
+  my ( $self, $format ) = ( shift, shift );
+
+  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 = '\\\\';
+    $separator = " & \n";
+    $column =
+      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 = '';
+    $separator = '';
+    $column =
+      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
   }
 
-  push @$late, map { { 'description' => $_,
-                       'subtotal'    => $l{$_},
-                       'post_total'  => 1,
-                   } } sort keys %l;
+  sub {
+    #my @args = @_;
+    my $href = shift;
+    my @result = ();
+
+    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
+      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;
+  };
 
-  map { {'description' => $_, 'subtotal' => $s{$_}} } sort keys %s;
+}
+
+sub _condensed_total_generator {
+  my ( $self, $format ) = ( shift, shift );
+
+  my ( $f, $prefix, $suffix, $separator, $column ) =
+    _condensed_generator_defaults($format);
+  my $style = '';
+
+  if ($format eq 'latex') {
+    $prefix = "& ";
+    $suffix = "\\\\\n";
+    $separator = " & \n";
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+          };
+  }elsif ( $format eq 'html' ) {
+    $prefix = '';
+    $suffix = '';
+    $separator = '';
+    $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+      };
+  }
+
+
+  sub {
+    my @args = @_;
+    my @result = ();
+
+    #  my $r = &{$f->{fields}->[$i]}(@args);
+    #  $r .= ' Total' unless $i;
+
+    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
+      push @result,
+        &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
+                    map { $f->{$_}->[$i] } qw(align span width)
+                  );
+    }
+
+    $prefix. join( $separator, @result ). $suffix;
+  };
+
+}
+
+=item total_line_generator FORMAT
+
+Returns a coderef used for generation of invoice total line items for this
+usage_class.  FORMAT is either html or latex
+
+=cut
+
+# should not be used: will have issues with hash element names (description vs
+# total_item and amount vs total_amount -- another array of functions?
+
+sub _condensed_total_line_generator {
+  my ( $self, $format ) = ( shift, shift );
+
+  my ( $f, $prefix, $suffix, $separator, $column ) =
+    _condensed_generator_defaults($format);
+  my $style = '';
+
+  if ($format eq 'latex') {
+    $prefix = "& ";
+    $suffix = "\\\\\n";
+    $separator = " & \n";
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
+          };
+  }elsif ( $format eq 'html' ) {
+    $prefix = '';
+    $suffix = '';
+    $separator = '';
+    $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
+    $column =
+      sub { my ($d,$a,$s,$w) = @_;
+            return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
+      };
+  }
+
+
+  sub {
+    my @args = @_;
+    my @result = ();
+
+    foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
+      push @result,
+        &{$column}( &{$f->{fields}->[$i]}(@args),
+                    map { $f->{$_}->[$i] } qw(align span width)
+                  );
+    }
+
+    $prefix. join( $separator, @result ). $suffix;
+  };
+
+}
+
+#sub _items_extra_usage_sections {
+#  my $self = shift;
+#  my $escape = shift;
+#
+#  my %sections = ();
+#
+#  my %usage_class =  map{ $_->classname, $_ } qsearch('usage_class', {});
+#  foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
+#  {
+#    next unless $cust_bill_pkg->pkgnum > 0;
+#
+#    foreach my $section ( keys %usage_class ) {
+#
+#      my $usage = $cust_bill_pkg->usage($section);
+#
+#      next unless $usage && $usage > 0;
+#
+#      $sections{$section} ||= 0;
+#      $sections{$section} += $usage;
+#
+#    }
+#
+#  }
+#
+#  map { { 'description' => &{$escape}($_),
+#          'subtotal'    => $sections{$_},
+#          'summarized'  => '',
+#          'tax_section' => '',
+#        }
+#      }
+#    sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections;
+#
+#}
+
+sub _items_extra_usage_sections {
+  my $self = shift;
+  my $escape = shift;
+  my $format = shift;
+
+  my %sections = ();
+  my %classnums = ();
+  my %lines = ();
+
+  my %usage_class =  map { $_->classnum => $_ } qsearch( 'usage_class', {} );
+  foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+    next unless $cust_bill_pkg->pkgnum > 0;
+
+    foreach my $classnum ( keys %usage_class ) {
+      my $section = $usage_class{$classnum}->classname;
+      $classnums{$section} = $classnum;
+
+      foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) {
+        my $amount = $detail->amount;
+        next unless $amount && $amount > 0;
+        $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 };
+        $sections{$section}{amount} += $amount;  #subtotal
+        $sections{$section}{calls}++;
+        $sections{$section}{duration} += $detail->duration;
+
+        my $desc = $detail->regionname; 
+        my $description = $desc;
+        $description = substr($desc, 0, 50). '...'
+          if $format eq 'latex' && length($desc) > 50;
+
+        $lines{$section}{$desc} ||= {
+          description     => &{$escape}($description),
+          #pkgpart         => $part_pkg->pkgpart,
+          pkgnum          => $cust_bill_pkg->pkgnum,
+          ref             => '',
+          amount          => 0,
+          calls           => 0,
+          duration        => 0,
+          #unit_amount     => $cust_bill_pkg->unitrecur,
+          quantity        => $cust_bill_pkg->quantity,
+          product_code    => 'N/A',
+          ext_description => [],
+        };
+
+        $lines{$section}{$desc}{amount} += $amount;
+        $lines{$section}{$desc}{calls}++;
+        $lines{$section}{$desc}{duration} += $detail->duration;
+
+      }
+    }
+  }
+
+  my %sectionmap = ();
+  foreach (keys %sections) {
+    my $usage_class = $usage_class{$classnums{$_}};
+    $sectionmap{$_} = { 'description' => &{$escape}($_),
+                        'amount'    => $sections{$_}{amount},    #subtotal
+                        'calls'       => $sections{$_}{calls},
+                        'duration'    => $sections{$_}{duration},
+                        'summarized'  => '',
+                        'tax_section' => '',
+                        'sort_weight' => $usage_class->weight,
+                        ( $usage_class->format
+                          ? ( map { $_ => $usage_class->$_($format) }
+                              qw( description_generator header_generator total_generator total_line_generator )
+                            )
+                          : ()
+                        ), 
+                      };
+  }
+
+  my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} }
+                 values %sectionmap;
+
+  my @lines = ();
+  foreach my $section ( keys %lines ) {
+    foreach my $line ( keys %{$lines{$section}} ) {
+      my $l = $lines{$section}{$line};
+      $l->{section}     = $sectionmap{$section};
+      $l->{amount}      = sprintf( "%.2f", $l->{amount} );
+      #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
+      push @lines, $l;
+    }
+  }
+
+  return(\@sections, \@lines);
+
+}
+
+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;
+  my $format = shift;
+
+  my %sections = ();
+  my %classnums = ();
+  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;
+      next unless $phonenum;
+
+      my $amount = $detail->amount;
+      next unless $amount && $amount > 0;
+
+      $sections{$phonenum} ||= { 'amount'      => 0,
+                                 'calls'       => 0,
+                                 'duration'    => 0,
+                                 'sort_weight' => -1,
+                                 'phonenum'    => $phonenum,
+                                };
+      $sections{$phonenum}{amount} += $amount;  #subtotal
+      $sections{$phonenum}{calls}++;
+      $sections{$phonenum}{duration} += $detail->duration;
+
+      my $desc = $detail->regionname; 
+      my $description = $desc;
+      $description = substr($desc, 0, 50). '...'
+        if $format eq 'latex' && length($desc) > 50;
+
+      $lines{$phonenum}{$desc} ||= {
+        description     => &{$escape}($description),
+        #pkgpart         => $part_pkg->pkgpart,
+        pkgnum          => '',
+        ref             => '',
+        amount          => 0,
+        calls           => 0,
+        duration        => 0,
+        #unit_amount     => '',
+        quantity        => '',
+        product_code    => 'N/A',
+        ext_description => [],
+      };
+
+      $lines{$phonenum}{$desc}{amount} += $amount;
+      $lines{$phonenum}{$desc}{calls}++;
+      $lines{$phonenum}{$desc}{duration} += $detail->duration;
+
+      my $line = $usage_class{$detail->classnum}->classname;
+      $sections{"$phonenum $line"} ||=
+        { 'amount' => 0,
+          'calls' => 0,
+          'duration' => 0,
+          'sort_weight' => $usage_class{$detail->classnum}->weight,
+          'phonenum' => $phonenum,
+          'header'  => [ @header ],
+        };
+      $sections{"$phonenum $line"}{amount} += $amount;  #subtotal
+      $sections{"$phonenum $line"}{calls}++;
+      $sections{"$phonenum $line"}{duration} += $detail->duration;
+
+      $lines{"$phonenum $line"}{$desc} ||= {
+        description     => &{$escape}($description),
+        #pkgpart         => $part_pkg->pkgpart,
+        pkgnum          => '',
+        ref             => '',
+        amount          => 0,
+        calls           => 0,
+        duration        => 0,
+        #unit_amount     => '',
+        quantity        => '',
+        product_code    => 'N/A',
+        ext_description => [],
+      };
+
+      $lines{"$phonenum $line"}{$desc}{amount} += $amount;
+      $lines{"$phonenum $line"}{$desc}{calls}++;
+      $lines{"$phonenum $line"}{$desc}{duration} += $detail->duration;
+      push @{$lines{"$phonenum $line"}{$desc}{ext_description}},
+           $detail->formatted('format' => $format);
+
+    }
+  }
+
+  my %sectionmap = ();
+  my $simple = new FS::usage_class { format => '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},
+                        'duration'    => $sections{$_}{duration},
+                        'summarized'  => '',
+                        'tax_section' => '',
+                        'phonenum'    => $sections{$_}{phonenum},
+                        'sort_weight' => $sections{$_}{sort_weight},
+                        'post_total'  => $summary, #inspire pagebreak
+                        (
+                          ( map { $_ => $usage_class->$_($format, %gen_opt) }
+                            qw( description_generator
+                                header_generator
+                                total_generator
+                                total_line_generator
+                              )
+                          )
+                        ), 
+                      };
+  }
+
+  my @sections = sort { $a->{phonenum} cmp $b->{phonenum} ||
+                        $a->{sort_weight} <=> $b->{sort_weight}
+                      }
+                 values %sectionmap;
+
+  my @lines = ();
+  foreach my $section ( keys %lines ) {
+    foreach my $line ( keys %{$lines{$section}} ) {
+      my $l = $lines{$section}{$line};
+      $l->{section}     = $sectionmap{$section};
+      $l->{amount}      = sprintf( "%.2f", $l->{amount} );
+      #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} );
+      push @lines, $l;
+    }
+  }
+
+  return(\@sections, \@lines);
 
 }
 
@@ -2738,9 +4053,11 @@ sub _items_previous {
   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
   my @b = ();
   foreach ( @pr_cust_bill ) {
+    my $date = $conf->exists('invoice_show_prior_due_date')
+               ? 'due '. $_->due_date2str($date_format)
+               : time2str($date_format, $_->_date);
     push @b, {
-      'description' => 'Previous Balance, Invoice #'. $_->invnum. 
-                       ' ('. time2str('%x',$_->_date). ')',
+      'description' => 'Previous Balance, Invoice #'. $_->invnum. " ($date)",
       #'pkgpart'     => 'N/A',
       'pkgnum'      => 'N/A',
       'amount'      => sprintf("%.2f", $_->owed),
@@ -2764,17 +4081,39 @@ sub _items_previous {
 
 sub _items_pkg {
   my $self = shift;
+  my %options = @_;
   my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
-  $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+  my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
+  if ($options{section} && $options{section}->{condensed}) {
+    my %itemshash = ();
+    local $Storable::canonical = 1;
+    foreach ( @items ) {
+      my $item = { %$_ };
+      delete $item->{ref};
+      delete $item->{ext_description};
+      my $key = freeze($item);
+      $itemshash{$key} ||= 0;
+      $itemshash{$key} ++; # += $item->{quantity};
+    }
+    @items = sort { $a->{description} cmp $b->{description} }
+             map { my $i = thaw($_);
+                   $i->{quantity} = $itemshash{$_};
+                   $i->{amount} =
+                     sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
+                   $i;
+                 }
+             keys %itemshash;
+  }
+  @items;
 }
 
 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 {
@@ -2793,22 +4132,42 @@ sub _items_cust_bill_pkg {
   my $format_function = $opt{format_function} || '';
   my $unsquelched = $opt{unsquelched} || '';
   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 && !$discount_show_always );
+        $_ = undef;
+      }
+    }
+
     foreach my $display ( grep { defined($section)
                                  ? $_->section eq $section
                                  : 1
                                }
+                          #grep { !$_->summary || !$summary_page } # bunk!
+                          grep { !$_->summary || $multisection }
                           $cust_bill_pkg->cust_bill_pkg_display
                         )
     {
 
       my $type = $display->type;
 
-      my $cust_pkg = $cust_bill_pkg->cust_pkg;
-
       my $desc = $cust_bill_pkg->desc;
       $desc = substr($desc, 0, 50). '...'
         if $format eq 'latex' && length($desc) > 50;
@@ -2820,54 +4179,96 @@ sub _items_cust_bill_pkg {
 
       if ( $cust_bill_pkg->pkgnum > 0 ) {
 
+        my $cust_pkg = $cust_bill_pkg->cust_pkg;
+
         if ( $cust_bill_pkg->setup != 0 && (!$type || $type eq 'S') ) {
 
           my $description = $desc;
           $description .= ' Setup' if $cust_bill_pkg->recur != 0;
 
           my @d = ();
-          push @d, map &{$escape_function}($_),
-                       $cust_pkg->h_labels_short($self->_date)
-            unless $cust_pkg->part_pkg->hide_svc_detail;
+          unless ( $cust_pkg->part_pkg->hide_svc_detail
+                || $cust_bill_pkg->hidden )
+          {
+
+            push @d, map &{$escape_function}($_),
+                         $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;
 
-          push @b, {
-            description     => $description,
-            #pkgpart         => $part_pkg->pkgpart,
-            pkgnum          => $cust_bill_pkg->pkgnum,
-            amount          => sprintf("%.2f", $cust_bill_pkg->setup),
-            unit_amount     => sprintf("%.2f", $cust_bill_pkg->unitsetup),
-            quantity        => $cust_bill_pkg->quantity,
-            ext_description => \@d,
+          if ( $cust_bill_pkg->hidden ) {
+            $s->{amount}      += $cust_bill_pkg->setup;
+            $s->{unit_amount} += $cust_bill_pkg->unitsetup;
+            push @{ $s->{ext_description} }, @d;
+          } else {
+            $s = {
+              description     => $description,
+              #pkgpart         => $part_pkg->pkgpart,
+              pkgnum          => $cust_bill_pkg->pkgnum,
+              amount          => $cust_bill_pkg->setup,
+              unit_amount     => $cust_bill_pkg->unitsetup,
+              quantity        => $cust_bill_pkg->quantity,
+              ext_description => \@d,
+            };
           };
 
         }
 
-        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' )
            )
         {
 
           my $is_summary = $display->summary;
-          my $description = $is_summary ? "Usage charges" : $desc;
+          my $description = ($is_summary && $type && $type eq 'U')
+                            ? "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 = ();
 
           #at least until cust_bill_pkg has "past" ranges in addition to
           #the "future" sdate/edate ones... see #3032
-          push @d, map &{$escape_function}($_),
-                       $cust_pkg->h_labels_short($self->_date)
-                                                 #$cust_bill_pkg->edate,
-                                                 #$cust_bill_pkg->sdate)
-            unless $cust_pkg->part_pkg->hide_svc_detail
+          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
-                || $is_summary;
+                || $cust_bill_pkg->hidden
+                || $is_summary && $type && $type eq 'U' )
+          {
+
+            push @d, map &{$escape_function}($_),
+                         $cust_pkg->h_labels_short(@dates, 'I')
+                                                   #$cust_bill_pkg->edate,
+                                                   #$cust_bill_pkg->sdate)
+              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)
             unless ($is_summary || $type && $type eq 'R');
@@ -2881,17 +4282,45 @@ sub _items_cust_bill_pkg {
             $amount = $cust_bill_pkg->usage;
           }
   
-          push @b, {
-            description     => $description,
-            #pkgpart         => $part_pkg->pkgpart,
-            pkgnum          => $cust_bill_pkg->pkgnum,
-            amount          => sprintf("%.2f", $amount),
-            unit_amount     => sprintf("%.2f", $cust_bill_pkg->unitrecur),
-            quantity        => $cust_bill_pkg->quantity,
-            ext_description => \@d,
-          } unless ( $type eq 'U' && ! $amount );
+          if ( !$type || $type eq 'R' ) {
+
+            if ( $cust_bill_pkg->hidden ) {
+              $r->{amount}      += $amount;
+              $r->{unit_amount} += $cust_bill_pkg->unitrecur;
+              push @{ $r->{ext_description} }, @d;
+            } else {
+              $r = {
+                description     => $description,
+                #pkgpart         => $part_pkg->pkgpart,
+                pkgnum          => $cust_bill_pkg->pkgnum,
+                amount          => $amount,
+                unit_amount     => $cust_bill_pkg->unitrecur,
+                quantity        => $cust_bill_pkg->quantity,
+                ext_description => \@d,
+              };
+            }
+
+          } else {  # $type eq 'U'
+
+            if ( $cust_bill_pkg->hidden ) {
+              $u->{amount}      += $amount;
+              $u->{unit_amount} += $cust_bill_pkg->unitrecur;
+              push @{ $u->{ext_description} }, @d;
+            } else {
+              $u = {
+                description     => $description,
+                #pkgpart         => $part_pkg->pkgpart,
+                pkgnum          => $cust_bill_pkg->pkgnum,
+                amount          => $amount,
+                unit_amount     => $cust_bill_pkg->unitrecur,
+                quantity        => $cust_bill_pkg->quantity,
+                ext_description => \@d,
+              };
+            }
 
-        }
+          }
+
+        } # recurring or usage with recurring charge
 
       } else { #pkgnum tax or one-shot line item (??)
 
@@ -2904,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),
           };
         }
@@ -2916,6 +4345,16 @@ sub _items_cust_bill_pkg {
 
   }
 
+  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+    if ( $_  ) {
+      $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+      $_->{amount}      =~ s/^\-0\.00$/0.00/;
+      $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+      push @b, { %$_ }
+           unless ( $_->{amount} == 0 && !$discount_show_always );
+    }
+  }
+
   @b;
 
 }
@@ -2939,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),
     };
   }
@@ -2959,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 )
     };
   }
@@ -2968,6 +4407,38 @@ sub _items_payments {
 
 }
 
+=item call_details [ OPTION => VALUE ... ]
+
+Returns an array of CSV strings representing the call details for this invoice
+The only option available is the boolean prepend_billed_number
+
+=cut
+
+sub call_details {
+  my ($self, %opt) = @_;
+
+  my $format_function = sub { shift };
+
+  if ($opt{prepend_billed_number}) {
+    $format_function = sub {
+      my $detail = shift;
+      my $row = shift;
+
+      $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail;
+      
+    };
+  }
+
+  my @details = map { $_->details( 'format_function' => $format_function,
+                                   'escape_function' => sub{ return() },
+                                 )
+                    }
+                  grep { $_->pkgnum }
+                  $self->cust_bill_pkg;
+  my $header = $details[0];
+  ( $header, grep { $_ ne $header } @details );
+}
+
 
 =back
 
@@ -3044,7 +4515,7 @@ sub re_X {
   my $distinct = '';
   my $orderby = 'ORDER BY cust_bill._date';
 
-  my $extra_sql = ' WHERE '. FS::cust_bill->search_sql(\%param);
+  my $extra_sql = ' WHERE '. FS::cust_bill->search_sql_where(\%param);
 
   my $addl_from = 'LEFT JOIN cust_main USING ( custnum )';
      
@@ -3095,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
@@ -3106,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
@@ -3117,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
@@ -3129,25 +4606,44 @@ 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 HASHREF
+=item search_sql_where HASHREF
 
 Class method which returns an SQL WHERE fragment to search for parameters
 specified in HASHREF.  Valid parameters are
 
 =over 4
 
-=item begin
+=item _date
 
-Epoch date (UNIX timestamp) setting a lower bound for _date values
-
-=item end
-
-Epoch date (UNIX timestamp) setting an upper bound for _date values
+List reference of start date, end date, as UNIX timestamps.
 
 =item invnum_min
 
@@ -3155,10 +4651,22 @@ Epoch date (UNIX timestamp) setting an upper bound for _date values
 
 =item agentnum
 
+=item charged
+
+List reference of charged limits (exclusive).
+
 =item owed
 
+List reference of charged limits (exclusive).
+
+=item open
+
+flag, return open invoices only
+
 =item net
 
+flag, return net invoices only
+
 =item days
 
 =item newest_percust
@@ -3169,40 +4677,68 @@ Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
 
 =cut
 
-sub search_sql {
+sub search_sql_where {
   my($class, $param) = @_;
   if ( $DEBUG ) {
-    warn "$me search_sql called with params: \n".
+    warn "$me search_sql_where called with params: \n".
          join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
   }
 
   my @search = ();
 
-  if ( $param->{'begin'} =~ /^(\d+)$/ ) {
-    push @search, "cust_bill._date >= $1";
+  #agentnum
+  if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
+    push @search, "cust_main.agentnum = $1";
   }
-  if ( $param->{'end'} =~ /^(\d+)$/ ) {
-    push @search, "cust_bill._date < $1";
+
+  #_date
+  if ( $param->{_date} ) {
+    my($beginning, $ending) = @{$param->{_date}};
+
+    push @search, "cust_bill._date >= $beginning",
+                  "cust_bill._date <  $ending";
   }
+
+  #invnum
   if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) {
     push @search, "cust_bill.invnum >= $1";
   }
   if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) {
     push @search, "cust_bill.invnum <= $1";
   }
-  if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
-    push @search, "cust_main.agentnum = $1";
+
+  #charged
+  if ( $param->{charged} ) {
+    my @charged = ref($param->{charged})
+                    ? @{ $param->{charged} }
+                    : ($param->{charged});
+
+    push @search, map { s/^charged/cust_bill.charged/; $_; }
+                      @charged;
   }
 
-  push @search, '0 != '. FS::cust_bill->owed_sql
-    if $param->{'open'};
+  my $owed_sql = FS::cust_bill->owed_sql;
 
+  #owed
+  if ( $param->{owed} ) {
+    my @owed = ref($param->{owed})
+                 ? @{ $param->{owed} }
+                 : ($param->{owed});
+    push @search, map { s/^owed/$owed_sql/; $_; }
+                      @owed;
+  }
+
+  #open/net flags
+  push @search, "0 != $owed_sql"
+    if $param->{'open'};
   push @search, '0 != '. FS::cust_bill->net_sql
     if $param->{'net'};
 
+  #days
   push @search, "cust_bill._date < ". (time-86400*$param->{'days'})
     if $param->{'days'};
 
+  #newest_percust
   if ( $param->{'newest_percust'} ) {
 
     #$distinct = 'DISTINCT ON ( cust_bill.custnum )';
@@ -3226,6 +4762,7 @@ sub search_sql {
 
   }
 
+  #agent virtualization
   my $curuser = $FS::CurrentUser::CurrentUser;
   if ( $curuser->username eq 'fs_queue'
        && $param->{'CurrentUser'} =~ /^(\w+)$/ ) {
@@ -3240,7 +4777,6 @@ sub search_sql {
       warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n";
     }
   }
-
   push @search, $curuser->agentnums_sql;
 
   join(' AND ', @search );