RT# 81961 Repair broken links in POD documentation
[freeside.git] / FS / FS / Template_Mixin.pm
index 44d44e1..76680d2 100644 (file)
@@ -10,20 +10,23 @@ use vars qw( $invoice_lines @buf ); #yuck
 use List::Util qw(sum); #can't import first, it conflicts with cust_main.first
 use Date::Format;
 use Date::Language;
+use Time::Local qw( timelocal );
 use Text::Template 1.20;
 use File::Temp 0.14;
+use Archive::Zip qw( :ERROR_CODES :CONSTANTS );
+use IO::Scalar;
 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::Record qw( qsearch qsearchs dbh );
 use FS::Conf;
 use FS::Misc qw( generate_ps generate_pdf );
 use FS::pkg_category;
 use FS::pkg_class;
 use FS::invoice_mode;
 use FS::L10N;
+use FS::Log;
 
 $DEBUG = 0;
 $me = '[FS::Template_Mixin]';
@@ -146,6 +149,10 @@ sub print_latex {
   $template ||= $self->_agent_template
     if $self->can('_agent_template');
 
+  #the new way
+  $self->set('mode', $params{mode})
+    if $params{mode};
+
   my $pkey = $self->primary_key;
   my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
 
@@ -293,7 +300,7 @@ before that line item (quotations only)
 
 =item template
 
-Dprecated.  Used as a suffix for a configuration template.  Please 
+Deprecated.  Used as a suffix for a configuration template.  Please
 don't use this, it deprecated in favor of more flexible alternatives.
 
 =back
@@ -560,6 +567,7 @@ sub print_generic {
     'notice_name'     => $notice_name, # escape?
     'current_charges' => sprintf("%.2f", $self->charged),
     'duedate'         => $self->due_date2str('rdate'), #date_format?
+    'duedate_long'    => $self->due_date2str('long'),
 
     #customer info
     'custnum'         => $cust_main->display_custnum,
@@ -649,7 +657,7 @@ sub print_generic {
   if ( $cust_main->country eq $countrydefault ) {
     $invoice_data{'country'} = '';
   } else {
-    $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
+    $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full);
   }
 
   my @address = ();
@@ -685,7 +693,12 @@ sub print_generic {
   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
 #  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;
+  my $balance_due = $self->owed;
+  if ( $self->enable_previous ) {
+    $balance_due += $pr_total;
+  }
+  # otherwise the previous balance is not shown, so including it in the
+  # balance due is just confusing
 
   # the sum of amount owed on all invoices
   # (this is used in the summary & on the payment coupon)
@@ -708,6 +721,8 @@ 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.
+      #
+      # still do this for the "Previous Balance" line of the summary block
       my @sql =
         map "$_ WHERE _date <= ? AND custnum = ?", (
           "SELECT      COALESCE( SUM(charged), 0 ) FROM cust_bill",
@@ -740,19 +755,31 @@ sub print_generic {
       # longer stored in the database)
       $invoice_data{'true_previous_balance'} = $last_bill_balance;
 
-      # the change in balance from immediately after that invoice
-      # to immediately before this one
-      my $before_this_bill_balance = 0;
+      # Now, get all applications of credits/payments dated on or after the
+      # previous bill, to invoices before the current bill. (The
+      # credit/payment date restriction prevents these from intersecting
+      # the "Previous Balance" set.)
+      # These are "adjustments". The past due balance will be shown as
+      # Previous Balance - Adjustments.
+      my $adjustments = 0;
+      @sql = map {
+        "SELECT COALESCE(SUM(y.amount),0) FROM $_ JOIN cust_bill USING (invnum)
+         WHERE cust_bill._date < ?
+           AND x._date >= ?
+           AND cust_bill.custnum = ?"
+        } "cust_credit AS x JOIN cust_credit_bill y USING (crednum)",
+          "cust_pay    AS x JOIN cust_bill_pay    y USING (paynum)"
+      ;
       foreach (@sql) {
         my $delta = FS::Record->scalar_sql(
           $_,
-          $self->_date - 1,
+          $self->_date,
+          $last_bill->_date,
           $self->custnum,
         );
-        $before_this_bill_balance += $delta;
+        $adjustments += $delta;
       }
-      $invoice_data{'balance_adjustments'} =
-        sprintf("%.2f", $last_bill_balance - $before_this_bill_balance);
+      $invoice_data{'balance_adjustments'} = sprintf("%.2f", $adjustments);
 
       warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n",
                    $invoice_data{'balance_adjustments'}
@@ -805,7 +832,7 @@ sub print_generic {
       $invoice_data{'previous_credits'} = [];
     }
  
-    if ( $conf->exists('invoice_usesummary', $agentnum) ) {
+    if ( $conf->config_bool('invoice_usesummary', $agentnum) ) {
       $invoice_data{'summarypage'} = $summarypage = 1;
     }
 
@@ -818,35 +845,36 @@ sub print_generic {
   my @include = ( [ $tc,        'notes' ],
                   [ 'invoice_', 'footer' ],
                   [ 'invoice_', 'smallfooter', ],
+                  [ 'invoice_', 'watermark' ],
                 );
   push @include, [ $tc,        'coupon', ]
     unless $params{'no_coupon'};
 
   foreach my $i (@include) {
 
+    # load the configuration for this sub-template
+
     my($base, $include) = @$i;
 
     my $inc_file = $conf->key_orbase("$base$format$include", $template);
-    my @inc_src;
-
-    if ( $conf->exists($inc_file, $agentnum)
-         && length( $conf->config($inc_file, $agentnum) ) ) {
-
-      @inc_src = $conf->config($inc_file, $agentnum);
-
-    } else {
 
-      $inc_file = $conf->key_orbase("${base}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_src = $conf->config($inc_file, $agentnum);
+    if (!@inc_src) {
+      my $converter = $convert_maps{$format}{$include};
+      if ( $converter ) {
+        # then attempt to convert LaTeX to the requested format
+        $inc_file = $conf->key_orbase($base.'latex'.$include, $template);
+        @inc_src = &$converter( $conf->config($inc_file, $agentnum) );
+        foreach (@inc_src) {
+          # this isn't included in the convert_maps
+          my ($open, $close) = @{ $delimiters{$format} };
+          s/\[\@--/$open/g;
+          s/--\@\]/$close/g;
+        }
+      }
+    } # else @inc_src is empty and that's fine
 
-    }
+    # make a Text::Template out of it
 
     my $inc_tt = new Text::Template (
       TYPE       => 'ARRAY',
@@ -860,6 +888,8 @@ sub print_generic {
       die $error;
     }
 
+    # fill in variables
+
     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
 
     $invoice_data{$include} =~ s/\n+$//
@@ -907,9 +937,12 @@ sub print_generic {
     if $DEBUG > 1;
 
   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
-  my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
-                     $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
-  $invoice_data{'multisection'} = $multisection;
+  my $multisection = $self->has_sections;
+  if ( $multisection ) {
+    $invoice_data{multisection} = $conf->config($tc.'sections_method') || 1;
+  }
+  my $section_with_taxes = 1
+    if $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum);
   my $late_sections;
   my $extra_sections = [];
   my $extra_lines = ();
@@ -1036,7 +1069,7 @@ sub print_generic {
   # 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') ) {
+  if ( ( ref($self) ne 'FS::quotation' ) and $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' ) {
@@ -1127,14 +1160,27 @@ sub print_generic {
       if ( $invoice_data{finance_section} &&
            $section->{'description'} eq $invoice_data{finance_section} );
 
-    $section->{'subtotal'} = $other_money_char.
-                             sprintf('%.2f', $section->{'subtotal'})
-      if $multisection;
+    if ( $multisection ) {
+
+      if ( ref($section->{'subtotal'}) ) {
+
+        $section->{'subtotal'} =
+          sprintf("$other_money_char%.2f to $other_money_char%.2f",
+                    $section->{'subtotal'}[0],
+                    $section->{'subtotal'}[1]
+                 );
+
+      } else {
 
-    # continue some normalization
-    $section->{'amount'}   = $section->{'subtotal'}
-      if $multisection;
+        $section->{'subtotal'} = $other_money_char.
+                                 sprintf('%.2f', $section->{'subtotal'})
 
+      }
+
+      # continue some normalization
+      $section->{'amount'}   = $section->{'subtotal'}
+
+    }
 
     if ( $section->{'description'} ) {
       push @buf, ( [ &$escape_function($section->{'description'}), '' ],
@@ -1147,6 +1193,9 @@ sub print_generic {
 
     my %options = ();
     $options{'section'} = $section if $multisection;
+    $options{'section_with_taxes'} = 1
+      if $multisection
+      && $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum);
     $options{'format'} = $format;
     $options{'escape_function'} = $escape_function;
     $options{'no_usage'} = 1 unless $unsquelched;
@@ -1155,10 +1204,15 @@ sub print_generic {
     $options{'skip_usage'} =
       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
     $options{'preref_callback'} = $params{'preref_callback'};
+    $options{'disable_line_item_date_ranges'} =
+      $conf->exists('disable_line_item_date_ranges');
 
     warn "$me   searching for line items\n"
       if $DEBUG > 1;
 
+    my %section_tax_lines;
+    my %seen_tax_lines;
+
     foreach my $line_item ( $self->_items_pkg(%options),
                             $self->_items_fee(%options) ) {
 
@@ -1182,10 +1236,55 @@ sub print_generic {
         $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
       }
       $line_item->{'ext_description'} ||= [];
+
+      if ( $section_with_taxes && ref $line_item->{pkg_tax} ) {
+        for my $line_tax ( @{$ line_item->{pkg_tax} } ) {
+
+          # It is rarely possible for the same tax record to be presented here
+          # multiple times.  See cust_bill_pkg::_pkg_tax_list for more info
+          next if $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} };
+          $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} } = 1;
+
+          $section_tax_lines{ $line_tax->{taxname} } += $line_tax->{amount};
+        }
+      }
+
       push @detail_items, $line_item;
     }
 
+    # If conf flag invoice_sections_with_taxes:
+    # - Add @detail_items for taxes into each section
+    # - Update section subtotal to include taxes
+    if ( $section_with_taxes && %section_tax_lines ) {
+      for my $taxname ( keys %section_tax_lines ) {
+
+        push @detail_items, {
+          section => $section,
+          amount  => sprintf($money_char."%.2f",$section_tax_lines{$taxname}),
+          description => &$escape_function($taxname),
+        };
+
+        # Append taxes to total.  If line format resembles "$5.00 to $12.00"
+        # append to the second value.
+        if ($section->{subtotal} =~ /to/) {
+          my @subtotal = split /\s/, $section->{subtotal};
+          $subtotal[2] =~ s/[^\d\.]//g;
+          $subtotal[2] = sprintf(
+            $money_char."%.2f",
+            ( $subtotal[2] + $section_tax_lines{$taxname} )
+          );
+          $section->{subtotal} = join ' ', @subtotal;
+        } else {
+        $section->{subtotal} =~ s/[^\d\.]//g;
+          $section->{subtotal} = sprintf(
+            $money_char . "%.2f",
+            ( $section->{subtotal} + $section_tax_lines{$taxname} )
+          );
+        }
+
+      }
+    }
+
     if ( $section->{'description'} ) {
       push @buf, ( ['','-----------'],
                    [ $section->{'description'}. ' sub-total',
@@ -1274,17 +1373,31 @@ sub print_generic {
 
     if ( $multisection ) {
       if ( $taxtotal > 0 ) {
+        # there are taxes, so prepare the section to be displayed.
+        # $taxtotal already includes any line items that were already in the
+        # section (fees, taxes that are charged as packages for some reason).
+        # also set 'summarized' to false so that this isn't a summary-only
+        # section.
         $tax_section->{'subtotal'} = $other_money_char.
                                      sprintf('%.2f', $taxtotal);
         $tax_section->{'pretotal'} = 'New charges sub-total '.
                                      $total->{'total_amount'};
         $tax_section->{'description'} = $self->mt($tax_description);
+        $tax_section->{'summarized'} = '';
+
+        if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) {
 
-        # append it if it's not already there
-        if ( !grep $tax_section, @sections ) {
+          # remove tax section if taxes are itemized within other sections
+          @sections = grep{ $_ ne $tax_section } @sections;
+
+        } elsif ( !grep $tax_section, @sections ) {
+
+          # append it if it's not already there
           push @sections, $tax_section;
           push @summary_subtotals, $tax_section;
+
         }
+
       }
 
     } else {
@@ -1327,7 +1440,16 @@ sub print_generic {
     foreach ( @new_total_items ) {
       my ($item, $amount) = ($_->{'total_item'}, $_->{'total_amount'});
       $_->{'total_item'}   = &$embolden_function( $item );
+
+      if ( ref($amount) ) {
+        $_->{'total_amount'} = &$embolden_function(
+                                 $other_money_char.$amount->[0]. ' to '.
+                                 $other_money_char.$amount->[1]
+                               );
+      } else {
       $_->{'total_amount'} = &$embolden_function( $other_money_char.$amount );
+      }
+
       # but if it's multisection, don't append to @total_items. the adjust
       # section has all this stuff
       push @total_items, $_ if !$multisection;
@@ -1509,7 +1631,7 @@ sub print_generic {
   # usage subtotals
   if ( $conf->exists('usage_class_summary')
        and $self->can('_items_usage_class_summary') ) {
-    my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function);
+    my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function, 'money_char' => $other_money_char);
     if ( @usage_subtotals ) {
       unshift @sections, $usage_subtotals[0]->{section}; # do not summarize
       unshift @detail_items, @usage_subtotals;
@@ -1606,24 +1728,24 @@ sub print_generic {
   die "no invoice_lines() functions in template?"
     if ( $format eq 'template' && !$wasfunc );
 
-  if ($format eq 'template') {
+  if ( $invoice_lines ) {
+    $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
+    $invoice_data{'total_pages'}++
+      if scalar(@buf) % $invoice_lines;
+  }
 
-    if ( $invoice_lines ) {
-      $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
-      $invoice_data{'total_pages'}++
-        if scalar(@buf) % $invoice_lines;
+  #setup subroutine for the template
+  $invoice_data{invoice_lines} = sub {
+    my $lines = shift || scalar(@buf);
+    map { 
+      scalar(@buf)
+        ? shift @buf
+        : [ '', '' ];
     }
+    ( 1 .. $lines );
+  };
 
-    #setup subroutine for the template
-    $invoice_data{invoice_lines} = sub {
-      my $lines = shift || scalar(@buf);
-      map { 
-        scalar(@buf)
-          ? shift @buf
-          : [ '', '' ];
-      }
-      ( 1 .. $lines );
-    };
+  if ($format eq 'template') {
 
     my $lines;
     my @collect;
@@ -1637,6 +1759,13 @@ sub print_generic {
 
   } else { # this is where we actually create the invoice
 
+    if ( $params{no_addresses} ) {
+      delete $invoice_data{$_} foreach qw(
+        payname company address1 address2 city state zip country
+      );
+      $invoice_data{returnaddress} = '~';
+    }
+
     warn "filling in template for invoice ". $self->invnum. "\n"
       if $DEBUG;
     warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
@@ -1879,6 +2008,12 @@ sub due_date {
   my $duedate = '';
   if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
     $duedate = $self->_date() + ( $1 * 86400 );
+  } elsif ( $self->terms =~ /^End of Month$/ ) {
+    my ($mon,$year) = (localtime($self->_date) )[4,5];
+    $mon++;
+    until ( $mon < 12 ) { $mon -= 12; $year++; }
+    my $nextmonth_first = timelocal(0,0,0,1,$mon,$year);
+    $duedate = $nextmonth_first - 86400;
   }
   $duedate;
 }
@@ -1892,6 +2027,23 @@ sub due_date2str {
   $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
 }
 
+=item invoice_pay_by_msg
+
+  displays the invoice_pay_by_msg or default Please pay by [_1] if empty.
+
+=cut
+
+sub invoice_pay_by_msg {
+  my $self = shift;
+  my $msg = '';
+  my $please_pay_by =
+        $self->conf->config('invoice_pay_by_msg', $self->agentnum)
+        || 'Please pay by [_1]';
+  $msg .= ' - ' . $self->mt($please_pay_by, $self->due_date2str('short')) . ' ';
+
+  $msg;
+}
+
 =item balance_due_msg
 
 =cut
@@ -1901,13 +2053,13 @@ sub balance_due_msg {
   my $msg = $self->mt('Balance Due');
   return $msg unless $self->terms; # huh?
   if ( !$self->conf->exists('invoice_show_prior_due_date')
-       or $self->conf->exists('invoice_sections') ) {
-    # if enabled, the due date is shown with Total New Charges (see 
+       || $self->has_sections ) {
+    # if enabled, the due date is shown with Total New Charges (see
     # _items_total) and not here
     # (yes, or if invoice_sections is enabled; this is just for compatibility)
     if ( $self->due_date ) {
-      $msg .= ' - ' . $self->mt('Please pay by'). ' '.
-        $self->due_date2str('short');
+      $msg .= $self->invoice_pay_by_msg
+       unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum);
     } elsif ( $self->terms ) {
       $msg .= ' - '. $self->mt($self->terms);
     }
@@ -2028,11 +2180,12 @@ notice name instead of "Invoice", optional
 
 =back
 
-Returns an argument list to be passed to L<FS::Misc::send_email>.
+Returns an argument list to be passed to L<FS::Misc/send_email>.
 
 =cut
 
 use MIME::Entity;
+use Encode;
 
 sub generate_email {
 
@@ -2094,12 +2247,11 @@ sub generate_email {
 
   if (!@text) {
 
-    if ( $conf->config($tc.'template') ) {
+    if ( $conf->exists($tc.'template') ) {
 
       warn "$me generating plain text invoice"
         if $DEBUG;
 
-      # 'print_text' argument is no longer used
       @text = $self->print_text(\%args);
 
     } else {
@@ -2116,7 +2268,11 @@ sub generate_email {
     'Encoding'    => 'quoted-printable',
     'Charset'     => 'UTF-8',
     #'Encoding'    => '7bit',
-    'Data'        => \@text,
+    'Data'        => [
+      map
+        { Encode::encode('UTF-8', $_, Encode::FB_WARN | Encode::LEAVE_SRC ) }
+        @text
+    ],
     'Disposition' => 'inline',
   );
 
@@ -2195,7 +2351,11 @@ sub generate_email {
                          '    </title>',
                          '  </head>',
                          '  <body bgcolor="#e8e8e8">',
-                         $html,
+                         Encode::encode(
+                           'UTF-8',
+                           $html,
+                           Encode::FB_WARN | Encode::LEAVE_SRC
+                         ),
                          '  </body>',
                          '</html>',
                        ],
@@ -2222,15 +2382,42 @@ sub generate_email {
   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->config('voip-cdr_email_attach') eq 'zip' ) {
+
+      my $data = join('', map "$_\n",
+                   $self->call_details(prepend_billed_number=>1)
+                 );
+
+      my $zip = new Archive::Zip;
+      my $file = $zip->addString( $data, 'usage-'.$self->invnum.'.csv' );
+      $file->desiredCompressionMethod( COMPRESSION_DEFLATED );
+
+      my $zipdata = '';
+      my $SH = IO::Scalar->new(\$zipdata);
+      my $status = $zip->writeToFileHandle($SH);
+      die "Error zipping CDR attachment: $!" unless $status == AZ_OK;
+
+      push @otherparts, build MIME::Entity
+        'Type'        => 'application/zip',
+        'Encoding'    => 'base64',
+        'Data'        => $zipdata,
+        'Disposition' => 'attachment',
+        'Filename'    => 'usage-'. $self->invnum. '.zip',
+      ;
+
+    } else { # } elsif ( $conf->config('voip-cdr_email_attach') eq 'csv' ) {
+      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',
+      ;
+
+    }
 
   }
 
@@ -2274,7 +2461,7 @@ sub generate_email {
 =item mimebuild_pdf
 
 Returns a list suitable for passing to MIME::Entity->build(), representing
-this invoice as PDF attachment.
+this quotation or invoice as PDF attachment.
 
 =cut
 
@@ -2285,8 +2472,112 @@ sub mimebuild_pdf {
     'Encoding'    => 'base64',
     'Data'        => [ $self->print_pdf(@_) ],
     'Disposition' => 'attachment',
-    'Filename'    => 'invoice-'. $self->invnum. '.pdf',
+    'Filename'    => $self->pdf_filename,
+  );
+}
+
+=item postal_mail_fsinc
+
+Sends this invoice to the Freeside Internet Services, Inc. print and mail
+service.
+
+=cut
+
+use CAM::PDF;
+use IO::Socket::SSL;
+use LWP::UserAgent;
+use HTTP::Request::Common qw( POST );
+use Cpanel::JSON::XS;
+use MIME::Base64;
+sub postal_mail_fsinc {
+  my ( $self, %opt ) = @_;
+
+  my $url = 'https://ws.freeside.biz/print';
+
+  my $cust_main = $self->cust_main;
+  my $agentnum = $cust_main->agentnum;
+  my $bill_location = $cust_main->bill_location;
+
+  die "Extra charges for international mailing; contact support\@freeside.biz to enable\n"
+    if $bill_location->country ne 'US';
+
+  my $conf = new FS::Conf;
+
+  my @company_address = $conf->config('company_address', $agentnum);
+  my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip );
+  if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+    $company_address1 = $company_address[0];
+    $company_address2 = $company_address[1];
+    $company_city  = $1;
+    $company_state = $2;
+    $company_zip   = $3;
+  } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+    $company_address1 = $company_address[0];
+    $company_address2 = '';
+    $company_city  = $1;
+    $company_state = $2;
+    $company_zip   = $3;
+  } else {
+    die "Unparsable company_address; contact support\@freeside.biz\n";
+  }
+  $company_city =~ s/,$//;
+
+  my $file = $self->print_pdf(%opt, 'no_addresses' => 1);
+  my $pages = CAM::PDF->new($file)->numPages;
+
+  my $ua = LWP::UserAgent->new(
+    'ssl_opts' => { 
+      verify_hostname => 0,
+      SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
+      SSL_version     => 'SSLv3',
+    }
   );
+  my $response = $ua->request( POST $url, [
+    'support-key'      => scalar($conf->config('support-key')),
+    'file'             => encode_base64($file),
+    'pages'            => $pages,
+
+    #from:
+    'company_name'     => scalar( $conf->config('company_name', $agentnum) ),
+    'company_address1' => $company_address1,
+    'company_address2' => $company_address2,
+    'company_city'     => $company_city,
+    'company_state'    => $company_state,
+    'company_zip'      => $company_zip,
+    'company_country'  => 'US',
+    'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)),
+    'company_email'    => scalar($conf->config('invoice_from', $agentnum)),
+
+    #to:
+    'name'             => ( $cust_main->payname
+                              && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/
+                                ? $cust_main->payname
+                                : $cust_main->contact_firstlast
+                          ),
+    'company'          => $cust_main->company,
+    'address1'         => $bill_location->address1,
+    'address2'         => $bill_location->address2,
+    'city'             => $bill_location->city,
+    'state'            => $bill_location->state,
+    'zip'              => $bill_location->zip,
+    'country'          => $bill_location->country,
+  ]);
+
+  die "Print connection error: ". $response->message.
+      ' ('. $response->as_string. ")\n"
+    unless $response->is_success;
+
+  local $@;
+  my $content = eval { decode_json($response->content) };
+  die "Print JSON error : $@\n" if $@;
+
+  die $content->{error}."\n"
+    if $content->{error};
+
+  #TODO: store this so we can query for a status later
+  warn "Invoice printed, ID ". $content->{id}. "\n";
+
+  $content->{id};
 }
 
 =item _items_sections OPTIONS
@@ -2398,7 +2689,13 @@ sub _items_sections {
       foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
         next if ( $display->summary && $opt{summary} );
 
-        my $section = $display->section;
+        #my $section = $display->section;
+        #false laziness with the method, but for efficiency inside this loop
+        my $section = $display->get('section');
+        if ( !$section && !$cust_bill_pkg->hidden ) {
+          $section = $cust_bill_pkg->get('categoryname'); #cust_bill->cust_bill_pkg added it (XXX quotations / quotation_section)
+        }
+
         my $type    = $display->type;
         # Set $section = undef if we're sectioning by location and this
         # line item _has_ a location (i.e. isn't a fee).
@@ -2489,7 +2786,6 @@ sub _items_sections {
       foreach my $sectionname (keys %{ $s->{$locationnum} }) {
         my $section = {
                         'subtotal'    => $s->{$locationnum}{$sectionname},
-                        'post_total'  => $post_total,
                         'sort_weight' => 0,
                       };
         if ( $locationnum ) {
@@ -2824,6 +3120,10 @@ sub _items_fee {
   my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
   my $escape_function = $options{escape_function};
 
+  my $locale = $self->cust_main
+             ? $self->cust_main->locale
+             : $self->prospect_main->locale;
+
   my @items;
   foreach my $cust_bill_pkg (@cust_bill_pkg) {
     # cache this, so we don't look it up again in every section
@@ -2865,14 +3165,18 @@ sub _items_fee {
           $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
         );
     }
-    my $desc = $part_fee->itemdesc_locale($self->cust_main->locale);
+    my $desc = $part_fee->itemdesc_locale($locale);
     # but not escape the base description line
 
+    my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+      if $options{section_with_taxes};
+
     push @items,
       { feepart     => $cust_bill_pkg->feepart,
         amount      => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
         description => $desc,
-        ext_description => \@ext_desc
+        pkg_tax     => \@pkg_tax,
+        ext_description => \@ext_desc,
         # sdate/edate?
       };
   }
@@ -2973,6 +3277,8 @@ 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)
 
+section_with_taxes:  Look up and include applied taxes for each record
+
 Returns a list of hashrefs, each of which may contain:
 
 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
@@ -2999,15 +3305,21 @@ sub _items_cust_bill_pkg {
   }
   my $summary_page = $opt{summary_page} || ''; #unused
   my $multisection = defined($category) || defined($locationnum);
-  my $discount_show_always = 0;
+  # this variable is the value of the config setting, not whether it applies
+  # to this particular line item.
+  my $discount_show_always = $conf->exists('discount-show-always');
 
   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
 
   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
 
+  my $agentnum = $self->agentnum;
+
   # for location labels: use default location on the invoice date
   my $default_locationnum;
-  if ( $self->custnum ) {
+  if ( $conf->exists('invoice-all_pkg_addresses') ) {
+    $default_locationnum = 0; # treat them all as non-default
+  } elsif ( $self->custnum ) {
     my $h_cust_main;
     my @h_search = FS::h_cust_main->sql_h_search($self->_date);
     $h_cust_main = qsearchs({
@@ -3039,11 +3351,13 @@ sub _items_cust_bill_pkg {
         if (exists($_->{unit_amount})) {
           $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
         }
-        push @b, { %$_ }
-          if $_->{amount} != 0
-          || $discount_show_always
-          || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
-          || (   $_->{_is_setup} && $_->{setup_show_zero} )
+        push @b, { %$_ };
+        # we already decided to create this display line; don't reconsider it
+        # now.
+        #  if $_->{amount} != 0
+        #  || $discount_show_always
+        #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+        #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
         ;
         $_ = undef;
       }
@@ -3102,6 +3416,9 @@ sub _items_cust_bill_pkg {
                           'no_usage'        => $opt{'no_usage'},
                         );
 
+      my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+        if $opt{section_with_taxes};
+
       if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
         # XXX this should be pulled out into quotation_pkg
 
@@ -3110,8 +3427,10 @@ sub _items_cust_bill_pkg {
         # quotation_pkgs are never fees, so don't worry about the case where
         # part_pkg is undefined
 
+        my @details = $cust_bill_pkg->details;
+
         # and I guess they're never bundled either?
-        if ( $cust_bill_pkg->setup != 0 ) {
+        if (( $cust_bill_pkg->setup != 0 ) || ( $cust_bill_pkg->setup_show_zero )) {
           my $description = $desc;
           $description .= ' Setup'
             if $cust_bill_pkg->recur != 0
@@ -3125,13 +3444,15 @@ sub _items_cust_bill_pkg {
             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
             'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup),
             'quantity'    => $cust_bill_pkg->quantity,
+            'pkg_tax'     => \@pkg_tax,
+            'ext_description' => \@details,
             'preref_html' => ( $opt{preref_callback}
                                  ? &{ $opt{preref_callback} }( $cust_bill_pkg )
                                  : ''
                              ),
           };
         }
-        if ( $cust_bill_pkg->recur != 0 ) {
+        if (( $cust_bill_pkg->recur != 0 ) || ( $cust_bill_pkg->recur_show_zero )) {
           #push @b, {
           $r = {
             'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
@@ -3139,6 +3460,8 @@ sub _items_cust_bill_pkg {
             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
             'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur),
             'quantity'    => $cust_bill_pkg->quantity,
+            'pkg_tax'     => \@pkg_tax,
+            'ext_description' => \@details,
            'preref_html'  => ( $opt{preref_callback}
                                  ? &{ $opt{preref_callback} }( $cust_bill_pkg )
                                  : ''
@@ -3153,6 +3476,27 @@ sub _items_cust_bill_pkg {
           if $DEBUG > 1;
  
         my $cust_pkg = $cust_bill_pkg->cust_pkg;
+
+        unless ( $cust_pkg ) {
+          # There is no related row in cust_pkg for this cust_bill_pkg.pkgnum.
+          # This invoice may have been broken by an unusual combination
+          # of manually editing package dates, and aborted package changes
+          # when the manually edited dates used are nonsensical.
+
+          my $error = sprintf
+            'cust_bill_pkg(billpkgnum:%s) '.
+            'is missing related row in cust_pkg(pkgnum:%s)! '.
+            'cust_bill(invnum:%s) is corrupted by bad database data, '.
+            'and should be investigated',
+              $cust_bill_pkg->billpkgnum,
+              $cust_bill_pkg->pkgnum,
+              $cust_bill_pkg->invnum;
+
+          FS::Log->new('FS::cust_bill_pkg')->critical( $error );
+          warn $error;
+          next;
+        }
+
         my $part_pkg = $cust_pkg->part_pkg;
 
         # which pkgpart to show for display purposes?
@@ -3170,6 +3514,7 @@ sub _items_cust_bill_pkg {
         if (    (!$type || $type eq 'S')
              && (    $cust_bill_pkg->setup != 0
                   || $cust_bill_pkg->setup_show_zero
+                  || ($discount_show_always and $cust_bill_pkg->unitsetup > 0)
                 )
            )
          {
@@ -3177,14 +3522,24 @@ sub _items_cust_bill_pkg {
           warn "$me _items_cust_bill_pkg adding setup\n"
             if $DEBUG > 1;
 
+          # append the word 'Setup' to the setup line if there's going to be
+          # a recur line for the same package (i.e. not a one-time charge) 
+          # XXX localization
           my $description = $desc;
           $description .= ' Setup'
             if $cust_bill_pkg->recur != 0
-            || $discount_show_always
+            || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
             || $cust_bill_pkg->recur_show_zero;
 
-          $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
-                                                              $self->agentnum )
+          my $disable_date_ranges =
+               $opt{disable_line_item_date_ranges}
+            || $part_pkg->option('disable_line_item_date_ranges', 1);
+
+          $description .= $cust_bill_pkg->time_period_pretty(
+                            $part_pkg,
+                            $agentnum,
+                            disable_date_ranges => $disable_date_ranges,
+                          )
             if $part_pkg->is_prepaid #for prepaid, "display the validity period
                                      # triggered by the recurring charge freq
                                      # (RT#26274)
@@ -3197,8 +3552,11 @@ sub _items_cust_bill_pkg {
           # always pass the svc_label through to the template, even if 
           # not displaying it as an ext_description
           my @svc_labels = map &{$escape_function}($_),
-                      $cust_pkg->h_labels_short($self->_date, undef, 'I');
-
+            $cust_pkg->h_labels_short($self->_date,
+                                      undef,
+                                      'I',
+                                      $self->conf->{locale},
+                                     );
           $svc_label = $svc_labels[0];
 
           unless ( $cust_pkg->part_pkg->hide_svc_detail
@@ -3236,6 +3594,7 @@ sub _items_cust_bill_pkg {
               setup_show_zero => $cust_bill_pkg->setup_show_zero,
               unit_amount     => $cust_bill_pkg->unitsetup,
               quantity        => $cust_bill_pkg->quantity,
+              pkg_tax         => \@pkg_tax,
               ext_description => \@d,
               svc_label       => ($svc_label || ''),
               locationnum     => $cust_pkg->locationnum, # sure, why not?
@@ -3244,11 +3603,18 @@ sub _items_cust_bill_pkg {
 
         }
 
+        # should we show a recur line?
+        # if type eq 'S', then NO, because we've been told not to.
+        # otherwise, show the recur line if:
+        # - there's a recurring charge
+        # - or recur_show_zero is on
+        # - or there's a positive unitrecur (so it's been discounted to zero)
+        #   and discount-show-always is on
         if (    ( !$type || $type eq 'R' || $type eq 'U' )
              && (
                      $cust_bill_pkg->recur != 0
-                  || $cust_bill_pkg->setup == 0
-                  || $discount_show_always
+                  || !defined($s)
+                  || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
                   || $cust_bill_pkg->recur_show_zero
                 )
            )
@@ -3264,10 +3630,15 @@ sub _items_cust_bill_pkg {
             $description = $self->mt('Usage charges');
           }
 
-          my $part_pkg = $cust_pkg->part_pkg;
+          my $disable_date_ranges =
+               $opt{disable_line_item_date_ranges}
+            || $part_pkg->option('disable_line_item_date_ranges', 1);
 
-          $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
-                                                              $self->agentnum );
+          $description .= $cust_bill_pkg->time_period_pretty(
+                                    $part_pkg,
+                                    $agentnum,
+                                    disable_date_ranges => $disable_date_ranges,
+                          );
 
           my @d = ();
           my @seconds = (); # for display of usage info
@@ -3281,7 +3652,9 @@ sub _items_cust_bill_pkg {
           push @dates, undef if !$prev;
 
           my @svc_labels = map &{$escape_function}($_),
-                      $cust_pkg->h_labels_short(@dates, 'I');
+            $cust_pkg->h_labels_short(@dates,
+                                      'I',
+                                      $self->conf->{locale});
           $svc_label = $svc_labels[0];
 
           # show service labels, unless...
@@ -3387,6 +3760,7 @@ sub _items_cust_bill_pkg {
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
                 unit_amount     => $unit_amount,
                 quantity        => $cust_bill_pkg->quantity,
+                pkg_tax         => \@pkg_tax,
                 %item_dates,
                 ext_description => \@d,
                 svc_label       => ($svc_label || ''),
@@ -3415,6 +3789,7 @@ sub _items_cust_bill_pkg {
                 amount          => $amount,
                 usage_item      => 1,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
+                pkg_tax         => \@pkg_tax,
                 %item_dates,
                 ext_description => \@d,
                 locationnum     => $cust_pkg->locationnum,
@@ -3490,9 +3865,6 @@ sub _items_cust_bill_pkg {
 
     } # foreach $display
 
-    $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
-                                && $conf->exists('discount-show-always'));
-
   }
 
   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
@@ -3504,11 +3876,11 @@ sub _items_cust_bill_pkg {
         $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
       }
 
-      push @b, { %$_ }
-        if $_->{amount} != 0
-        || $discount_show_always
-        || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
-        || (   $_->{_is_setup} && $_->{setup_show_zero} )
+      push @b, { %$_ };
+      #if $_->{amount} != 0
+      #  || $discount_show_always
+      #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+      #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
     }
   }
 
@@ -3566,4 +3938,66 @@ sub _items_discounts_avail {
 
 }
 
+=item has_sections AGENTNUM
+
+Return true if invoice_sections should be enabled for this bill.
+ (Inherited by both cust_bill and cust_bill_void)
+
+Determination:
+* False if not an invoice
+* True always if conf invoice_sections is enabled
+* True always if sections_by_location is enabled
+* True if conf invoice_sections_multilocation > 1,
+  and location_count >= invoice_sections_multilocation
+* Else, False
+
+=cut
+
+sub has_sections {
+  my ($self, $agentnum) = @_;
+
+  return 0 unless $self->invnum > 0;
+
+  $agentnum ||= $self->agentnum;
+  return 1 if $self->conf->config_bool('invoice_sections', $agentnum);
+  return 1 if $self->conf->exists('sections_by_location', $agentnum);
+
+  my $location_min = $self->conf->config(
+    'invoice_sections_multilocation', $agentnum,
+  );
+
+  return 1
+    if $location_min
+    && $self->location_count >= $location_min;
+
+  0;
+}
+
+
+=item location_count
+
+Return the number of locations billed on this invoice
+
+=cut
+
+sub location_count {
+  my ($self) = @_;
+  return 0 unless $self->invnum;
+
+  # SELECT COUNT( DISTINCT cust_pkg.locationnum )
+  # FROM cust_bill_pkg
+  # LEFT JOIN cust_pkg USING (pkgnum)
+  # WHERE invnum = 278
+  #   AND cust_bill_pkg.pkgnum > 0
+
+  my $result = qsearchs({
+    select    => 'COUNT(DISTINCT cust_pkg.locationnum) as location_count',
+    table     => 'cust_bill_pkg',
+    addl_from => 'LEFT JOIN cust_pkg USING (pkgnum)',
+    extra_sql => 'WHERE invnum = '.dbh->quote( $self->invnum )
+               . '  AND cust_bill_pkg.pkgnum > 0'
+  });
+  ref $result ? $result->location_count : 0;
+}
+
 1;