add advertising source to sales/credits/receipts summary, RT#18349
[freeside.git] / FS / FS / cust_bill.pm
index d9a5e42..498025f 100644 (file)
@@ -43,6 +43,7 @@ use FS::bill_batch;
 use FS::cust_bill_batch;
 use FS::cust_bill_pay_pkg;
 use FS::cust_credit_bill_pkg;
+use FS::discount_plan;
 use FS::L10N;
 
 @ISA = qw( FS::cust_main_Mixin FS::Record );
@@ -143,6 +144,8 @@ Specific use cases
 
 =item agent_invid - legacy invoice number
 
+=item promised_date - customer promised payment date, for collection
+
 =back
 
 =head1 METHODS
@@ -385,8 +388,10 @@ sub previous {
   my $self = shift;
   my $total = 0;
   my @cust_bill = sort { $a->_date <=> $b->_date }
-    grep { $_->owed != 0 && $_->_date < $self->_date }
-      qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) 
+    grep { $_->owed != 0 }
+      qsearch( 'cust_bill', { 'custnum' => $self->custnum,
+                              '_date'   => { op=>'<', value=>$self->_date },
+                            } ) 
   ;
   foreach ( @cust_bill ) { $total += $_->owed; }
   $total, @cust_bill;
@@ -748,6 +753,18 @@ sub cust_bill_batch {
   qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
 }
 
+=item discount_plans
+
+Returns all discount plans (L<FS::discount_plan>) for this invoice, as a 
+hash keyed by term length.
+
+=cut
+
+sub discount_plans {
+  my $self = shift;
+  FS::discount_plan->all($self);
+}
+
 =item tax
 
 Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
@@ -796,6 +813,23 @@ sub owed_pkgnum {
   $balance;
 }
 
+=item hide
+
+Returns true if this invoice should be hidden.  See the
+selfservice-hide_invoices-taxclass configuraiton setting.
+
+=cut
+
+sub hide {
+  my $self = shift;
+  my $conf = $self->conf;
+  my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
+    or return '';
+  my @cust_bill_pkg = $self->cust_bill_pkg;
+  my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
+  ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
+}
+
 =item apply_payments_and_credits [ OPTION => VALUE ... ]
 
 Applies unapplied payments and credits to this invoice.
@@ -1028,41 +1062,54 @@ sub generate_email {
       'Disposition' => 'inline',
     );
 
-    $args{'from'} =~ /\@([\w\.\-]+)/;
-    my $from = $1 || 'example.com';
-    my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
 
