fix sprintf error, mostly #31273
[freeside.git] / FS / FS / Template_Mixin.pm
index 07d2030..9669ac2 100644 (file)
@@ -16,6 +16,7 @@ use HTML::Entities;
 use Locale::Country;
 use Cwd;
 use FS::UID;
+use FS::Misc qw( send_email );
 use FS::Record qw( qsearch qsearchs );
 use FS::Conf;
 use FS::Misc qw( generate_ps generate_pdf );
@@ -220,26 +221,82 @@ Internal method - returns a filled-in template for this invoice as a scalar.
 
 See print_ps and print_pdf for methods that return PostScript and PDF output.
 
-Non optional options include 
-  format - latex, html, template
+Required options
 
-Optional options include
+=over 4
 
-template - a value used as a suffix for a configuration template.  Please 
-don't use this.
+=item format
 
-time - a value used to control the printing of overdue messages.  The
+The B<format> option is required and should be set to html, latex (print and PDF) or template (plaintext).
+
+=back
+
+Additional options
+
+=over 4
+
+=item notice_name
+
+Overrides "Invoice" as the name of the sent document.
+
+=item today
+
+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 - 
+=item logo_file
+
+Logo file (path to temporary EPS file on the local filesystem)
+
+=item cid
+
+CID for inline (emailed) images (logo)
+
+=item locale
+
+Override customer's locale
+
+=item unsquelch_cdr
+
+Overrides any per customer cdr squelching when true
+
+=item no_number
+
+Supress the (invoice, quotation, statement, etc.) number
 
-unsquelch_cdr - overrides any per customer cdr squelching when true
+=item no_date
 
-notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+Supress the date
 
-locale - override customer's locale
+=item no_coupon
+
+Supress the payment coupon
+
+=item barcode_file
+
+Barcode file (path to temporary EPS file on the local filesystem)
+
+=item barcode_img
+
+Flag indicating the barcode image should be a link (normal HTML dipaly)
+
+=item barcode_cid
+
+Barcode CID for inline (emailed) images
+
+=item preref_callback
+
+Coderef run for each line item, code should return HTML to be displayed
+before that line item (quotations only)
+
+=item template
+
+Dprecated.  Used as a suffix for a configuration template.  Please 
+don't use this, it deprecated in favor of more flexible alternatives.
+
+=back
 
 =cut
 
@@ -288,13 +345,13 @@ sub print_generic {
   my @invoice_template = map "$_\n", $conf->config($templatefile)
     or die "cannot load config data $templatefile";
 
-  my $old_latex = '';
   if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
     #change this to a die when the old code is removed
-    warn "old-style invoice template $templatefile; ".
+    # it's been almost ten years, changing it to a die
+    die "old-style invoice template $templatefile; ".
          "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
-    $old_latex = 'true';
-    @invoice_template = _translate_old_latex_format(@invoice_template);
+         #$old_latex = 'true';
+         #@invoice_template = _translate_old_latex_format(@invoice_template);
   } 
 
   warn "$me print_generic creating T:T object\n"
@@ -646,28 +703,29 @@ sub print_generic {
       # "balance_date_range" unfortunately is unsuitable for this, since it
       # cares about application dates.  We want to know the sum of all 
       # _top-level transactions_ dated before the last invoice.
-      my @sql = (
-        'SELECT SUM(charged) FROM cust_bill WHERE _date <= ? AND custnum = ?',
-        'SELECT -1*SUM(amount) FROM cust_credit WHERE _date <= ? AND custnum = ?',
-        'SELECT -1*SUM(paid) FROM cust_pay  WHERE _date <= ? AND custnum = ?',
-        'SELECT SUM(refund) FROM cust_refund WHERE _date <= ? AND custnum = ?',
-      );
+      my @sql =
+        map "$_ WHERE _date <= ? AND custnum = ?", (
+          "SELECT      COALESCE( SUM(charged), 0 ) FROM cust_bill",
+          "SELECT -1 * COALESCE( SUM(amount),  0 ) FROM cust_credit",
+          "SELECT -1 * COALESCE( SUM(paid),    0 ) FROM cust_pay",
+          "SELECT      COALESCE( SUM(refund),  0 ) FROM cust_refund",
+        );
 
       # the customer's current balance immediately after generating the last 
       # bill
 
       my $last_bill_balance = $last_bill->charged;
       foreach (@sql) {
-        #warn "$_\n";
         my $delta = FS::Record->scalar_sql(
           $_,
           $last_bill->_date - 1,
           $self->custnum,
         );
-        #warn "$delta\n";
         $last_bill_balance += $delta;
       }
 
+      $last_bill_balance = sprintf("%.2f", $last_bill_balance);
+
       warn sprintf("LAST BILL: INVNUM %d, DATE %s, BALANCE %.2f\n\n",
         $last_bill->invnum,
         $self->time2str_local('%D', $last_bill->_date),
@@ -681,13 +739,11 @@ sub print_generic {
       # to immediately before this one
       my $before_this_bill_balance = 0;
       foreach (@sql) {
-        #warn "$_\n";
         my $delta = FS::Record->scalar_sql(
           $_,
           $self->_date - 1,
           $self->custnum,
         );
-        #warn "$delta\n";
         $before_this_bill_balance += $delta;
       }
       $invoice_data{'balance_adjustments'} =
@@ -822,6 +878,7 @@ sub print_generic {
                     );
   my $money_char = $money_chars{$format};
 
+  # extremely dubious
   my %other_money_chars = ( 'latex'    => '\dollar ',#XXX should be a config too
                             'html'     => $conf->config('money_char') || '$',
                             'template' => '',
@@ -924,7 +981,7 @@ sub print_generic {
     # we haven't yet changed the template to take advantage of that, so for 
     # now, treat them as mutually exclusive.
     my %section_method = ( by_category => 1 );
-    if ( $conf->exists($tc.'sections_by_location') ) {
+    if ( $conf->config($tc.'sections_method') eq 'location' ) {
       %section_method = ( by_location => 1 );
     }
     my ($early, $late) =
@@ -982,6 +1039,36 @@ sub print_generic {
                                     sprintf('%.2f', sum( @charges ) || 0);
   }
 
+  # start setting up summary subtotals
+  my @summary_subtotals;
+  my $method = $conf->config('summary_subtotals_method');
+  if ( $method and $method ne $conf->config($tc.'sections_method') ) {
+    # then re-section them by the correct method
+    my %section_method = ( by_category => 1 );
+    if ( $conf->config('summary_subtotals_method') eq 'location' ) {
+      %section_method = ( by_location => 1 );
+    }
+    my ($early, $late) =
+      $self->_items_sections( 'summary' => $summarypage,
+                              'escape'  => $escape_function_nonbsp,
+                              'extra_sections' => $extra_sections,
+                              'format'  => $format,
+                              %section_method
+                            );
+    foreach ( @$early ) {
+      next if $_->{subtotal} == 0;
+      $_->{subtotal} = $other_money_char.sprintf('%.2f', $_->{subtotal});
+      push @summary_subtotals, $_;
+    }
+  } else {
+    # subtotal sectioning is the same as for the actual invoice sections
+    @summary_subtotals = @sections;
+  }
+
+  # Hereafter, push sections to both @sections and @summary_subtotals
+  # if they belong in both places (e.g. tax section).  Late sections are
+  # never in @summary_subtotals.
+
   # previous invoice balances in the Previous Charges section if there
   # is one, otherwise in the main detail section
   # (except if summary_only is enabled, don't show them at all)
@@ -1003,8 +1090,7 @@ sub print_generic {
         ext_description => [ map { &$escape_function($_) } 
                              @{ $line_item->{'ext_description'} || [] }
                            ],
-        amount          => ( $old_latex ? '' : $money_char).
-                            $line_item->{'amount'},
+        amount          => $money_char . $line_item->{'amount'},
         product_code    => $line_item->{'pkgpart'} || 'N/A',
       };
 
@@ -1074,6 +1160,7 @@ sub print_generic {
     $options{'summary_page'} = $summarypage;
     $options{'skip_usage'} =
       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
+    $options{'preref_callback'} = $params{'preref_callback'};
 
     warn "$me   searching for line items\n"
       if $DEBUG > 1;
@@ -1081,50 +1168,28 @@ sub print_generic {
     foreach my $line_item ( $self->_items_pkg(%options),
                             $self->_items_fee(%options) ) {
 
-      warn "$me     adding line item $line_item\n"
+      warn "$me     adding line item ".
+           join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
         if $DEBUG > 1;
 
-      # this is silly
-      #my $detail = {
-      #  ext_description => [],
-      #};
-      #$detail->{'ref'} = $line_item->{'pkgnum'};
-      #$detail->{'pkgpart'} = $line_item->{'pkgpart'};
-      #$detail->{'quantity'} = $line_item->{'quantity'};
-      #$detail->{'section'} = $section;
-      #$detail->{'description'} = &$escape_function($line_item->{'description'});
-      #if ( exists $line_item->{'ext_description'} ) {
-      #  @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
-      #}
-      #$detail->{'amount'} = ( $old_latex ? '' : $money_char ).
-      #                        $line_item->{'amount'};
-      #if ( exists $line_item->{'unit_amount'} ) {
-      #  $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
-      #                             $line_item->{'unit_amount'};
-      #}
-      #$detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
-
-      #$detail->{'sdate'} = $line_item->{'sdate'};
-      #$detail->{'edate'} = $line_item->{'edate'};
-      #$detail->{'seconds'} = $line_item->{'seconds'};
-      #$detail->{'svc_label'} = $line_item->{'svc_label'};
-      #$detail->{'usage_item'} = $line_item->{'usage_item'};
+      push @buf, ( [ $line_item->{'description'},
+                     $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+                   ],
+                   map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
+                 );
+
       $line_item->{'ref'} = $line_item->{'pkgnum'};
       $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
       $line_item->{'section'} = $section;
       $line_item->{'description'} = &$escape_function($line_item->{'description'});
-      if (!$old_latex) { # dubious; templates should provide this
-        $line_item->{'amount'} = $money_char.$line_item->{'amount'};
+      $line_item->{'amount'} = $money_char.$line_item->{'amount'};
+
+      if ( length($line_item->{'unit_amount'}) ) {
         $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
       }
       $line_item->{'ext_description'} ||= [];
  
       push @detail_items, $line_item;
-      push @buf, ( [ $line_item->{'description'},
-                     $money_char. sprintf("%10.2f", $line_item->{'amount'}),
-                   ],
-                   map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
-                 );
     }
 
     if ( $section->{'description'} ) {
@@ -1145,6 +1210,7 @@ sub print_generic {
   # if there's anything in the Previous Charges section, prepend it to the list
   if ( $pr_total and $previous_section ne $default_section ) {
     unshift @sections, $previous_section;
+    # but not @summary_subtotals
   }
 
   warn "$me adding taxes\n"
@@ -1160,13 +1226,12 @@ sub print_generic {
 
     if ( $multisection ) {
 
-      my $money = $old_latex ? '' : $money_char;
       push @detail_items, {
         ext_description => [],
         ref          => '',
         quantity     => '',
         description  => $description,
-        amount       => $money. $amount,
+        amount       => $money_char. $amount,
         product_code => '',
         section      => $tax_section,
       };
@@ -1197,8 +1262,11 @@ sub print_generic {
                                    sprintf('%.2f', $taxtotal);
       $tax_section->{'pretotal'} = 'New charges sub-total '.
                                    $total->{'total_amount'};
-      push @sections, $tax_section if $taxtotal;
-    }else{
+      if ( $taxtotal ) {
+        push @sections, $tax_section;
+        push @summary_subtotals, $tax_section;
+      }
+    } else {
       unshift @total_items, $total;
     }
   }
@@ -1291,13 +1359,12 @@ sub print_generic {
         $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
         $adjusttotal += $credit->{'amount'};
         if ( $multisection ) {
-          my $money = $old_latex ? '' : $money_char;
           push @detail_items, {
             ext_description => [],
             ref          => '',
             quantity     => '',
             description  => &$escape_function($credit->{'description'}),
-            amount       => $money. $credit->{'amount'},
+            amount       => $money_char . $credit->{'amount'},
             product_code => '',
             section      => $adjust_section,
           };
@@ -1326,13 +1393,12 @@ sub print_generic {
         $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
         $adjusttotal += $payment->{'amount'};
         if ( $multisection ) {
-          my $money = $old_latex ? '' : $money_char;
           push @detail_items, {
             ext_description => [],
             ref          => '',
             quantity     => '',
             description  => &$escape_function($payment->{'description'}),
-            amount       => $money. $payment->{'amount'},
+            amount       => $money_char . $payment->{'amount'},
             product_code => '',
             section      => $adjust_section,
           };
@@ -1350,6 +1416,8 @@ sub print_generic {
                                         sprintf('%.2f', $adjusttotal);
         push @sections, $adjust_section
           unless $adjust_section->{sort_weight};
+        # do not summarize; adjustments there are shown according to 
+        # different rules
       }
 
       # create Balance Due message
@@ -1428,7 +1496,7 @@ sub print_generic {
       'no_subtotal' => 1,
     };
 
-    push @sections, $discount_section;
+    push @sections, $discount_section; # do not summarize
     push @detail_items, map { +{
         'ref'         => '', #should this be something else?
         'section'     => $discount_section,
@@ -1438,23 +1506,7 @@ sub print_generic {
     } } @discounts_avail;
   }
 
-  my @summary_subtotals;
-  # the templates say "$_->{tax_section} || !$_->{summarized}"
-  # except 'summarized' is only true when tax_section is true, so this 
-  # is always true, so what's the deal?
-  foreach my $s (@sections) {
-    # not to include in the "summary of new charges" block:
-    # finance charges, adjustments, previous charges, 
-    # and itemized phone usage sections
-    if ( $s eq $adjust_section   or
-         ($s eq $previous_section and $s ne $default_section) or
-         ($invoice_data{'finance_section'} and 
-          $invoice_data{'finance_section'} eq $s->{description}) or
-         $s->{'description'} =~ /^\d+ $/ ) {
-      next;
-    }
-    push @summary_subtotals, $s;
-  }
+  # not adding any more sections after this
   $invoice_data{summary_subtotals} = \@summary_subtotals;
 
   # usage subtotals
@@ -1462,7 +1514,7 @@ sub print_generic {
        and $self->can('_items_usage_class_summary') ) {
     my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function);
     if ( @usage_subtotals ) {
-      unshift @sections, $usage_subtotals[0]->{section};
+      unshift @sections, $usage_subtotals[0]->{section}; # do not summarize
       unshift @detail_items, @usage_subtotals;
     }
   }
@@ -1795,6 +1847,10 @@ sub _translate_old_latex_format {
   (@template);
 }
 
+=item terms
+
+=cut
+
 sub terms {
   my $self = shift;
   my $conf = $self->conf;
@@ -1806,10 +1862,21 @@ sub terms {
   my $cust_main = $self->cust_main;
   return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
 
+  my $agentnum = '';
+  if ( $cust_main ) {
+    $agentnum = $cust_main->agentnum;
+  } elsif ( my $prospect_main = $self->prospect_main ) {
+    $agentnum = $prospect_main->agentnum;
+  }
+
   #use configured default
-  $conf->config('invoice_default_terms') || '';
+  $conf->config('invoice_default_terms', $agentnum) || '';
 }
 
+=item due_date
+
+=cut
+
 sub due_date {
   my $self = shift;
   my $duedate = '';
@@ -1819,11 +1886,19 @@ sub due_date {
   $duedate;
 }
 
+=item due_date2str
+
+=cut
+
 sub due_date2str {
   my $self = shift;
   $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
 }
 
+=item balance_due_msg
+
+=cut
+
 sub balance_due_msg {
   my $self = shift;
   my $msg = $self->mt('Balance Due');
@@ -1837,12 +1912,16 @@ sub balance_due_msg {
   $msg;
 }
 
+=item balance_due_date
+
+=cut
+
 sub balance_due_date {
   my $self = shift;
   my $conf = $self->conf;
   my $duedate = '';
-  if (    $conf->exists('invoice_default_terms') 
-       && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
+  my $terms = $self->terms;
+  if ( $terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
     $duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
   }
   $duedate;
@@ -1877,6 +1956,348 @@ sub _date_pretty_unlocalized {
   time2str($date_format, $self->_date);
 }
 
+=item email HASHREF
+
+Emails this template.
+
+Options are passed as a hashref.  Available options:
+
+=over 4
+
+=item from
+
+If specified, overrides the default From: address.
+
+=item notice_name
+
+If specified, overrides the name of the sent document ("Invoice" or "Quotation")
+
+=item template
+
+(Deprecated) If specified, is the name of a suffix for alternate template files.
+
+=back
+
+Options accepted by generate_email can also be used.
+
+=cut
+
+sub email {
+  my $self = shift;
+  my $opt = shift || {};
+  if ($opt and !ref($opt)) {
+    die ref($self). '->email called with positional parameters';
+  }
+
+  return if $self->hide;
+
+  my $error = send_email(
+    $self->generate_email(
+      'subject'     => $self->email_subject($opt->{template}),
+      %$opt, # template, etc.
+    )
+  );
+
+  die "can't email: $error\n" if $error;
+}
+
+=item generate_email OPTION => VALUE ...
+
+Options:
+
+=over 4
+
+=item from
+
+sender address, required
+
+=item template
+
+alternate template name, optional
+
+=item print_text
+
+text attachment arrayref, optional
+
+=item subject
+
+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>.
+
+=cut
+
+use MIME::Entity;
+
+sub generate_email {
+
+  my $self = shift;
+  my %args = @_;
+  my $conf = $self->conf;
+
+  my $me = '[FS::Template_Mixin::generate_email]';
+
+  my %return = (
+    'from'      => $args{'from'},
+    'subject'   => ($args{'subject'} || $self->email_subject),
+    'custnum'   => $self->custnum,
+    'msgtype'   => 'invoice',
+  );
+
+  $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
+
+  my $cust_main = $self->cust_main;
+
+  if (ref($args{'to'}) eq 'ARRAY') {
+    $return{'to'} = $args{'to'};
+  } elsif ( $cust_main ) {
+    $return{'to'} = [ $cust_main->invoicing_list_emailonly ];
+  }
+
+  my $tc = $self->template_conf;
+
+  if ( $conf->exists($tc.'html') ) {
+
+    warn "$me creating HTML/text multipart message"
+      if $DEBUG;
+
+    $return{'nobody'} = 1;
+
+    my $alternative = build MIME::Entity
+      'Type'        => 'multipart/alternative',
+      #'Encoding'    => '7bit',
+      'Disposition' => 'inline'
+    ;
+
+    my $data = '';
+    if ( $conf->exists($tc. 'email_pdf')
+         and scalar($conf->config($tc. 'email_pdf_note')) ) {
+
+      warn "$me using '${tc}email_pdf_note' in multipart message"
+        if $DEBUG;
+      $data = [ map { $_ . "\n" }
+                    $conf->config($tc.'email_pdf_note')
+              ];
+
+    } else {
+
+      warn "$me not using '${tc}email_pdf_note' in multipart message"
+        if $DEBUG;
+      if ( ref($args{'print_text'}) eq 'ARRAY' ) {
+        $data = $args{'print_text'};
+      } elsif ( $conf->exists($tc.'template') ) { #plaintext invoice_template
+        $data = [ $self->print_text(\%args) ];
+      }
+
+    }
+
+    if ( $data ) {
+      $alternative->attach(
+        'Type'        => 'text/plain',
+        'Encoding'    => 'quoted-printable',
+        'Charset'     => 'UTF-8',
+        #'Encoding'    => '7bit',
+        'Data'        => $data,
+        'Disposition' => 'inline',
+      );
+    }
+
+    my $htmldata;
+    my $image = '';
+    my $barcode = '';
+    if ( $conf->exists($tc.'email_pdf')
+         and scalar($conf->config($tc.'email_pdf_note')) ) {
+
+      $htmldata = join('<BR>', $conf->config($tc.'email_pdf_note') );
+
+    } else {
+
+      $args{'from'} =~ /\@([\w\.\-]+)/;
+      my $from = $1 || 'example.com';
+      my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+
+      my $logo;
+      my $agentnum = $cust_main ? $cust_main->agentnum
+                                : $self->prospect_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>",
+      ;
+   
+      if ( ref($self) eq 'FS::cust_bill' && $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>",
+        ;
+        $args{'barcode_cid'} = $barcode_content_id;
+      }
+
+      $htmldata = $self->print_html({ 'cid'=>$content_id, %args });
+    }
+
+    $alternative->attach(
+      'Type'        => 'text/html',
+      'Encoding'    => 'quoted-printable',
+      'Data'        => [ '<html>',
+                         '  <head>',
+                         '    <title>',
+                         '      '. encode_entities($return{'subject'}), 
+                         '    </title>',
+                         '  </head>',
+                         '  <body bgcolor="#e8e8e8">',
+                         $htmldata,
+                         '  </body>',
+                         '</html>',
+                       ],
+      'Disposition' => 'inline',
+      #'Filename'    => 'invoice.pdf',
+    );
+
+
+    my @otherparts = ();
+    if ( ref($self) eq 'FS::cust_bill' && $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($tc.'email_pdf') ) {
+
+      #attaching pdf too:
+      # multipart/mixed
+      #   multipart/related
+      #     multipart/alternative
+      #       text/plain
+      #       text/html
+      #     image/png
+      #   application/pdf
+
+      my $related = build MIME::Entity 'Type'     => 'multipart/related',
+                                       'Encoding' => '7bit';
+
+      #false laziness w/Misc::send_email
+      $related->head->replace('Content-type',
+        $related->mime_type.
+        '; boundary="'. $related->head->multipart_boundary. '"'.
+        '; type=multipart/alternative'
+      );
+
+      $related->add_part($alternative);
+
+      $related->add_part($image) if $image;
+
+      my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
+
+      $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
+
+    } else {
+
+      #no other attachment:
+      # multipart/related
+      #   multipart/alternative
+      #     text/plain
+      #     text/html
+      #   image/png
+
+      $return{'content-type'} = 'multipart/related';
+      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';
+
+    }
+  
+  } else {
+
+    if ( $conf->exists($tc.'email_pdf') ) {
+      warn "$me creating PDF attachment"
+        if $DEBUG;
+
+      #mime parts arguments a la MIME::Entity->build().
+      $return{'mimeparts'} = [
+        { $self->mimebuild_pdf(\%args) }
+      ];
+    }
+  
+    if ( $conf->exists($tc.'email_pdf')
+         and scalar($conf->config($tc.'email_pdf_note')) ) {
+
+      warn "$me using '${tc}email_pdf_note'"
+        if $DEBUG;
+      $return{'body'} = [ map { $_ . "\n" }
+                              $conf->config($tc.'email_pdf_note')
+                        ];
+
+    } else {
+
+      warn "$me not using '${tc}email_pdf_note'"
+        if $DEBUG;
+      if ( ref($args{'print_text'}) eq 'ARRAY' ) {
+        $return{'body'} = $args{'print_text'};
+      } else {
+        $return{'body'} = [ $self->print_text(\%args) ];
+      }
+
+    }
+
+  }
+
+  %return;
+
+}
+
+=item mimebuild_pdf
+
+Returns a list suitable for passing to MIME::Entity->build(), representing
+this invoice as PDF attachment.
+
+=cut
+
+sub mimebuild_pdf {
+  my $self = shift;
+  (
+    'Type'        => 'application/pdf',
+    'Encoding'    => 'base64',
+    'Data'        => [ $self->print_pdf(@_) ],
+    'Disposition' => 'attachment',
+    'Filename'    => 'invoice-'. $self->invnum. '.pdf',
+  );
+}
+
 =item _items_sections OPTIONS
 
 Generate section information for all items appearing on this invoice.
@@ -2034,27 +2455,20 @@ sub _items_sections {
                   ! $cust_bill_pkg->feepart   and
                   ! $section;
 
-          if (! $type || $type eq 'S') {
+          if ( $type eq 'S' ) {
             $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
               if $cust_bill_pkg->setup != 0
               || $cust_bill_pkg->setup_show_zero;
-          }
-
-          if (! $type) {
-            $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
-              if $cust_bill_pkg->recur != 0
-              || $cust_bill_pkg->recur_show_zero;
-          }
-
-          if ($type && $type eq 'R') {
+          } elsif ( $type eq 'R' ) {
             $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
               if $cust_bill_pkg->recur != 0
               || $cust_bill_pkg->recur_show_zero;
-          }
-          
-          if ($type && $type eq 'U') {
+          } elsif ( $type eq 'U' ) {
             $subtotal{$locationnum}{$section} += $usage
               unless scalar(@extra_sections);
+          } elsif ( !$type ) {
+            $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
+                                               + $cust_bill_pkg->recur;
           }
 
         }
@@ -2100,15 +2514,16 @@ sub _items_sections {
           $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
                                       $locationnum;
           $section->{'location'} = {
+            label_prefix => &{ $escape }($location->label_prefix),
             map { $_ => &{ $escape }($location->get($_)) }
-            $location->fields
+              $location->fields
           };
         } else {
           $section->{'category'} = $sectionname;
           $section->{'description'} = &{ $escape }($sectionname);
-          if ( _pkg_category($_) ) {
-            $section->{'sort_weight'} = _pkg_category($_)->weight;
-            if ( _pkg_category($_)->condense ) {
+          if ( _pkg_category($sectionname) ) {
+            $section->{'sort_weight'} = _pkg_category($sectionname)->weight;
+            if ( _pkg_category($sectionname)->condense ) {
               $section = { %$section, $self->_condense_section($opt{format}) };
             }
           }
@@ -2550,6 +2965,9 @@ location (whichever is defined).
 multisection: a flag indicating that this is a multisection invoice,
 which does something complicated.
 
+preref_callback: coderef run for each line item, code should return HTML to be
+displayed before that line item (quotations only)
+
 Returns a list of hashrefs, each of which may contain:
 
 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
@@ -2583,16 +3001,21 @@ sub _items_cust_bill_pkg {
   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
                                    # and location labels
 
-  my @b = ();
-  my ($s, $r, $u) = ( undef, undef, undef );
+  my @b = (); # accumulator for the line item hashes that we'll return
+  my ($s, $r, $u, $d) = ( undef, undef, undef, undef );
+            # the 'current' line item hashes for setup, recur, usage, discount
   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
   {
-
-    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+    # if the current line item is waiting to go out, and the one we're about
+    # to start is not bundled, then push out the current one and start a new
+    # one.
+    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
       if ( $_ && !$cust_bill_pkg->hidden ) {
-        $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
+        $_->{amount}      = sprintf( "%.2f", $_->{amount} );
         $_->{amount}      =~ s/^\-0\.00$/0.00/;
-        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
+        if (exists($_->{unit_amount})) {
+          $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
+        }
         push @b, { %$_ }
           if $_->{amount} != 0
           || $discount_show_always
@@ -2663,6 +3086,7 @@ sub _items_cust_bill_pkg {
         # quotation_pkgs are never fees, so don't worry about the case where
         # part_pkg is undefined
 
+        # and I guess they're never bundled either?
         if ( $cust_bill_pkg->setup != 0 ) {
           my $description = $desc;
           $description .= ' Setup'
@@ -2670,18 +3094,33 @@ sub _items_cust_bill_pkg {
             || $discount_show_always
             || $cust_bill_pkg->recur_show_zero;
           push @b, {
+            'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
             'description' => $description,
             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
+            'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup),
+            'quantity'    => $cust_bill_pkg->quantity,
+            'preref_html' => ( $opt{preref_callback}
+                                 ? &{ $opt{preref_callback} }( $cust_bill_pkg )
+                                 : ''
+                             ),
           };
         }
         if ( $cust_bill_pkg->recur != 0 ) {
           push @b, {
+            'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
             'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
+            'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur),
+            'quantity'    => $cust_bill_pkg->quantity,
+           'preref_html'  => ( $opt{preref_callback}
+                                 ? &{ $opt{preref_callback} }( $cust_bill_pkg )
+                                 : ''
+                             ),
           };
         }
 
-      } elsif ( $cust_bill_pkg->pkgnum > 0 ) { # and it's not a quotation_pkg
+      } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
+        # a "normal" package line item (not a quotation, not a fee, not a tax)
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
           if $DEBUG > 1;
@@ -2960,6 +3399,89 @@ sub _items_cust_bill_pkg {
 
         } # recurring or usage with recurring charge
 
+        # decide whether to show active discounts here
+        if (
+            # case 1: we are showing a single line for the package
+            ( !$type )
+            # case 2: we are showing a setup line for a package that has
+            # no base recurring fee
+            or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
+            # case 3: we are showing a recur line for a package that has 
+            # a base recurring fee
+            or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
+        ) {
+
+          # the line item hashref for the line that will show the original
+          # price
+          # (use the recur or single line for the package, unless we're 
+          # showing a setup line for a package with no recurring fee)
+          my $active_line = $r;
+          if ( $type eq 'S' ) {
+            $active_line = $s;
+          }
+
+          my @discounts = $cust_bill_pkg->cust_bill_pkg_discount;
+          # special case: if there are old "discount details" on this line 
+          # item, don't show discount line items
+          if ( FS::cust_bill_pkg_detail->count(
+              "detail LIKE 'Includes discount%' AND billpkgnum = " .
+              $cust_bill_pkg->billpkgnum
+             ) > 0 ) {
+             @discounts = ();
+          }
+          if ( @discounts ) {
+            warn "$me _items_cust_bill_pkg including discounts for ".
+              $cust_bill_pkg->billpkgnum."\n"
+              if $DEBUG;
+            my $discount_amount = sum( map {$_->amount} @discounts );
+            # if multiple discounts apply to the same package, how to display
+            # them? ext_description lines, apparently
+            #
+            # # discount amounts are negative
+            if ( $d and $cust_bill_pkg->hidden ) {
+              $d->{amount}      -= $discount_amount;
+            } else {
+              my @ext;
+              $d = {
+                _is_discount    => 1,
+                description     => $self->mt('Discount'),
+                amount          => -1 * $discount_amount,
+                ext_description => \@ext,
+              };
+              foreach my $cust_bill_pkg_discount (@discounts) {
+                my $discount = $cust_bill_pkg_discount->cust_pkg_discount->discount;
+                my $discount_desc = $discount->description_short;
+
+                if ($discount->months) {
+
+                  # calculate months remaining after this invoice
+                  my $used = FS::Record->scalar_sql(
+                    'SELECT SUM(months) FROM cust_bill_pkg_discount
+                      JOIN cust_bill_pkg USING (billpkgnum)
+                      JOIN cust_bill USING (invnum)
+                      WHERE pkgdiscountnum = ? AND _date <= ?',
+                    $cust_bill_pkg_discount->pkgdiscountnum,
+                    $self->_date
+                  );
+                  $used ||= 0;
+                  my $remaining = sprintf('%.2f', $discount->months - $used);
+                  # append "for X months (Y months remaining)"
+                  $discount_desc .= $self->mt(' for [quant,_1,month] ([quant,_2,month] remaining)',
+                    $cust_bill_pkg_discount->months,
+                    $remaining
+                  );
+                } # else it's not time-limited
+                push @ext, &{$escape_function}($discount_desc);
+              }
+            }
+
+            # update the active line (before the discount) to show the 
+            # original price (whether this is a hidden line or not)
+            $active_line->{amount} += $discount_amount;
+            
+          } # if there are any discounts
+        } # if this is an appropriate place to show discounts
+
       } else { # taxes and fees
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
@@ -2981,13 +3503,14 @@ sub _items_cust_bill_pkg {
 
   }
 
-  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
     if ( $_  ) {
       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
         if exists($_->{amount});
       $_->{amount}      =~ s/^\-0\.00$/0.00/;
-      $_->{unit_amount} = sprintf('%.2f', $_->{unit_amount})
-        if exists($_->{unit_amount});
+      if (exists($_->{unit_amount})) {
+        $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
+      }
 
       push @b, { %$_ }
         if $_->{amount} != 0