-    my $logo;
-    my $agentnum = $cust_main->agentnum;
-    if ( defined($args{'template'}) && length($args{'template'})
-         && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
-       )
-    {
-      $logo = 'logo_'. $args{'template'}. '.png';
+    my $htmldata;
+    my $image = '';
+    my $barcode = '';
+    if ( $conf->exists('invoice_email_pdf')
+         and scalar($conf->config('invoice_email_pdf_note')) ) {
+
+      $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
+
     } else {
-      $logo = "logo.png";
-    }
-    my $image_data = $conf->config_binary( $logo, $agentnum);
-
-    my $image = build MIME::Entity
-      'Type'       => 'image/png',
-      'Encoding'   => 'base64',
-      'Data'       => $image_data,
-      'Filename'   => 'logo.png',
-      'Content-ID' => "<$content_id>",
-    ;
+
+      $args{'from'} =~ /\@([\w\.\-]+)/;
+      my $from = $1 || 'example.com';
+      my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+
+      my $logo;
+      my $agentnum = $cust_main->agentnum;
+      if ( defined($args{'template'}) && length($args{'template'})
+           && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
+         )
+      {
+        $logo = 'logo_'. $args{'template'}. '.png';
+      } else {
+        $logo = "logo.png";
+      }
+      my $image_data = $conf->config_binary( $logo, $agentnum);
+
+      $image = build MIME::Entity
+        'Type'       => 'image/png',
+        'Encoding'   => 'base64',
+        'Data'       => $image_data,
+        'Filename'   => 'logo.png',
+        'Content-ID' => "<$content_id>",
+      ;
    
-    my $barcode;
-    if($conf->exists('invoice-barcode')){
-       my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
-       $barcode = build MIME::Entity
-         'Type'       => 'image/png',
-         'Encoding'   => 'base64',
-         'Data'       => $self->invoice_barcode(0),
-         'Filename'   => 'barcode.png',
-         'Content-ID' => "<$barcode_content_id>",
-       ;
-       $opt{'barcode_cid'} = $barcode_content_id;
+      if ($conf->exists('invoice-barcode')) {
+        my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+        $barcode = build MIME::Entity
+          'Type'       => 'image/png',
+          'Encoding'   => 'base64',
+          'Data'       => $self->invoice_barcode(0),
+          'Filename'   => 'barcode.png',
+          'Content-ID' => "<$barcode_content_id>",
+        ;
+        $opt{'barcode_cid'} = $barcode_content_id;
+      }
+
+      $htmldata = $self->print_html({ 'cid'=>$content_id, %opt });
     }
 
     $alternative->attach(
@@ -1075,7 +1122,7 @@ sub generate_email {
                          '    </title>',
                          '  </head>',
                          '  <body bgcolor="#e8e8e8">',
-                         $self->print_html({ 'cid'=>$content_id, %opt }),
+                         $htmldata,
                          '  </body>',
                          '</html>',
                        ],
@@ -1083,6 +1130,7 @@ sub generate_email {
       #'Filename'    => 'invoice.pdf',
     );
 
+
     my @otherparts = ();
     if ( $cust_main->email_csv_cdr ) {
 
@@ -1121,7 +1169,7 @@ sub generate_email {
 
       $related->add_part($alternative);
 
-      $related->add_part($image);
+      $related->add_part($image) if $image;
 
       my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
 
@@ -1137,11 +1185,10 @@ sub generate_email {
       #   image/png
 
       $return{'content-type'} = 'multipart/related';
-      if($conf->exists('invoice-barcode')){
-         $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
-      }
-      else {
-         $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
+      if ($conf->exists('invoice-barcode') && $barcode) {
+        $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
+      } else {
+        $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
       }
       $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
       #$return{'disposition'} = 'inline';
@@ -1269,14 +1316,16 @@ sub send {
     $balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/;
   }
 
+  my $cust_main = $self->cust_main;
+
   return 'N/A' unless ! $agentnums
-                   or grep { $_ == $self->cust_main->agentnum } @$agentnums;
+                   or grep { $_ == $cust_main->agentnum } @$agentnums;
 
   return ''
-    unless $self->cust_main->total_owed_date($self->_date) > $balance_over;
+    unless $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 );
+                    $conf->config('invoice_from', $cust_main->agentnum );
 
   my %opt = (
     'template'     => $template,
@@ -1284,11 +1333,12 @@ sub send {
     'notice_name'  => ( $notice_name || 'Invoice' ),
   );
 
-  my @invoicing_list = $self->cust_main->invoicing_list;
+  my @invoicing_list = $cust_main->invoicing_list;
 
   #$self->email_invoice(\%opt)
   $self->email(\%opt)
-    if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list;
+    if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
+    && ! $self->invoice_noemail;
 
   #$self->print_invoice(\%opt)
   $self->print(\%opt)
@@ -1335,6 +1385,7 @@ sub queueable_email {
 #sub email_invoice {
 sub email {
   my $self = shift;
+  return if $self->hide;
   my $conf = $self->conf;
 
   my( $template, $invoice_from, $notice_name, $no_coupon );
@@ -1453,7 +1504,9 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 #sub print_invoice {
 sub print {
   my $self = shift;
+  return if $self->hide;
   my $conf = $self->conf;
+
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -1493,7 +1546,9 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub fax_invoice {
   my $self = shift;
+  return if $self->hide;
   my $conf = $self->conf;
+
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -1698,13 +1753,21 @@ Options are:
 
 =over 4
 
-=item format - 'default' or 'billco'
+=item format - any of FS::Misc::::Invoicing::spool_formats
+
+=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the
+customer has the corresponding invoice destinations set (see
+L<FS::cust_main_invoice>).
 
-=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the customer has the corresponding invoice destinations set (see L<FS::cust_main_invoice>).
+=item agent_spools - if set to a true value, will spool to per-agent files
+rather than a single global file
 
-=item agent_spools - if set to a true value, will spool to per-agent files rather than a single global file
+=item ftp_targetnum - if set to an FTP target (see L<FS::ftp_target>), will
+append to that spool.  L<FS::Cron::upload> will then send the spool file to
+that destination.
 
-=item balanceover - if set, only spools the invoice if the total amount owed on this invoice and all older invoices is greater than the specified amount.
+=item balanceover - if set, only spools the invoice if the total amount owed on
+this invoice and all older invoices is greater than the specified amount.
 
 =back
 
@@ -1732,11 +1795,23 @@ sub spool_csv {
 
   my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
 
-  my $file =
-    "$spooldir/".
-    ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
-    ( lc($opt{'format'}) eq 'billco' ? '-header' : '' ) .
-    '.csv';
+  my $file;
+  if ( $opt{'agent_spools'} ) {
+    $file = 'agentnum'.$cust_main->agentnum;
+  } else {
+    $file = 'spool';
+  }
+
+  if ( $opt{'ftp_targetnum'} ) {
+    $spooldir .= '/target'.$opt{'ftp_targetnum'};
+    mkdir $spooldir, 0700 unless -d $spooldir;
+  } # otherwise it just goes into export.xxx/cust_bill
+
+  if ( lc($opt{'format'}) eq 'billco' ) {
+    $file .= '-header';
+  }
+
+  $file = "$spooldir/$file.csv";
   
   my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
 
@@ -1751,10 +1826,7 @@ sub spool_csv {
     flock(CSV, LOCK_UN);
     close CSV;
 
-    $file =
-      "$spooldir/".
-      ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
-      '-detail.csv';
+    $file =~ s/-header.csv$/-detail.csv/;
 
     open(CSV,">>$file") or die "can't open $file: $!";
     flock(CSV, LOCK_EX);
@@ -1776,7 +1848,7 @@ Returns CSV data for this invoice.
 
 Options are:
 
-format - 'default' or 'billco'
+format - 'default', 'billco', 'oneline', 'bridgestone'
 
 Returns a list consisting of two scalars.  The first is a single line of CSV
 header information for this invoice.  The second is one or more lines of CSV
@@ -1785,7 +1857,8 @@ detail information for this invoice.
 If I<format> is not specified or "default", the fields of the CSV file are as
 follows:
 
-record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate
+record_type, invnum, custnum, _date, charged, first, last, company, address1, 
+address2, city, state, zip, country, pkg, setup, recur, sdate, edate
 
 =over 4
 
@@ -1890,6 +1963,26 @@ If I<format> is "billco", the fields of the detail CSV file are as follows:
   9     | Grouping Code              | GROUP     | CHAR |     2
   10    | User Defined               | ACCT CODE | CHAR |    15
 
+If format is 'oneline', there is no detail file.  Each invoice has a 
+header line only, with the fields:
+
+Agent number, agent name, customer number, first name, last name, address
+line 1, address line 2, city, state, zip, invoice date, invoice number,
+amount charged, amount due,
+
+and then, for each line item, three columns containing the package number,
+description, and amount.
+
+If format is 'bridgestone', there is no detail file.  Each invoice has a 
+header line with the following fields in a fixed-width format:
+
+Customer number (in display format), date, name (first last), company,
+address 1, address 2, city, state, zip.
+
+This is a mailing list format, and has no per-invoice fields.  To avoid
+sending redundant notices, the spooling event should have a "once" or 
+"once_percust_every" condition.
+
 =cut
 
 sub print_csv {
@@ -1955,6 +2048,62 @@ sub print_csv {
       '0',                        # 29 | Other Taxes & Fees***         NUM*   9
     );
 
+  } elsif ( lc($opt{'format'}) eq 'oneline' ) { #name?
+  
+    my ($previous_balance) = $self->previous; 
+    my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
+    my @items = map {
+      ($_->{pkgnum} || ''),
+      $_->{description},
+      $_->{amount}
+    } $self->_items_pkg;
+
+    $csv->combine(
+      $cust_main->agentnum,
+      $cust_main->agent->agent,
+      $self->custnum,
+      $cust_main->first,
+      $cust_main->last,
+      $cust_main->address1,
+      $cust_main->address2,
+      $cust_main->city,
+      $cust_main->state,
+      $cust_main->zip,
+
+      # invoice fields
+      time2str("%x", $self->_date),
+      $self->invnum,
+      $self->charged,
+      $totaldue,
+
+      @items,
+    );
+
+  } elsif ( lc($opt{'format'}) eq 'bridgestone' ) {
+
+    # bypass the CSV stuff and just return this
+    my $longdate = time2str('%B %d, %Y', time); #current time, right?
+    my $zip = $cust_main->zip;
+    $zip =~ s/\D//;
+    my $prefix = $self->conf->config('bridgestone-prefix', $cust_main->agentnum)
+      || '';
+    return (
+      sprintf(
+        "%-5s%-15s%-20s%-30s%-30s%-30s%-30s%-20s%-2s%-9s\n",
+        $prefix,
+        $cust_main->display_custnum,
+        $longdate,
+        uc(substr($cust_main->contact_firstlast,0,30)),
+        uc(substr($cust_main->company          ,0,30)),
+        uc(substr($cust_main->address1         ,0,30)),
+        uc(substr($cust_main->address2         ,0,30)),
+        uc(substr($cust_main->city             ,0,20)),
+        uc($cust_main->state),
+        $zip
+      ),
+      '' #detail
+      );
+
   } else {
   
     $csv->combine(
@@ -1994,6 +2143,10 @@ sub print_csv {
 
     }
 
+  } elsif ( lc($opt{'format'}) eq 'oneline' ) {
+
+    #do nothing
+
   } else {
 
     foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
@@ -2692,11 +2845,13 @@ sub print_generic {
   $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) ){
-    my $method = $prefix.$_;
+  foreach ( qw( address1 address2 city state zip country fax) ){
+    my $method = 'ship_'.$_;
     $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
   }
+  foreach ( qw( contact company ) ) { #compatibility
+    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
+  }
   $invoice_data{'ship_country'} = ''
     if ( $invoice_data{'ship_country'} eq $countrydefault );
   
@@ -2893,6 +3048,12 @@ sub print_generic {
   my $late_sections = [];
   my $extra_sections = [];
   my $extra_lines = ();
+
+  my $default_section = { 'description' => '',
+                          'subtotal'    => '', 
+                          'no_subtotal' => 1,
+                        };
+
   if ( $multisection ) {
     ($extra_sections, $extra_lines) =
       $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
@@ -2924,8 +3085,7 @@ sub print_generic {
     }
   } else {# not multisection
     # make a default section
-    push @sections, { 'description' => '', 'subtotal' => '', 
-      'no_subtotal' => 1 };
+    push @sections, $default_section;
     # and calculate the finance charge total, since it won't get done otherwise.
     # XXX possibly other totals?
     # XXX possibly finance_pkgclass should not be used in this manner?
@@ -2943,7 +3103,7 @@ sub print_generic {
     }
   }
 
-  unless (    $conf->exists('disable_previous_balance')
+  unless (    $conf->exists('disable_previous_balance', $agentnum)
            || $conf->exists('previous_balance-summary_only')
          )
   {
@@ -2958,7 +3118,8 @@ sub print_generic {
       };
       $detail->{'ref'} = $line_item->{'pkgnum'};
       $detail->{'quantity'} = 1;
-      $detail->{'section'} = $previous_section;
+      $detail->{'section'} = $multisection ? $previous_section
+                                           : $default_section;
       $detail->{'description'} = &$escape_function($line_item->{'description'});
       if ( exists $line_item->{'ext_description'} ) {
         @{$detail->{'ext_description'}} = map {
@@ -2977,7 +3138,8 @@ sub print_generic {
 
   }
   
-  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
+  if ( @pr_cust_bill && !$conf->exists('disable_previous_balance', $agentnum) ) 
+    {
     push @buf, ['','-----------'];
     push @buf, [ $self->mt('Total Previous Balance'),
                  $money_char. sprintf("%10.2f", $pr_total) ];
@@ -3093,7 +3255,7 @@ sub print_generic {
   $invoice_data{current_less_finance} =
     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
 
-  if ( $multisection && !$conf->exists('disable_previous_balance')
+  if ( $multisection && !$conf->exists('disable_previous_balance', $agentnum)
     || $conf->exists('previous_balance-summary_only') )
   {
     unshift @sections, $previous_section if $pr_total;
@@ -3157,7 +3319,7 @@ sub print_generic {
 
   push @buf,['','-----------'];
   push @buf,[$self->mt( 
-              $conf->exists('disable_previous_balance') 
+              $conf->exists('disable_previous_balance', $agentnum
                ? 'Total Charges'
                : 'Total New Charges'
              ),
@@ -3171,7 +3333,7 @@ sub print_generic {
          || 'Total New Charges'
       if $conf->exists('previous_balance-exclude_from_total');
     my $amount = $self->charged +
-                   ( $conf->exists('disable_previous_balance') ||
+                   ( $conf->exists('disable_previous_balance', $agentnum) ||
                      $conf->exists('previous_balance-exclude_from_total')
                      ? 0
                      : $pr_total
@@ -3198,7 +3360,7 @@ sub print_generic {
     push @buf,['',''];
   }
   
-  unless ( $conf->exists('disable_previous_balance') ) {
+  unless ( $conf->exists('disable_previous_balance', $agentnum) ) {
     #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
   
     # credits
@@ -3796,17 +3958,20 @@ sub _items_sections {
         if ( $display->post_total && !$summarypage ) {
           if (! $type || $type eq 'S') {
             $late_subtotal{$section} += $cust_bill_pkg->setup
-              if $cust_bill_pkg->setup != 0;
+              if $cust_bill_pkg->setup != 0
+              || $cust_bill_pkg->setup_show_zero;
           }
 
           if (! $type) {
             $late_subtotal{$section} += $cust_bill_pkg->recur
-              if $cust_bill_pkg->recur != 0;
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
           }
 
           if ($type && $type eq 'R') {
             $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
-              if $cust_bill_pkg->recur != 0;
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
           }
           
           if ($type && $type eq 'U') {
@@ -3820,17 +3985,20 @@ sub _items_sections {
 
           if (! $type || $type eq 'S') {
             $subtotal{$section} += $cust_bill_pkg->setup
-              if $cust_bill_pkg->setup != 0;
+              if $cust_bill_pkg->setup != 0
+              || $cust_bill_pkg->setup_show_zero;
           }
 
           if (! $type) {
             $subtotal{$section} += $cust_bill_pkg->recur
-              if $cust_bill_pkg->recur != 0;
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
           }
 
           if ($type && $type eq 'R') {
             $subtotal{$section} += $cust_bill_pkg->recur - $usage
-              if $cust_bill_pkg->recur != 0;
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
           }
           
           if ($type && $type eq 'U') {
@@ -4801,6 +4969,8 @@ sub _items_cust_bill_pkg {
 
   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
 
+  my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
+
   my @b = ();
   my ($s, $r, $u) = ( undef, undef, undef );
   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
@@ -4821,6 +4991,8 @@ sub _items_cust_bill_pkg {
       }
     }
 
+    my @cust_bill_pkg_display = $cust_bill_pkg->cust_bill_pkg_display;
+
     warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
       if $DEBUG > 1;
@@ -4831,7 +5003,7 @@ sub _items_cust_bill_pkg {
                                }
                           #grep { !$_->summary || !$summary_page } # bunk!
                           grep { !$_->summary || $multisection }
-                          $cust_bill_pkg->cust_bill_pkg_display
+                          @cust_bill_pkg_display
                         )
     {
 
@@ -4936,11 +5108,30 @@ sub _items_cust_bill_pkg {
           my $description = ($is_summary && $type && $type eq 'U')
                             ? "Usage charges" : $desc;
 
-          $description .= " (" . time2str($date_format, $cust_bill_pkg->sdate).
-                          " - ". time2str($date_format, $cust_bill_pkg->edate).
-                          ")"
-            unless $conf->exists('disable_line_item_date_ranges')
-                || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
+          #pry be a bit more efficient to look some of this conf stuff up
+          # outside the loop
+          unless (
+            $conf->exists('disable_line_item_date_ranges')
+              || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1)
+          ) {
+            my $time_period;
+            my $date_style = $conf->config( 'cust_bill-line_item-date_style',
+                                            $cust_main->agentnum
+                                          );
+            if ( defined($date_style) && $date_style eq 'month_of' ) {
+              $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
+            } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
+              my $desc = $conf->config( 'cust_bill-line_item-date_description',
+                                         $cust_main->agentnum
+                                      );
+              $desc .= ' ' unless $desc =~ /\s$/;
+              $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
+            } else {
+              $time_period =      time2str($date_format, $cust_bill_pkg->sdate).
+                           " - ". time2str($date_format, $cust_bill_pkg->edate);
+            }
+            $description .= " ($time_period)";
+          }
 
           my @d = ();
           my @seconds = (); # for display of usage info
@@ -5183,108 +5374,37 @@ a setup fee if the discount is allowed to apply to setup fees.
 
 sub _items_discounts_avail {
   my $self = shift;
-  my %terms;
   my $list_pkgnums = 0; # if any packages are not eligible for all discounts
-  my ($previous_balance) = $self->previous;
-
-  foreach (qsearch('discount',{ 'months' => { op => '>', value => 1} })) {
-    $terms{$_->months} = {
-      pkgnums       => [],
-      base          => $previous_balance || 0, # pre-discount sum of charges
-      discounted    => $previous_balance || 0, # post-discount sum
-      list_pkgnums  => 0, # whether any packages are not discounted
-    }
-  }
-  foreach my $months (keys %terms) {
-    my $hash = $terms{$months};
-
-    # tricky, because packages may not all be eligible for the same discounts
-    foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
-      my $cust_pkg = $cust_bill_pkg->cust_pkg or next;
-      my $part_pkg = $cust_pkg->part_pkg or next;
-      my $freq = $part_pkg->freq;
-      my $setup = $cust_bill_pkg->setup || 0;
-      my $recur = $cust_bill_pkg->recur || 0;
 
-      if ( $freq eq '1' ) { #monthly
-        my $permonth = $part_pkg->base_recur_permonth || 0;
+  my %plans = $self->discount_plans;
 
-        my ($discount) = grep { $_->months == $months } 
-                         map { $_->discount } $part_pkg->part_pkg_discount;
+  $list_pkgnums = grep { $_->list_pkgnums } values %plans;
 
-        $hash->{base} += $setup + $recur + ($months - 1) * $permonth;
+  map {
+    my $months = $_;
+    my $plan = $plans{$months};
 
-        if ( $discount ) {
-
-          my $discountable;
-          if ( $discount->setup ) {
-            $discountable += $setup;
-          }
-          else {
-            $hash->{discounted} += $setup;
-          }
-
-          if ( $discount->percent ) {
-            $discountable += $months * $permonth;
-            $discountable -= ($discountable * $discount->percent / 100);
-            $discountable -= ($permonth - $recur); # correct for prorate
-            $hash->{discounted} += $discountable;
-          }
-          else {
-            $discountable += $recur;
-            $discountable -= $discount->amount * $recur/$permonth;
-
-            $discountable += ($months - 1) * max($permonth - $discount->amount,0);
-          }
-
-          $hash->{discounted} += $discountable;
-          push @{ $hash->{pkgnums} }, $cust_pkg->pkgnum;
-        }
-        else { #no discount
-          $hash->{discounted} += $setup + $recur + ($months - 1) * $permonth;
-          $hash->{list_pkgnums} = 1;
-        }
-      } #if $freq eq '1'
-      else { # all non-monthly packages: include current charges only
-        $hash->{discounted} += $setup + $recur;
-        $hash->{base} += $setup + $recur;
-        $hash->{list_pkgnums} = 1;
-      }
-    } #foreach $cust_bill_pkg
-
-    # don't show this line if no packages have discounts at this term
-    # or if there are no new charges to apply the discount to
-    delete $terms{$months} if $hash->{base} == $hash->{discounted}
-                           or $hash->{base} == 0;
-
-  }
-
-  $list_pkgnums = grep { $_->{list_pkgnums} > 0 } values %terms;
-
-  foreach my $months (keys %terms) {
-    my $hash = $terms{$months};
-    my $term_total = sprintf('%.2f', $hash->{discounted});
-    # possibly shouldn't include previous balance in these?
-    my $percent = sprintf('%.0f', 100 * (1 - $term_total / $hash->{base}) );
+    my $term_total = sprintf('%.2f', $plan->discounted_total);
+    my $percent = sprintf('%.0f', 
+                          100 * (1 - $term_total / $plan->base_total) );
     my $permonth = sprintf('%.2f', $term_total / $months);
-
-    $hash->{description} = $self->mt('Save [_1]% by paying for [_2] months',
-      $percent, $months
-    );
-    $hash->{amount} = $self->mt('[_1] ([_2] per month)', 
-      $term_total, $money_char.$permonth
-    );
-
-    my @detail;
-    if ( $list_pkgnums ) {
-      push @detail, $self->mt('discount on item'). ' '.
-                join(', ', map { "#$_" } @{ $hash->{pkgnums} });
+    my $detail = $self->mt('discount on item'). ' '.
+                 join(', ', map { "#$_" } $plan->pkgnums)
+      if $list_pkgnums;
+
+    # discounts for non-integer months don't work anyway
+    $months = sprintf("%d", $months);
+
+    +{
+      description => $self->mt('Save [_1]% by paying for [_2] months',
+                                $percent, $months),
+      amount      => $self->mt('[_1] ([_2] per month)', 
+                                $term_total, $money_char.$permonth),
+      ext_description => ($detail || ''),
     }
-    $hash->{ext_description} = join ', ', @detail;
-  }
+  } #map
+  sort { $b <=> $a } keys %plans;
 
-  map { $terms{$_} } sort {$b <=> $a} keys %terms;
 }
 
 =item call_details [ OPTION => VALUE ... ]
@@ -5385,6 +5505,7 @@ sub process_re_X {
 }
 
 sub re_X {
+  # spool_invoice ftp_invoice fax_invoice print_invoice
   my($method, $job, %param ) = @_;
   if ( $DEBUG ) {
     warn "re_X $method for job $job with param:\n".
@@ -5572,7 +5693,12 @@ sub search_sql_where {
     push @search, "cust_main.agentnum = $1";
   }
 
-  #agentnum
+  #refnum
+  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
+    push @search, "cust_main.refnum = $1";
+  }
+
+  #custnum
   if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
     push @search, "cust_bill.custnum = $1";
   }
@@ -5648,6 +5774,15 @@ sub search_sql_where {
 
   }
 
+  #promised_date - also has an option to accept nulls
+  if ( $param->{promised_date} ) {
+    my($beginning, $ending, $null) = @{$param->{promised_date}};
+
+    push @search, "(( cust_bill.promised_date >= $beginning AND ".
+                    "cust_bill.promised_date <  $ending )" .
+                    ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')');
+  }
+
   #agent virtualization
   my $curuser = $FS::CurrentUser::CurrentUser;
   if ( $curuser->username eq 'fs_queue'