fix v3 perf regression from #79636, RT#80177
[freeside.git] / FS / FS / Template_Mixin.pm
1 package FS::Template_Mixin;
2
3 use strict;
4 use vars qw( $DEBUG $me
5              $money_char
6              $date_format
7            );
8              # but NOT $conf
9 use vars qw( $invoice_lines @buf ); #yuck
10 use List::Util qw(sum); #can't import first, it conflicts with cust_main.first
11 use Date::Format;
12 use Date::Language;
13 use Time::Local qw( timelocal );
14 use Text::Template 1.20;
15 use File::Temp 0.14;
16 use Archive::Zip qw( :ERROR_CODES :CONSTANTS );
17 use IO::Scalar;
18 use HTML::Entities;
19 use Cwd;
20 use FS::UID;
21 use FS::Misc qw( send_email );
22 use FS::Record qw( qsearch qsearchs dbh );
23 use FS::Conf;
24 use FS::Misc qw( generate_ps generate_pdf );
25 use FS::pkg_category;
26 use FS::pkg_class;
27 use FS::invoice_mode;
28 use FS::L10N;
29
30 $DEBUG = 0;
31 $me = '[FS::Template_Mixin]';
32 FS::UID->install_callback( sub { 
33   my $conf = new FS::Conf; #global
34   $money_char  = $conf->config('money_char')  || '$';  
35   $date_format = $conf->config('date_format') || '%x'; #/YY
36 } );
37
38 =item conf [ MODE ]
39
40 Returns a configuration handle (L<FS::Conf>) set to the customer's locale.
41
42 If the "mode" pseudo-field is set on the object, the configuration handle
43 will be an L<FS::invoice_conf> for that invoice mode (and the customer's
44 locale).
45
46 =cut
47
48 sub conf {
49   my $self = shift;
50   my $mode = $self->get('mode');
51   if ($self->{_conf} and !defined($mode)) {
52     return $self->{_conf};
53   }
54
55   my $cust_main = $self->cust_main;
56   my $locale = $cust_main ? $cust_main->locale : '';
57   my $conf;
58   if ( $mode ) {
59     if ( ref $mode and $mode->isa('FS::invoice_mode') ) {
60       $mode = $mode->modenum;
61     } elsif ( $mode =~ /\D/ ) {
62       die "invalid invoice mode $mode";
63     }
64     $conf = qsearchs('invoice_conf', { modenum => $mode, locale => $locale });
65     if (!$conf) {
66       $conf = qsearchs('invoice_conf', { modenum => $mode, locale => '' });
67       # it doesn't have a locale, but system conf still might
68       $conf->set('locale' => $locale) if $conf;
69     }
70   }
71   # if $mode is unspecified, or if there is no invoice_conf matching this mode
72   # and locale, then use the system config only (but with the locale)
73   $conf ||= FS::Conf->new({ 'locale' => $locale });
74   # cache it
75   return $self->{_conf} = $conf;
76 }
77
78 =item print_text OPTIONS
79
80 Returns an text invoice, as a list of lines.
81
82 Options can be passed as a hash.
83
84 I<time>, if specified, is used to control the printing of overdue messages.  The
85 default is now.  It isn't the date of the invoice; that's the `_date' field.
86 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
87 L<Time::Local> and L<Date::Parse> for conversion functions.
88
89 I<template>, if specified, is the name of a suffix for alternate invoices.
90
91 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
92
93 =cut
94
95 sub print_text {
96   my $self = shift;
97   my %params;
98   if ( ref($_[0]) ) {
99     %params = %{ shift() };
100   } else {
101     %params = @_;
102   }
103
104   $params{'format'} = 'template'; # for some reason
105
106   $self->print_generic( %params );
107 }
108
109 =item print_latex HASHREF
110
111 Internal method - returns a filename of a filled-in LaTeX template for this
112 invoice (Note: add ".tex" to get the actual filename), and a filename of
113 an associated logo (with the .eps extension included).
114
115 See print_ps and print_pdf for methods that return PostScript and PDF output.
116
117 Options can be passed as a hash.
118
119 I<time>, if specified, is used to control the printing of overdue messages.  The
120 default is now.  It isn't the date of the invoice; that's the `_date' field.
121 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
122 L<Time::Local> and L<Date::Parse> for conversion functions.
123
124 I<template>, if specified, is the name of a suffix for alternate invoices.  
125 This is strongly deprecated; see L<FS::invoice_conf> for the right way to
126 customize invoice templates for different purposes.
127
128 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
129
130 =cut
131
132 sub print_latex {
133   my $self = shift;
134   my %params;
135
136   if ( ref($_[0]) ) {
137     %params = %{ shift() };
138   } else {
139     %params = @_;
140   }
141
142   $params{'format'} = 'latex';
143   my $conf = $self->conf;
144
145   # this needs to go away
146   my $template = $params{'template'};
147   # and this especially
148   $template ||= $self->_agent_template
149     if $self->can('_agent_template');
150
151   #the new way
152   $self->set('mode', $params{mode})
153     if $params{mode};
154
155   my $pkey = $self->primary_key;
156   my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
157
158   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
159   my $lh = new File::Temp(
160     TEMPLATE => $tmp_template,
161     DIR      => $dir,
162     SUFFIX   => '.eps',
163     UNLINK   => 0,
164   ) or die "can't open temp file: $!\n";
165
166   my $agentnum = $self->agentnum;
167
168   if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
169     print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
170       or die "can't write temp file: $!\n";
171   } else {
172     print $lh $conf->config_binary('logo.eps', $agentnum)
173       or die "can't write temp file: $!\n";
174   }
175   close $lh;
176   $params{'logo_file'} = $lh->filename;
177
178   if( $conf->exists('invoice-barcode') 
179         && $self->can('invoice_barcode')
180         && $self->invnum ) { # don't try to barcode statements
181       my $png_file = $self->invoice_barcode($dir);
182       my $eps_file = $png_file;
183       $eps_file =~ s/\.png$/.eps/g;
184       $png_file =~ /(barcode.*png)/;
185       $png_file = $1;
186       $eps_file =~ /(barcode.*eps)/;
187       $eps_file = $1;
188
189       my $curr_dir = cwd();
190       chdir($dir); 
191       # after painfuly long experimentation, it was determined that sam2p won't
192       # accept : and other chars in the path, no matter how hard I tried to
193       # escape them, hence the chdir (and chdir back, just to be safe)
194       system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0
195         or die "sam2p failed: $!\n";
196       unlink($png_file);
197       chdir($curr_dir);
198
199       $params{'barcode_file'} = $eps_file;
200   }
201
202   my @filled_in = $self->print_generic( %params );
203   
204   my $fh = new File::Temp( TEMPLATE => $tmp_template,
205                            DIR      => $dir,
206                            SUFFIX   => '.tex',
207                            UNLINK   => 0,
208                          ) or die "can't open temp file: $!\n";
209   binmode($fh, ':utf8'); # language support
210   print $fh join('', @filled_in );
211   close $fh;
212
213   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
214   return ($1, $params{'logo_file'}, $params{'barcode_file'});
215
216 }
217
218 sub agentnum {
219   my $self = shift;
220   my $cust_main = $self->cust_main;
221   $cust_main ? $cust_main->agentnum : $self->prospect_main->agentnum;
222 }
223
224 =item print_generic OPTION => VALUE ...
225
226 Internal method - returns a filled-in template for this invoice as a scalar.
227
228 See print_ps and print_pdf for methods that return PostScript and PDF output.
229
230 Required options
231
232 =over 4
233
234 =item format
235
236 The B<format> option is required and should be set to html, latex (print and PDF) or template (plaintext).
237
238 =back
239
240 Additional options
241
242 =over 4
243
244 =item notice_name
245
246 Overrides "Invoice" as the name of the sent document.
247
248 =item today
249
250 Used to control the printing of overdue messages.  The
251 default is now.  It isn't the date of the invoice; that's the `_date' field.
252 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
253 L<Time::Local> and L<Date::Parse> for conversion functions.
254
255 =item logo_file
256
257 Logo file (path to temporary EPS file on the local filesystem)
258
259 =item cid
260
261 CID for inline (emailed) images (logo)
262
263 =item locale
264
265 Override customer's locale
266
267 =item unsquelch_cdr
268
269 Overrides any per customer cdr squelching when true
270
271 =item no_number
272
273 Supress the (invoice, quotation, statement, etc.) number
274
275 =item no_date
276
277 Supress the date
278
279 =item no_coupon
280
281 Supress the payment coupon
282
283 =item barcode_file
284
285 Barcode file (path to temporary EPS file on the local filesystem)
286
287 =item barcode_img
288
289 Flag indicating the barcode image should be a link (normal HTML dipaly)
290
291 =item barcode_cid
292
293 Barcode CID for inline (emailed) images
294
295 =item preref_callback
296
297 Coderef run for each line item, code should return HTML to be displayed
298 before that line item (quotations only)
299
300 =item template
301
302 Deprecated.  Used as a suffix for a configuration template.  Please
303 don't use this, it deprecated in favor of more flexible alternatives.
304
305 =back
306
307 =cut
308
309 #what's with all the sprintf('%10.2f')'s in here?  will it cause any
310 # (alignment in text invoice?) problems to change them all to '%.2f' ?
311 # yes: fixed width/plain text printing will be borked
312 sub print_generic {
313   my( $self, %params ) = @_;
314   my $conf = $self->conf;
315
316   my $today = $params{today} ? $params{today} : time;
317   warn "$me print_generic called on $self with suffix $params{template}\n"
318     if $DEBUG;
319
320   my $format = $params{format};
321   die "Unknown format: $format"
322     unless $format =~ /^(latex|html|template)$/;
323
324   my $cust_main = $self->cust_main || $self->prospect_main;
325   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
326     unless $cust_main->payname
327         && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
328
329   my $locale = $params{'locale'} || $cust_main->locale;
330
331   my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
332                      'html'     => [ '<%=', '%>' ],
333                      'template' => [ '{', '}' ],
334                    );
335
336   warn "$me print_generic creating template\n"
337     if $DEBUG > 1;
338
339   # set the notice name here, and nowhere else.
340   my $notice_name =  $params{notice_name}
341                   || $conf->config('notice_name')
342                   || $self->notice_name;
343
344   #create the template
345   my $template = $params{template} ? $params{template} : $self->_agent_template;
346   my $templatefile = $self->template_conf. $format;
347   $templatefile .= "_$template"
348     if length($template) && $conf->exists($templatefile."_$template");
349
350   # the base template
351   my @invoice_template = map "$_\n", $conf->config($templatefile)
352     or die "cannot load config data $templatefile";
353
354   if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
355     #change this to a die when the old code is removed
356     # it's been almost ten years, changing it to a die on the next release.
357     warn "old-style invoice template $templatefile; ".
358          "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
359          #$old_latex = 'true';
360          #@invoice_template = _translate_old_latex_format(@invoice_template);
361   } 
362
363   warn "$me print_generic creating T:T object\n"
364     if $DEBUG > 1;
365
366   my $text_template = new Text::Template(
367     TYPE => 'ARRAY',
368     SOURCE => \@invoice_template,
369     DELIMITERS => $delimiters{$format},
370   );
371
372   warn "$me print_generic compiling T:T object\n"
373     if $DEBUG > 1;
374
375   $text_template->compile()
376     or die "Can't compile $templatefile: $Text::Template::ERROR\n";
377
378
379   # additional substitution could possibly cause breakage in existing templates
380   my %convert_maps = ( 
381     'latex' => {
382                  'notes'         => sub { map "$_", @_ },
383                  'footer'        => sub { map "$_", @_ },
384                  'smallfooter'   => sub { map "$_", @_ },
385                  'returnaddress' => sub { map "$_", @_ },
386                  'coupon'        => sub { map "$_", @_ },
387                  'summary'       => sub { map "$_", @_ },
388                },
389     'html'  => {
390                  'notes' =>
391                    sub {
392                      map { 
393                        s/%%(.*)$/<!-- $1 -->/g;
394                        s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
395                        s/\\begin\{enumerate\}/<ol>/g;
396                        s/\\item /  <li>/g;
397                        s/\\end\{enumerate\}/<\/ol>/g;
398                        s/\\textbf\{(.*)\}/<b>$1<\/b>/g;
399                        s/\\\\\*/<br>/g;
400                        s/\\dollar ?/\$/g;
401                        s/\\#/#/g;
402                        s/~/&nbsp;/g;
403                        $_;
404                      }  @_
405                    },
406                  'footer' =>
407                    sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
408                  'smallfooter' =>
409                    sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
410                  'returnaddress' =>
411                    sub {
412                      map { 
413                        s/~/&nbsp;/g;
414                        s/\\\\\*?\s*$/<BR>/;
415                        s/\\hyphenation\{[\w\s\-]+}//;
416                        s/\\([&])/$1/g;
417                        $_;
418                      }  @_
419                    },
420                  'coupon'        => sub { "" },
421                  'summary'       => sub { "" },
422                },
423     'template' => {
424                  'notes' =>
425                    sub {
426                      map { 
427                        s/%%.*$//g;
428                        s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
429                        s/\\begin\{enumerate\}//g;
430                        s/\\item /  * /g;
431                        s/\\end\{enumerate\}//g;
432                        s/\\textbf\{(.*)\}/$1/g;
433                        s/\\\\\*/ /;
434                        s/\\dollar ?/\$/g;
435                        $_;
436                      }  @_
437                    },
438                  'footer' =>
439                    sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
440                  'smallfooter' =>
441                    sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
442                  'returnaddress' =>
443                    sub {
444                      map { 
445                        s/~/ /g;
446                        s/\\\\\*?\s*$/\n/;             # dubious
447                        s/\\hyphenation\{[\w\s\-]+}//;
448                        $_;
449                      }  @_
450                    },
451                  'coupon'        => sub { "" },
452                  'summary'       => sub { "" },
453                },
454   );
455
456
457   # hashes for differing output formats
458   my %nbsps = ( 'latex'    => '~',
459                 'html'     => '',    # '&nbps;' would be nice
460                 'template' => '',    # not used
461               );
462   my $nbsp = $nbsps{$format};
463
464   my %escape_functions = ( 'latex'    => \&_latex_escape,
465                            'html'     => \&_html_escape_nbsp,#\&encode_entities,
466                            'template' => sub { shift },
467                          );
468   my $escape_function = $escape_functions{$format};
469   my $escape_function_nonbsp = ($format eq 'html')
470                                  ? \&_html_escape : $escape_function;
471
472   my %newline_tokens = (  'latex'     => '\\\\',
473                           'html'      => '<br>',
474                           'template'  => "\n",
475                         );
476   my $newline_token = $newline_tokens{$format};
477
478   warn "$me generating template variables\n"
479     if $DEBUG > 1;
480
481   # generate template variables
482   my $returnaddress;
483
484   if (
485          defined( $conf->config_orbase( "invoice_${format}returnaddress",
486                                         $template
487                                       )
488                 )
489        && length( $conf->config_orbase( "invoice_${format}returnaddress",
490                                         $template
491                                       )
492                 )
493   ) {
494
495     $returnaddress = join("\n",
496       $conf->config_orbase("invoice_${format}returnaddress", $template)
497     );
498
499   } elsif ( grep /\S/,
500             $conf->config_orbase('invoice_latexreturnaddress', $template) ) {
501
502     my $convert_map = $convert_maps{$format}{'returnaddress'};
503     $returnaddress =
504       join( "\n",
505             &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress",
506                                                  $template
507                                                )
508                          )
509           );
510   } elsif ( grep /\S/, $conf->config('company_address', $cust_main->agentnum) ) {
511
512     my $convert_map = $convert_maps{$format}{'returnaddress'};
513     $returnaddress = join( "\n", &$convert_map(
514                                    map { s/( {2,})/'~' x length($1)/eg;
515                                          s/$/\\\\\*/;
516                                          $_
517                                        }
518                                      ( $conf->config('company_name', $cust_main->agentnum),
519                                        $conf->config('company_address', $cust_main->agentnum),
520                                      )
521                                  )
522                      );
523
524   } else {
525
526     my $warning = "Couldn't find a return address; ".
527                   "do you need to set the company_address configuration value?";
528     warn "$warning\n";
529     $returnaddress = $nbsp;
530     #$returnaddress = $warning;
531
532   }
533
534   warn "$me generating invoice data\n"
535     if $DEBUG > 1;
536
537   my $agentnum = $cust_main->agentnum;
538
539   my %invoice_data = (
540
541     #invoice from info
542     'company_name'    => scalar( $conf->config('company_name', $agentnum) ),
543     'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
544     'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
545     'returnaddress'   => $returnaddress,
546     'agent'           => &$escape_function($cust_main->agent->agent),
547
548     #invoice/quotation info
549     'no_number'       => $params{'no_number'},
550     'invnum'          => ( $params{'no_number'} ? '' : $self->invnum ),
551     'quotationnum'    => $self->quotationnum,
552     'no_date'         => $params{'no_date'},
553     '_date'           => ( $params{'no_date'} ? '' : $self->_date ),
554       # workaround for inconsistent behavior in the early plain text 
555       # templates; see RT#28271
556     'date'            => ( $params{'no_date'}
557                              ? ''
558                              : ($format eq 'template'
559                                ? $self->_date
560                                : $self->time2str_local('long', $self->_date, $format)
561                                )
562                          ),
563     'today'           => $self->time2str_local('long', $today, $format),
564     'terms'           => $self->terms,
565     'template'        => $template, #params{'template'},
566     'notice_name'     => $notice_name, # escape?
567     'current_charges' => sprintf("%.2f", $self->charged),
568     'duedate'         => $self->due_date2str('rdate'), #date_format?
569     'duedate_long'    => $self->due_date2str('long'),
570
571     #customer info
572     'custnum'         => $cust_main->display_custnum,
573     'prospectnum'     => $cust_main->prospectnum,
574     'agent_custid'    => &$escape_function($cust_main->agent_custid),
575     ( map { $_ => &$escape_function($cust_main->$_()) } qw(
576       payname company address1 address2 city state zip fax
577     )),
578
579     #global config
580     'ship_enable'     => $cust_main->invoice_ship_address || $conf->exists('invoice-ship_address'),
581     'unitprices'      => $conf->exists('invoice-unitprice'),
582     'smallernotes'    => $conf->exists('invoice-smallernotes'),
583     'smallerfooter'   => $conf->exists('invoice-smallerfooter'),
584     'balance_due_below_line' => $conf->exists('balance_due_below_line'),
585    
586     #layout info -- would be fancy to calc some of this and bury the template
587     #               here in the code
588     'topmargin'             => scalar($conf->config('invoice_latextopmargin', $agentnum)),
589     'headsep'               => scalar($conf->config('invoice_latexheadsep', $agentnum)),
590     'textheight'            => scalar($conf->config('invoice_latextextheight', $agentnum)),
591     'extracouponspace'      => scalar($conf->config('invoice_latexextracouponspace', $agentnum)),
592     'couponfootsep'         => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)),
593     'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum),
594     'addresssep'            => scalar($conf->config('invoice_latexaddresssep', $agentnum)),
595     'amountenclosedsep'     => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
596     'coupontoaddresssep'    => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
597     'addcompanytoaddress'   => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
598
599     # better hang on to conf_dir for a while (for old templates)
600     'conf_dir'        => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
601
602     #these are only used when doing paged plaintext
603     'page'            => 1,
604     'total_pages'     => 1,
605
606   );
607  
608   #localization
609   $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
610   # prototype here to silence warnings
611   $invoice_data{'time2str'} = sub ($;$$) { $self->time2str_local(@_, $format) };
612
613   my $min_sdate = 999999999999;
614   my $max_edate = 0;
615   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
616     next unless $cust_bill_pkg->pkgnum > 0;
617     $min_sdate = $cust_bill_pkg->sdate
618       if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate;
619     $max_edate = $cust_bill_pkg->edate
620       if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate;
621   }
622
623   $invoice_data{'bill_period'} = '';
624   $invoice_data{'bill_period'} =
625       $self->time2str_local('%e %h', $min_sdate, $format) 
626       . " to " .
627       $self->time2str_local('%e %h', $max_edate, $format)
628     if ($max_edate != 0 && $min_sdate != 999999999999);
629
630   $invoice_data{finance_section} = '';
631   if ( $conf->config('finance_pkgclass') ) {
632     my $pkg_class =
633       qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
634     $invoice_data{finance_section} = $pkg_class->categoryname;
635   } 
636   $invoice_data{finance_amount} = '0.00';
637   $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
638
639   my $countrydefault = $conf->config('countrydefault') || 'US';
640   foreach ( qw( address1 address2 city state zip country fax) ){
641     my $method = 'ship_'.$_;
642     $invoice_data{"ship_$_"} = $escape_function->($cust_main->$method);
643   }
644   if ( length($cust_main->ship_company) ) {
645     $invoice_data{'ship_company'} = $escape_function->($cust_main->ship_company);
646   } else {
647     $invoice_data{'ship_company'} = $escape_function->($cust_main->company);
648   }
649   $invoice_data{'ship_contact'} = $escape_function->($cust_main->contact);
650   $invoice_data{'ship_country'} = ''
651     if ( $invoice_data{'ship_country'} eq $countrydefault );
652   
653   $invoice_data{'cid'} = $params{'cid'}
654     if $params{'cid'};
655
656   if ( $cust_main->country eq $countrydefault ) {
657     $invoice_data{'country'} = '';
658   } else {
659     $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full);
660   }
661
662   my @address = ();
663   $invoice_data{'address'} = \@address;
664   push @address,
665     $cust_main->payname.
666       ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
667         ? " (P.O. #". $cust_main->payinfo. ")"
668         : ''
669       )
670   ;
671   push @address, $cust_main->company
672     if $cust_main->company;
673   push @address, $cust_main->address1;
674   push @address, $cust_main->address2
675     if $cust_main->address2;
676   push @address,
677     $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
678   push @address, $invoice_data{'country'}
679     if $invoice_data{'country'};
680   push @address, ''
681     while (scalar(@address) < 5);
682
683   $invoice_data{'logo_file'} = $params{'logo_file'}
684     if $params{'logo_file'};
685   $invoice_data{'barcode_file'} = $params{'barcode_file'}
686     if $params{'barcode_file'};
687   $invoice_data{'barcode_img'} = $params{'barcode_img'}
688     if $params{'barcode_img'};
689   $invoice_data{'barcode_cid'} = $params{'barcode_cid'}
690     if $params{'barcode_cid'};
691
692   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
693 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
694   #my $balance_due = $self->owed + $pr_total - $cr_total;
695   my $balance_due = $self->owed;
696   if ( $self->enable_previous ) {
697     $balance_due += $pr_total;
698   }
699   # otherwise the previous balance is not shown, so including it in the
700   # balance due is just confusing
701
702   # the sum of amount owed on all invoices
703   # (this is used in the summary & on the payment coupon)
704   $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
705
706   # flag telling this invoice to have a first-page summary
707   my $summarypage = '';
708
709   if ( $self->custnum && $self->invnum ) {
710     # XXX should be an FS::cust_bill method to set the defaults, instead
711     # of checking the type here
712
713     # info from customer's last invoice before this one, for some 
714     # summary formats
715     $invoice_data{'last_bill'} = {};
716  
717     my $last_bill = $self->previous_bill;
718     if ( $last_bill ) {
719
720       # "balance_date_range" unfortunately is unsuitable for this, since it
721       # cares about application dates.  We want to know the sum of all 
722       # _top-level transactions_ dated before the last invoice.
723       #
724       # still do this for the "Previous Balance" line of the summary block
725       my @sql =
726         map "$_ WHERE _date <= ? AND custnum = ?", (
727           "SELECT      COALESCE( SUM(charged), 0 ) FROM cust_bill",
728           "SELECT -1 * COALESCE( SUM(amount),  0 ) FROM cust_credit",
729           "SELECT -1 * COALESCE( SUM(paid),    0 ) FROM cust_pay",
730           "SELECT      COALESCE( SUM(refund),  0 ) FROM cust_refund",
731         );
732
733       # the customer's current balance immediately after generating the last 
734       # bill
735
736       my $last_bill_balance = $last_bill->charged;
737       foreach (@sql) {
738         my $delta = FS::Record->scalar_sql(
739           $_,
740           $last_bill->_date - 1,
741           $self->custnum,
742         );
743         $last_bill_balance += $delta;
744       }
745
746       $last_bill_balance = sprintf("%.2f", $last_bill_balance);
747
748       warn sprintf("LAST BILL: INVNUM %d, DATE %s, BALANCE %.2f\n\n",
749         $last_bill->invnum,
750         $self->time2str_local('%D', $last_bill->_date),
751         $last_bill_balance
752       ) if $DEBUG > 0;
753       # ("true_previous_balance" is a terrible name, but at least it's no
754       # longer stored in the database)
755       $invoice_data{'true_previous_balance'} = $last_bill_balance;
756
757       # Now, get all applications of credits/payments dated on or after the
758       # previous bill, to invoices before the current bill. (The
759       # credit/payment date restriction prevents these from intersecting
760       # the "Previous Balance" set.)
761       # These are "adjustments". The past due balance will be shown as
762       # Previous Balance - Adjustments.
763       my $adjustments = 0;
764       @sql = map {
765         "SELECT COALESCE(SUM(y.amount),0) FROM $_ JOIN cust_bill USING (invnum)
766          WHERE cust_bill._date < ?
767            AND x._date >= ?
768            AND cust_bill.custnum = ?"
769         } "cust_credit AS x JOIN cust_credit_bill y USING (crednum)",
770           "cust_pay    AS x JOIN cust_bill_pay    y USING (paynum)"
771       ;
772       foreach (@sql) {
773         my $delta = FS::Record->scalar_sql(
774           $_,
775           $self->_date,
776           $last_bill->_date,
777           $self->custnum,
778         );
779         $adjustments += $delta;
780       }
781       $invoice_data{'balance_adjustments'} = sprintf("%.2f", $adjustments);
782
783       warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n",
784                    $invoice_data{'balance_adjustments'}
785       ) if $DEBUG > 0;
786
787       # the sum of amount owed on all previous invoices
788       # ($pr_total is used elsewhere but not as $previous_balance)
789       $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
790
791       $invoice_data{'last_bill'}{'_date'} = $last_bill->_date; #unformatted
792       my (@payments, @credits);
793       # for formats that itemize previous payments
794       foreach my $cust_pay ( qsearch('cust_pay', {
795                               'custnum' => $self->custnum,
796                               '_date'   => { op => '>=',
797                                              value => $last_bill->_date }
798                              } ) )
799       {
800         next if $cust_pay->_date > $self->_date;
801         push @payments, {
802             '_date'       => $cust_pay->_date,
803             'date'        => $self->time2str_local('long', $cust_pay->_date, $format),
804             'payinfo'     => $cust_pay->payby_payinfo_pretty,
805             'amount'      => sprintf('%.2f', $cust_pay->paid),
806         };
807         # not concerned about applications
808       }
809       foreach my $cust_credit ( qsearch('cust_credit', {
810                               'custnum' => $self->custnum,
811                               '_date'   => { op => '>=',
812                                              value => $last_bill->_date }
813                              } ) )
814       {
815         next if $cust_credit->_date > $self->_date;
816         push @credits, {
817             '_date'       => $cust_credit->_date,
818             'date'        => $self->time2str_local('long', $cust_credit->_date, $format),
819             'creditreason'=> $cust_credit->reason,
820             'amount'      => sprintf('%.2f', $cust_credit->amount),
821         };
822       }
823       $invoice_data{'previous_payments'} = \@payments;
824       $invoice_data{'previous_credits'}  = \@credits;
825     } else {
826       # there is no $last_bill
827       $invoice_data{'true_previous_balance'} =
828       $invoice_data{'balance_adjustments'}   =
829       $invoice_data{'previous_balance'}      = '0.00';
830       $invoice_data{'previous_payments'} = [];
831       $invoice_data{'previous_credits'} = [];
832     }
833  
834     if ( $conf->config_bool('invoice_usesummary', $agentnum) ) {
835       $invoice_data{'summarypage'} = $summarypage = 1;
836     }
837
838   } # if this is an invoice
839
840   warn "$me substituting variables in notes, footer, smallfooter\n"
841     if $DEBUG > 1;
842
843   my $tc = $self->template_conf;
844   my @include = ( [ $tc,        'notes' ],
845                   [ 'invoice_', 'footer' ],
846                   [ 'invoice_', 'smallfooter', ],
847                   [ 'invoice_', 'watermark' ],
848                 );
849   push @include, [ $tc,        'coupon', ]
850     unless $params{'no_coupon'};
851
852   foreach my $i (@include) {
853
854     # load the configuration for this sub-template
855
856     my($base, $include) = @$i;
857
858     my $inc_file = $conf->key_orbase("$base$format$include", $template);
859
860     my @inc_src = $conf->config($inc_file, $agentnum);
861     if (!@inc_src) {
862       my $converter = $convert_maps{$format}{$include};
863       if ( $converter ) {
864         # then attempt to convert LaTeX to the requested format
865         $inc_file = $conf->key_orbase($base.'latex'.$include, $template);
866         @inc_src = &$converter( $conf->config($inc_file, $agentnum) );
867         foreach (@inc_src) {
868           # this isn't included in the convert_maps
869           my ($open, $close) = @{ $delimiters{$format} };
870           s/\[\@--/$open/g;
871           s/--\@\]/$close/g;
872         }
873       }
874     } # else @inc_src is empty and that's fine
875
876     # make a Text::Template out of it
877
878     my $inc_tt = new Text::Template (
879       TYPE       => 'ARRAY',
880       SOURCE     => [ map "$_\n", @inc_src ],
881       DELIMITERS => $delimiters{$format},
882     ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
883
884     unless ( $inc_tt->compile() ) {
885       my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
886       warn $error. "Template:\n". join('', map "$_\n", @inc_src);
887       die $error;
888     }
889
890     # fill in variables
891
892     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
893
894     $invoice_data{$include} =~ s/\n+$//
895       if ($format eq 'latex');
896   }
897
898   # let invoices use either of these as needed
899   $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') 
900     ? $cust_main->payinfo : '';
901   $invoice_data{'po_line'} = 
902     (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
903       ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
904       : $nbsp;
905
906   my %money_chars = ( 'latex'    => '',
907                       'html'     => $conf->config('money_char') || '$',
908                       'template' => '',
909                     );
910   my $money_char = $money_chars{$format};
911
912   # extremely dubious
913   my %other_money_chars = ( 'latex'    => '\dollar ',#XXX should be a config too
914                             'html'     => $conf->config('money_char') || '$',
915                             'template' => '',
916                           );
917   my $other_money_char = $other_money_chars{$format};
918   $invoice_data{'dollar'} = $other_money_char;
919
920   my %minus_signs = ( 'latex'    => '$-$',
921                       'html'     => '&minus;',
922                       'template' => '- ' );
923   my $minus = $minus_signs{$format};
924
925   my @detail_items = ();
926   my @total_items = ();
927   my @buf = ();
928   my @sections = ();
929
930   $invoice_data{'detail_items'} = \@detail_items;
931   $invoice_data{'total_items'} = \@total_items;
932   $invoice_data{'buf'} = \@buf;
933   $invoice_data{'sections'} = \@sections;
934
935   warn "$me generating sections\n"
936     if $DEBUG > 1;
937
938   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
939   my $multisection = $self->has_sections;
940   $invoice_data{'multisection'} = $multisection;
941   my $section_with_taxes = 1
942     if $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum);
943   my $late_sections;
944   my $extra_sections = [];
945   my $extra_lines = ();
946
947   # default section ('Charges')
948   my $default_section = { 'description' => '',
949                           'subtotal'    => '', 
950                           'no_subtotal' => 1,
951                         };
952
953   # Previous Charges section
954   # subtotal is the first return value from $self->previous
955   my $previous_section;
956   # if the invoice has major sections, or if we're summarizing previous 
957   # charges with a single line, or if we've been specifically told to put them
958   # in a section, create a section for previous charges:
959   if ( $multisection or
960        $conf->exists('previous_balance-summary_only') or
961        $conf->exists('previous_balance-section') ) {
962     
963     $previous_section =  { 'description' => $self->mt('Previous Charges'),
964                            'subtotal'    => $other_money_char.
965                                             sprintf('%.2f', $pr_total),
966                            'summarized'  => '', #why? $summarypage ? 'Y' : '',
967                          };
968     $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. 
969       join(' / ', map { $cust_main->balance_date_range(@$_) }
970                   $self->_prior_month30s
971           )
972       if $conf->exists('invoice_include_aging');
973
974   } else {
975     # otherwise put them in the main section
976     $previous_section = $default_section;
977   }
978
979   my $adjust_section = {
980     'description'    => $self->mt('Credits, Payments, and Adjustments'),
981     'adjust_section' => 1,
982     'subtotal'       => 0,   # adjusted below
983   };
984   my $adjust_weight = _pkg_category($adjust_section->{description})
985                         ? _pkg_category($adjust_section->{description})->weight
986                         : 0;
987   $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
988   # Note: 'sort_weight' here is actually a flag telling whether there is an
989   # explicit package category for the adjust section. If so, certain behavior
990   # happens.
991   $adjust_section->{'sort_weight'} = $adjust_weight;
992
993
994   if ( $multisection ) {
995     ($extra_sections, $extra_lines) =
996       $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
997       if $conf->exists('usage_class_as_a_section', $cust_main->agentnum)
998       && $self->can('_items_extra_usage_sections');
999
1000     push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
1001
1002     push @detail_items, @$extra_lines if $extra_lines;
1003
1004     # the code is written so that both methods can be used together, but
1005     # we haven't yet changed the template to take advantage of that, so for 
1006     # now, treat them as mutually exclusive.
1007     my %section_method = ( by_category => 1 );
1008     if ( $conf->config($tc.'sections_method') eq 'location' ) {
1009       %section_method = ( by_location => 1 );
1010     }
1011     my ($early, $late) =
1012       $self->_items_sections( 'summary' => $summarypage,
1013                               'escape'  => $escape_function_nonbsp,
1014                               'extra_sections' => $extra_sections,
1015                               'format'  => $format,
1016                               %section_method
1017                             );
1018     push @sections, @$early;
1019     $late_sections = $late;
1020
1021     if (    $conf->exists('svc_phone_sections')
1022          && $self->can('_items_svc_phone_sections')
1023        )
1024     {
1025       my ($phone_sections, $phone_lines) =
1026         $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
1027       push @{$late_sections}, @$phone_sections;
1028       push @detail_items, @$phone_lines;
1029     }
1030     if ( $conf->exists('voip-cust_accountcode_cdr')
1031          && $cust_main->accountcode_cdr
1032          && $self->can('_items_accountcode_cdr')
1033        )
1034     {
1035       my ($accountcode_section, $accountcode_lines) =
1036         $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
1037       if ( scalar(@$accountcode_lines) ) {
1038           push @{$late_sections}, $accountcode_section;
1039           push @detail_items, @$accountcode_lines;
1040       }
1041     }
1042   } else {# not multisection
1043     # make a default section
1044     push @sections, $default_section;
1045     # and calculate the finance charge total, since it won't get done otherwise.
1046     # and the default section total
1047     # XXX possibly finance_pkgclass should not be used in this manner?
1048     my @finance_charges;
1049     my @charges;
1050     foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
1051       if ( $invoice_data{finance_section} and 
1052         grep { $_->section eq $invoice_data{finance_section} }
1053            $cust_bill_pkg->cust_bill_pkg_display ) {
1054         # I think these are always setup fees, but just to be sure...
1055         push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
1056       } else {
1057         push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
1058       }
1059     }
1060     $invoice_data{finance_amount} = 
1061       sprintf('%.2f', sum( @finance_charges ) || 0);
1062     $default_section->{subtotal} = $other_money_char.
1063                                     sprintf('%.2f', sum( @charges ) || 0);
1064   }
1065
1066   # start setting up summary subtotals
1067   my @summary_subtotals;
1068   my $method = $conf->config('summary_subtotals_method');
1069   if ( ( ref($self) ne 'FS::quotation' ) and $method and $method ne $conf->config($tc.'sections_method') ) {
1070     # then re-section them by the correct method
1071     my %section_method = ( by_category => 1 );
1072     if ( $conf->config('summary_subtotals_method') eq 'location' ) {
1073       %section_method = ( by_location => 1 );
1074     }
1075     my ($early, $late) =
1076       $self->_items_sections( 'summary' => $summarypage,
1077                               'escape'  => $escape_function_nonbsp,
1078                               'extra_sections' => $extra_sections,
1079                               'format'  => $format,
1080                               %section_method
1081                             );
1082     foreach ( @$early ) {
1083       next if $_->{subtotal} == 0;
1084       $_->{subtotal} = $other_money_char.sprintf('%.2f', $_->{subtotal});
1085       push @summary_subtotals, $_;
1086     }
1087   } else {
1088     # subtotal sectioning is the same as for the actual invoice sections
1089     @summary_subtotals = @sections;
1090   }
1091
1092   # Hereafter, push sections to both @sections and @summary_subtotals
1093   # if they belong in both places (e.g. tax section).  Late sections are
1094   # never in @summary_subtotals.
1095
1096   # previous invoice balances in the Previous Charges section if there
1097   # is one, otherwise in the main detail section
1098   # (except if summary_only is enabled, don't show them at all)
1099   if ( $self->can('_items_previous') &&
1100        $self->enable_previous &&
1101        ! $conf->exists('previous_balance-summary_only') ) {
1102
1103     warn "$me adding previous balances\n"
1104       if $DEBUG > 1;
1105
1106     foreach my $line_item ( $self->_items_previous ) {
1107
1108       my $detail = {
1109         ref             => $line_item->{'pkgnum'},
1110         pkgpart         => $line_item->{'pkgpart'},
1111         #quantity        => 1, # not really correct
1112         section         => $previous_section, # which might be $default_section
1113         description     => &$escape_function($line_item->{'description'}),
1114         ext_description => [ map { &$escape_function($_) } 
1115                              @{ $line_item->{'ext_description'} || [] }
1116                            ],
1117         amount          => $money_char . $line_item->{'amount'},
1118         product_code    => $line_item->{'pkgpart'} || 'N/A',
1119       };
1120
1121       push @detail_items, $detail;
1122       push @buf, [ $detail->{'description'},
1123                    $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1124                  ];
1125     }
1126
1127   }
1128
1129   if ( @pr_cust_bill && $self->enable_previous ) {
1130     push @buf, ['','-----------'];
1131     push @buf, [ $self->mt('Total Previous Balance'),
1132                  $money_char. sprintf("%10.2f", $pr_total) ];
1133     push @buf, ['',''];
1134   }
1135  
1136   if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) {
1137       warn "$me adding DID summary\n"
1138         if $DEBUG > 1;
1139
1140       my ($didsummary,$minutes) = $self->_did_summary;
1141       my $didsummary_desc = 'DID Activity Summary (since last invoice)';
1142       push @detail_items, 
1143        { 'description' => $didsummary_desc,
1144            'ext_description' => [ $didsummary, $minutes ],
1145        };
1146   }
1147
1148   foreach my $section (@sections, @$late_sections) {
1149
1150     # begin some normalization
1151     $section->{'subtotal'} = $section->{'amount'}
1152       if $multisection
1153          && !exists($section->{subtotal})
1154          && exists($section->{amount});
1155
1156     $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
1157       if ( $invoice_data{finance_section} &&
1158            $section->{'description'} eq $invoice_data{finance_section} );
1159
1160     if ( $multisection ) {
1161
1162       if ( ref($section->{'subtotal'}) ) {
1163
1164         $section->{'subtotal'} =
1165           sprintf("$other_money_char%.2f to $other_money_char%.2f",
1166                     $section->{'subtotal'}[0],
1167                     $section->{'subtotal'}[1]
1168                  );
1169
1170       } else {
1171
1172         $section->{'subtotal'} = $other_money_char.
1173                                  sprintf('%.2f', $section->{'subtotal'})
1174
1175       }
1176
1177       # continue some normalization
1178       $section->{'amount'}   = $section->{'subtotal'}
1179
1180     }
1181
1182     if ( $section->{'description'} ) {
1183       push @buf, ( [ &$escape_function($section->{'description'}), '' ],
1184                    [ '', '' ],
1185                  );
1186     }
1187
1188     warn "$me   setting options\n"
1189       if $DEBUG > 1;
1190
1191     my %options = ();
1192     $options{'section'} = $section if $multisection;
1193     $options{'section_with_taxes'} = 1
1194       if $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum);
1195     $options{'format'} = $format;
1196     $options{'escape_function'} = $escape_function;
1197     $options{'no_usage'} = 1 unless $unsquelched;
1198     $options{'unsquelched'} = $unsquelched;
1199     $options{'summary_page'} = $summarypage;
1200     $options{'skip_usage'} =
1201       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
1202     $options{'preref_callback'} = $params{'preref_callback'};
1203
1204     warn "$me   searching for line items\n"
1205       if $DEBUG > 1;
1206
1207     my %section_tax_lines;
1208     my %seen_tax_lines;
1209
1210     foreach my $line_item ( $self->_items_pkg(%options),
1211                             $self->_items_fee(%options) ) {
1212
1213       warn "$me     adding line item ".
1214            join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
1215         if $DEBUG > 1;
1216
1217       push @buf, ( [ $line_item->{'description'},
1218                      $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1219                    ],
1220                    map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
1221                  );
1222
1223       $line_item->{'ref'} = $line_item->{'pkgnum'};
1224       $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
1225       $line_item->{'section'} = $section;
1226       $line_item->{'description'} = &$escape_function($line_item->{'description'});
1227       $line_item->{'amount'} = $money_char.$line_item->{'amount'};
1228
1229       if ( length($line_item->{'unit_amount'}) ) {
1230         $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
1231       }
1232       $line_item->{'ext_description'} ||= [];
1233
1234       if ( $section_with_taxes && ref $line_item->{pkg_tax} ) {
1235         for my $line_tax ( @{$ line_item->{pkg_tax} } ) {
1236
1237           # It is rarely possible for the same tax record to be presented here
1238           # multiple times.  See cust_bill_pkg::_pkg_tax_list for more info
1239           next if $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} };
1240           $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} } = 1;
1241
1242           $section_tax_lines{ $line_tax->{taxname} } += $line_tax->{amount};
1243         }
1244       }
1245
1246       push @detail_items, $line_item;
1247     }
1248
1249     # If conf flag invoice_sections_with_taxes:
1250     # - Add @detail_items for taxes into each section
1251     # - Update section subtotal to include taxes
1252     if ( $section_with_taxes && %section_tax_lines ) {
1253       for my $taxname ( keys %section_tax_lines ) {
1254
1255         push @detail_items, {
1256           section => $section,
1257           amount  => sprintf($money_char."%.2f",$section_tax_lines{$taxname}),
1258           description => &$escape_function($taxname),
1259         };
1260
1261         # Append taxes to total.  If line format resembles "$5.00 to $12.00"
1262         # append to the second value.
1263         if ($section->{subtotal} =~ /to/) {
1264           my @subtotal = split /\s/, $section->{subtotal};
1265           $subtotal[2] =~ s/[^\d\.]//g;
1266           $subtotal[2] = sprintf(
1267             $money_char."%.2f",
1268             ( $subtotal[2] + $section_tax_lines{$taxname} )
1269           );
1270           $section->{subtotal} = join ' ', @subtotal;
1271         } else {
1272         $section->{subtotal} =~ s/[^\d\.]//g;
1273           $section->{subtotal} = sprintf(
1274             $money_char . "%.2f",
1275             ( $section->{subtotal} + $section_tax_lines{$taxname} )
1276           );
1277         }
1278
1279       }
1280     }
1281
1282     if ( $section->{'description'} ) {
1283       push @buf, ( ['','-----------'],
1284                    [ $section->{'description'}. ' sub-total',
1285                       $section->{'subtotal'} # already formatted this 
1286                    ],
1287                    [ '', '' ],
1288                    [ '', '' ],
1289                  );
1290     }
1291   
1292   }
1293
1294   $invoice_data{current_less_finance} =
1295     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
1296
1297   # if there's anything in the Previous Charges section, prepend it to the list
1298   if ( $pr_total and $previous_section ne $default_section ) {
1299     unshift @sections, $previous_section;
1300     # but not @summary_subtotals
1301   }
1302
1303   warn "$me adding taxes\n"
1304     if $DEBUG > 1;
1305
1306   # create a tax section if we don't yet have one
1307   my $tax_description = 'Taxes, Surcharges, and Fees';
1308   my $tax_section =
1309     List::Util::first { $_->{description} eq $tax_description } @sections;
1310   if (!$tax_section) {
1311     $tax_section = { 'description' => $tax_description };
1312     push @sections, $tax_section if $multisection;
1313   }
1314   $tax_section->{tax_section} = 1; # mark this section as containing taxes
1315   # if this is an existing tax section, we're merging the tax items into it.
1316   # grab the taxtotal that's already there, strip the money symbol if any
1317   my $taxtotal = $tax_section->{'subtotal'} || 0;
1318   $taxtotal =~ s/^\Q$other_money_char\E//;
1319
1320   # this does nothing
1321   #my $tax_weight = _pkg_category($tax_section->{description})
1322   #                      ? _pkg_category($tax_section->{description})->weight
1323   #                      : 0;
1324   #$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
1325   #$tax_section->{'sort_weight'} = $tax_weight;
1326
1327   my @items_tax = $self->_items_tax;
1328   foreach my $tax ( @items_tax ) {
1329
1330     $taxtotal += $tax->{'amount'};
1331
1332     my $description = &$escape_function( $tax->{'description'} );
1333     my $amount      = sprintf( '%.2f', $tax->{'amount'} );
1334
1335     if ( $multisection ) {
1336
1337       push @detail_items, {
1338         ext_description => [],
1339         ref          => '',
1340         quantity     => '',
1341         description  => $description,
1342         amount       => $money_char. $amount,
1343         product_code => '',
1344         section      => $tax_section,
1345       };
1346
1347     } else {
1348
1349       push @total_items, {
1350         'total_item'   => $description,
1351         'total_amount' => $other_money_char. $amount,
1352       };
1353
1354     }
1355
1356     push @buf,[ $description,
1357                 $money_char. $amount,
1358               ];
1359
1360   }
1361  
1362   if ( @items_tax ) {
1363     my $total = {};
1364     $total->{'total_item'} = $self->mt('Sub-total');
1365     $total->{'total_amount'} =
1366       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
1367
1368     if ( $multisection ) {
1369       if ( $taxtotal > 0 ) {
1370         # there are taxes, so prepare the section to be displayed.
1371         # $taxtotal already includes any line items that were already in the
1372         # section (fees, taxes that are charged as packages for some reason).
1373         # also set 'summarized' to false so that this isn't a summary-only
1374         # section.
1375         $tax_section->{'subtotal'} = $other_money_char.
1376                                      sprintf('%.2f', $taxtotal);
1377         $tax_section->{'pretotal'} = 'New charges sub-total '.
1378                                      $total->{'total_amount'};
1379         $tax_section->{'description'} = $self->mt($tax_description);
1380         $tax_section->{'summarized'} = '';
1381
1382         if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) {
1383
1384           # remove tax section if taxes are itemized within other sections
1385           @sections = grep{ $_ ne $tax_section } @sections;
1386
1387         } elsif ( !grep $tax_section, @sections ) {
1388
1389           # append it if it's not already there
1390           push @sections, $tax_section;
1391           push @summary_subtotals, $tax_section;
1392
1393         }
1394
1395       }
1396
1397     } else {
1398       unshift @total_items, $total;
1399     }
1400   }
1401   $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
1402
1403   ###
1404   # Totals
1405   ###
1406
1407   my %embolden_functions = (
1408     'latex'    => sub { return '\textbf{'. shift(). '}' },
1409     'html'     => sub { return '<b>'. shift(). '</b>' },
1410     'template' => sub { shift },
1411   );
1412   my $embolden_function = $embolden_functions{$format};
1413
1414   if ( $multisection ) {
1415
1416     if ( $adjust_section->{'sort_weight'} ) {
1417       $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
1418         $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
1419     } else{
1420       $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
1421         $other_money_char.  sprintf('%.2f', $self->charged );
1422     }
1423
1424   }
1425   
1426   if ( $self->can('_items_total') ) { # should always be true now
1427
1428     # even for multisection, need plain text version
1429
1430     my @new_total_items = $self->_items_total;
1431
1432     push @buf,['','-----------'];
1433
1434     foreach ( @new_total_items ) {
1435       my ($item, $amount) = ($_->{'total_item'}, $_->{'total_amount'});
1436       $_->{'total_item'}   = &$embolden_function( $item );
1437
1438       if ( ref($amount) ) {
1439         $_->{'total_amount'} = &$embolden_function(
1440                                  $other_money_char.$amount->[0]. ' to '.
1441                                  $other_money_char.$amount->[1]
1442                                );
1443       } else {
1444       $_->{'total_amount'} = &$embolden_function( $other_money_char.$amount );
1445       }
1446
1447       # but if it's multisection, don't append to @total_items. the adjust
1448       # section has all this stuff
1449       push @total_items, $_ if !$multisection;
1450       push @buf, [ $item, $money_char.sprintf('%10.2f',$amount) ];
1451     }
1452
1453     push @buf, [ '', '' ];
1454
1455     # if we're showing previous invoices, also show previous
1456     # credits and payments 
1457     if ( $self->enable_previous 
1458           and $self->can('_items_credits')
1459           and $self->can('_items_payments') )
1460       {
1461     
1462       # credits
1463       my $credittotal = 0;
1464       foreach my $credit (
1465         $self->_items_credits( 'template' => $template, 'trim_len' => 40 )
1466       ) {
1467
1468         my $total;
1469         $total->{'total_item'} = &$escape_function($credit->{'description'});
1470         $credittotal += $credit->{'amount'};
1471         $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
1472         if ( $multisection ) {
1473           push @detail_items, {
1474             ext_description => [],
1475             ref          => '',
1476             quantity     => '',
1477             description  => &$escape_function($credit->{'description'}),
1478             amount       => $money_char . $credit->{'amount'},
1479             product_code => '',
1480             section      => $adjust_section,
1481           };
1482         } else {
1483           push @total_items, $total;
1484         }
1485
1486       }
1487       $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
1488
1489       #credits (again)
1490       foreach my $credit (
1491         $self->_items_credits( 'template' => $template, 'trim_len'=>32 )
1492       ) {
1493         push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
1494       }
1495
1496       # payments
1497       my $paymenttotal = 0;
1498       foreach my $payment (
1499         $self->_items_payments( 'template' => $template )
1500       ) {
1501         my $total = {};
1502         $total->{'total_item'} = &$escape_function($payment->{'description'});
1503         $paymenttotal += $payment->{'amount'};
1504         $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
1505         if ( $multisection ) {
1506           push @detail_items, {
1507             ext_description => [],
1508             ref          => '',
1509             quantity     => '',
1510             description  => &$escape_function($payment->{'description'}),
1511             amount       => $money_char . $payment->{'amount'},
1512             product_code => '',
1513             section      => $adjust_section,
1514           };
1515         }else{
1516           push @total_items, $total;
1517         }
1518         push @buf, [ $payment->{'description'},
1519                      $money_char. sprintf("%10.2f", $payment->{'amount'}),
1520                    ];
1521       }
1522       $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
1523     
1524       if ( $multisection ) {
1525         $adjust_section->{'subtotal'} = $other_money_char.
1526                                         sprintf('%.2f', $credittotal + $paymenttotal);
1527
1528         #why this? because {sort_weight} forces the adjust_section to appear
1529         #in @extra_sections instead of @sections. obviously.
1530         push @sections, $adjust_section
1531           unless $adjust_section->{sort_weight};
1532         # do not summarize; adjustments there are shown according to 
1533         # different rules
1534       }
1535
1536       # create Balance Due message
1537       { 
1538         my $total;
1539         $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1540         $total->{'total_amount'} =
1541           &$embolden_function(
1542             $other_money_char. sprintf('%.2f', #why? $summarypage 
1543                                                #  ? $self->charged +
1544                                                #    $self->billing_balance
1545                                                #  :
1546                                                    $self->owed + $pr_total
1547                                       )
1548           );
1549         if ( $multisection && !$adjust_section->{sort_weight} ) {
1550           $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
1551                                            $total->{'total_amount'};
1552         } else {
1553           push @total_items, $total;
1554         }
1555         push @buf,['','-----------'];
1556         push @buf,[$self->balance_due_msg, $money_char. 
1557           sprintf("%10.2f", $balance_due ) ];
1558       }
1559
1560       if ( $conf->exists('previous_balance-show_credit')
1561           and $cust_main->balance < 0 ) {
1562         my $credit_total = {
1563           'total_item'    => &$embolden_function($self->credit_balance_msg),
1564           'total_amount'  => &$embolden_function(
1565             $other_money_char. sprintf('%.2f', -$cust_main->balance)
1566           ),
1567         };
1568         if ( $multisection ) {
1569           $adjust_section->{'posttotal'} .= $newline_token .
1570             $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
1571         }
1572         else {
1573           push @total_items, $credit_total;
1574         }
1575         push @buf,['','-----------'];
1576         push @buf,[$self->credit_balance_msg, $money_char. 
1577           sprintf("%10.2f", -$cust_main->balance ) ];
1578       }
1579     }
1580
1581   } #end of default total adding ! can('_items_total')
1582
1583   if ( $multisection ) {
1584     if (    $conf->exists('svc_phone_sections')
1585          && $self->can('_items_svc_phone_sections')
1586        )
1587     {
1588       my $total;
1589       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1590       $total->{'total_amount'} =
1591         &$embolden_function(
1592           $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
1593         );
1594       my $last_section = pop @sections;
1595       $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
1596                                      $total->{'total_amount'};
1597       push @sections, $last_section;
1598     }
1599     push @sections, @$late_sections
1600       if $unsquelched;
1601   }
1602
1603   # make a discounts-available section, even without multisection
1604   if ( $conf->exists('discount-show_available') 
1605        and my @discounts_avail = $self->_items_discounts_avail ) {
1606     my $discount_section = {
1607       'description' => $self->mt('Discounts Available'),
1608       'subtotal'    => '',
1609       'no_subtotal' => 1,
1610     };
1611
1612     push @sections, $discount_section; # do not summarize
1613     push @detail_items, map { +{
1614         'ref'         => '', #should this be something else?
1615         'section'     => $discount_section,
1616         'description' => &$escape_function( $_->{description} ),
1617         'amount'      => $money_char . &$escape_function( $_->{amount} ),
1618         'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
1619     } } @discounts_avail;
1620   }
1621
1622   # not adding any more sections after this
1623   $invoice_data{summary_subtotals} = \@summary_subtotals;
1624
1625   # usage subtotals
1626   if ( $conf->exists('usage_class_summary')
1627        and $self->can('_items_usage_class_summary') ) {
1628     my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function, 'money_char' => $other_money_char);
1629     if ( @usage_subtotals ) {
1630       unshift @sections, $usage_subtotals[0]->{section}; # do not summarize
1631       unshift @detail_items, @usage_subtotals;
1632     }
1633   }
1634
1635   # invoice history "section" (not really a section)
1636   # not to be included in any subtotals, completely independent of 
1637   # everything...
1638   if ( $conf->exists('previous_invoice_history') and $cust_main->isa('FS::cust_main') ) {
1639     my %history;
1640     my %monthorder;
1641     foreach my $cust_bill ( $cust_main->cust_bill ) {
1642       # XXX hardcoded format, and currently only 'charged'; add other fields
1643       # if they become necessary
1644       my $date = $self->time2str_local('%b %Y', $cust_bill->_date);
1645       $history{$date} ||= 0;
1646       $history{$date} += $cust_bill->charged;
1647       # just so we have a numeric sort key
1648       $monthorder{$date} ||= $cust_bill->_date;
1649     }
1650     my @sorted_months = sort { $monthorder{$a} <=> $monthorder{$b} }
1651                         keys %history;
1652     my @sorted_amounts = map { sprintf('%.2f', $history{$_}) } @sorted_months;
1653     $invoice_data{monthly_history} = [ \@sorted_months, \@sorted_amounts ];
1654   }
1655
1656   # service locations: another option for template customization
1657   my %location_info;
1658   foreach my $item (@detail_items) {
1659     if ( $item->{locationnum} ) {
1660       $location_info{ $item->{locationnum} } ||= {
1661         FS::cust_location->by_key( $item->{locationnum} )->location_hash
1662       };
1663     }
1664   }
1665   $invoice_data{location_info} = \%location_info;
1666
1667   # debugging hook: call this with 'diag' => 1 to just get a hash of 
1668   # the invoice variables
1669   return \%invoice_data if ( $params{'diag'} );
1670
1671   # All sections and items are built; now fill in templates.
1672   my @includelist = ();
1673   push @includelist, 'summary' if $summarypage;
1674   foreach my $include ( @includelist ) {
1675
1676     my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
1677     my @inc_src;
1678
1679     if ( length( $conf->config($inc_file, $agentnum) ) ) {
1680
1681       @inc_src = $conf->config($inc_file, $agentnum);
1682
1683     } else {
1684
1685       $inc_file = $conf->key_orbase("invoice_latex$include", $template);
1686
1687       my $convert_map = $convert_maps{$format}{$include};
1688
1689       @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
1690                        s/--\@\]/$delimiters{$format}[1]/g;
1691                        $_;
1692                      } 
1693                  &$convert_map( $conf->config($inc_file, $agentnum) );
1694
1695     }
1696
1697     my $inc_tt = new Text::Template (
1698       TYPE       => 'ARRAY',
1699       SOURCE     => [ map "$_\n", @inc_src ],
1700       DELIMITERS => $delimiters{$format},
1701     ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
1702
1703     unless ( $inc_tt->compile() ) {
1704       my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
1705       warn $error. "Template:\n". join('', map "$_\n", @inc_src);
1706       die $error;
1707     }
1708
1709     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
1710
1711     $invoice_data{$include} =~ s/\n+$//
1712       if ($format eq 'latex');
1713   }
1714
1715   $invoice_lines = 0;
1716   my $wasfunc = 0;
1717   foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
1718     /invoice_lines\((\d*)\)/;
1719     $invoice_lines += $1 || scalar(@buf);
1720     $wasfunc=1;
1721   }
1722   die "no invoice_lines() functions in template?"
1723     if ( $format eq 'template' && !$wasfunc );
1724
1725   if ( $invoice_lines ) {
1726     $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
1727     $invoice_data{'total_pages'}++
1728       if scalar(@buf) % $invoice_lines;
1729   }
1730
1731   #setup subroutine for the template
1732   $invoice_data{invoice_lines} = sub {
1733     my $lines = shift || scalar(@buf);
1734     map { 
1735       scalar(@buf)
1736         ? shift @buf
1737         : [ '', '' ];
1738     }
1739     ( 1 .. $lines );
1740   };
1741
1742   if ($format eq 'template') {
1743
1744     my $lines;
1745     my @collect;
1746     while (@buf) {
1747       push @collect, split("\n",
1748         $text_template->fill_in( HASH => \%invoice_data )
1749       );
1750       $invoice_data{'page'}++;
1751     }
1752     map "$_\n", @collect;
1753
1754   } else { # this is where we actually create the invoice
1755
1756     if ( $params{no_addresses} ) {
1757       delete $invoice_data{$_} foreach qw(
1758         payname company address1 address2 city state zip country
1759       );
1760       $invoice_data{returnaddress} = '~';
1761     }
1762
1763     warn "filling in template for invoice ". $self->invnum. "\n"
1764       if $DEBUG;
1765     warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
1766       if $DEBUG > 1;
1767
1768     $text_template->fill_in(HASH => \%invoice_data);
1769   }
1770 }
1771
1772 sub notice_name { '('.shift->table.')'; }
1773
1774 sub template_conf { 'invoice_'; }
1775
1776 # helper routine for generating date ranges
1777 sub _prior_month30s {
1778   my $self = shift;
1779   my @ranges = (
1780    [ 1,       2592000 ], # 0-30 days ago
1781    [ 2592000, 5184000 ], # 30-60 days ago
1782    [ 5184000, 7776000 ], # 60-90 days ago
1783    [ 7776000, 0       ], # 90+   days ago
1784   );
1785
1786   map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
1787           $_->[1] ? $self->_date - $_->[1] - 1 : '',
1788       ] }
1789   @ranges;
1790 }
1791
1792 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
1793
1794 Returns an postscript invoice, as a scalar.
1795
1796 Options can be passed as a hashref (recommended) or as a list of time, template
1797 and then any key/value pairs for any other options.
1798
1799 I<time> an optional value used to control the printing of overdue messages.  The
1800 default is now.  It isn't the date of the invoice; that's the `_date' field.
1801 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1802 L<Time::Local> and L<Date::Parse> for conversion functions.
1803
1804 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1805
1806 =cut
1807
1808 sub print_ps {
1809   my $self = shift;
1810
1811   my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1812   my $ps = generate_ps($file);
1813   unlink($logofile);
1814   unlink($barcodefile) if $barcodefile;
1815
1816   $ps;
1817 }
1818
1819 =item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
1820
1821 Returns an PDF invoice, as a scalar.
1822
1823 Options can be passed as a hashref (recommended) or as a list of time, template
1824 and then any key/value pairs for any other options.
1825
1826 I<time> an optional value used to control the printing of overdue messages.  The
1827 default is now.  It isn't the date of the invoice; that's the `_date' field.
1828 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1829 L<Time::Local> and L<Date::Parse> for conversion functions.
1830
1831 I<template>, if specified, is the name of a suffix for alternate invoices.
1832
1833 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1834
1835 =cut
1836
1837 sub print_pdf {
1838   my $self = shift;
1839
1840   my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1841   my $pdf = generate_pdf($file);
1842   unlink($logofile);
1843   unlink($barcodefile) if $barcodefile;
1844
1845   $pdf;
1846 }
1847
1848 =item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
1849
1850 Returns an HTML invoice, as a scalar.
1851
1852 I<time> an optional value used to control the printing of overdue messages.  The
1853 default is now.  It isn't the date of the invoice; that's the `_date' field.
1854 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1855 L<Time::Local> and L<Date::Parse> for conversion functions.
1856
1857 I<template>, if specified, is the name of a suffix for alternate invoices.
1858
1859 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1860
1861 I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
1862 when emailing the invoice as part of a multipart/related MIME email.
1863
1864 =cut
1865
1866 sub print_html {
1867   my $self = shift;
1868   my %params;
1869   if ( ref($_[0]) ) {
1870     %params = %{ shift() }; 
1871   } else {
1872     %params = @_;
1873   }
1874   $params{'format'} = 'html';
1875   
1876   $self->print_generic( %params );
1877 }
1878
1879 # quick subroutine for print_latex
1880 #
1881 # There are ten characters that LaTeX treats as special characters, which
1882 # means that they do not simply typeset themselves: 
1883 #      # $ % & ~ _ ^ \ { }
1884 #
1885 # TeX ignores blanks following an escaped character; if you want a blank (as
1886 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
1887
1888 sub _latex_escape {
1889   my $value = shift;
1890   $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
1891   $value =~ s/([<>])/\$$1\$/g;
1892   $value;
1893 }
1894
1895 sub _html_escape {
1896   my $value = shift;
1897   encode_entities($value);
1898   $value;
1899 }
1900
1901 sub _html_escape_nbsp {
1902   my $value = _html_escape(shift);
1903   $value =~ s/ +/&nbsp;/g;
1904   $value;
1905 }
1906
1907 #utility methods for print_*
1908
1909 sub _translate_old_latex_format {
1910   warn "_translate_old_latex_format called\n"
1911     if $DEBUG; 
1912
1913   my @template = ();
1914   while ( @_ ) {
1915     my $line = shift;
1916   
1917     if ( $line =~ /^%%Detail\s*$/ ) {
1918   
1919       push @template, q![@--!,
1920                       q!  foreach my $_tr_line (@detail_items) {!,
1921                       q!    if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
1922                       q!      $_tr_line->{'description'} .= !, 
1923                       q!        "\\tabularnewline\n~~".!,
1924                       q!        join( "\\tabularnewline\n~~",!,
1925                       q!          @{$_tr_line->{'ext_description'}}!,
1926                       q!        );!,
1927                       q!    }!;
1928
1929       while ( ( my $line_item_line = shift )
1930               !~ /^%%EndDetail\s*$/                            ) {
1931         $line_item_line =~ s/'/\\'/g;    # nice LTS
1932         $line_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
1933         $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1934         push @template, "    \$OUT .= '$line_item_line';";
1935       }
1936
1937       push @template, '}',
1938                       '--@]';
1939       #' doh, gvim
1940     } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1941
1942       push @template, '[@--',
1943                       '  foreach my $_tr_line (@total_items) {';
1944
1945       while ( ( my $total_item_line = shift )
1946               !~ /^%%EndTotalDetails\s*$/                      ) {
1947         $total_item_line =~ s/'/\\'/g;    # nice LTS
1948         $total_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
1949         $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1950         push @template, "    \$OUT .= '$total_item_line';";
1951       }
1952
1953       push @template, '}',
1954                       '--@]';
1955
1956     } else {
1957       $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
1958       push @template, $line;  
1959     }
1960   
1961   }
1962
1963   if ($DEBUG) {
1964     warn "$_\n" foreach @template;
1965   }
1966
1967   (@template);
1968 }
1969
1970 =item terms
1971
1972 =cut
1973
1974 sub terms {
1975   my $self = shift;
1976   my $conf = $self->conf;
1977
1978   #check for an invoice-specific override
1979   return $self->invoice_terms if $self->invoice_terms;
1980   
1981   #check for a customer- specific override
1982   my $cust_main = $self->cust_main;
1983   return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
1984
1985   my $agentnum = '';
1986   if ( $cust_main ) {
1987     $agentnum = $cust_main->agentnum;
1988   } elsif ( my $prospect_main = $self->prospect_main ) {
1989     $agentnum = $prospect_main->agentnum;
1990   }
1991
1992   #use configured default
1993   $conf->config('invoice_default_terms', $agentnum) || '';
1994 }
1995
1996 =item due_date
1997
1998 =cut
1999
2000 sub due_date {
2001   my $self = shift;
2002   my $duedate = '';
2003   if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
2004     $duedate = $self->_date() + ( $1 * 86400 );
2005   } elsif ( $self->terms =~ /^End of Month$/ ) {
2006     my ($mon,$year) = (localtime($self->_date) )[4,5];
2007     $mon++;
2008     until ( $mon < 12 ) { $mon -= 12; $year++; }
2009     my $nextmonth_first = timelocal(0,0,0,1,$mon,$year);
2010     $duedate = $nextmonth_first - 86400;
2011   }
2012   $duedate;
2013 }
2014
2015 =item due_date2str
2016
2017 =cut
2018
2019 sub due_date2str {
2020   my $self = shift;
2021   $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
2022 }
2023
2024 =item balance_due_msg
2025
2026 =cut
2027
2028 sub balance_due_msg {
2029   my $self = shift;
2030   my $msg = $self->mt('Balance Due');
2031   return $msg unless $self->terms; # huh?
2032   if ( !$self->conf->exists('invoice_show_prior_due_date')
2033        || $self->has_sections ) {
2034     # if enabled, the due date is shown with Total New Charges (see
2035     # _items_total) and not here
2036     # (yes, or if invoice_sections is enabled; this is just for compatibility)
2037     if ( $self->due_date ) {
2038       my $please_pay_by =
2039         $self->conf->config('invoice_pay_by_msg', $self->agentnum)
2040         || 'Please pay by [_1]';
2041       $msg .= ' - ' . $self->mt($please_pay_by, $self->due_date2str('short')).
2042               ' '
2043        unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum);
2044     } elsif ( $self->terms ) {
2045       $msg .= ' - '. $self->mt($self->terms);
2046     }
2047   }
2048   $msg;
2049 }
2050
2051 =item balance_due_date
2052
2053 =cut
2054
2055 sub balance_due_date {
2056   my $self = shift;
2057   my $conf = $self->conf;
2058   my $duedate = '';
2059   my $terms = $self->terms;
2060   if ( $terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
2061     $duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
2062   }
2063   $duedate;
2064 }
2065
2066 sub credit_balance_msg { 
2067   my $self = shift;
2068   $self->mt('Credit Balance Remaining')
2069 }
2070
2071 =item _date_pretty
2072
2073 Returns a string with the date, for example: "3/20/2008", localized for the
2074 customer.  Use _date_pretty_unlocalized for non-end-customer display use.
2075
2076 =cut
2077
2078 sub _date_pretty {
2079   my $self = shift;
2080   $self->time2str_local('short', $self->_date);
2081 }
2082
2083 =item _date_pretty_unlocalized
2084
2085 Returns a string with the date, for example: "3/20/2008", in the format
2086 configured for the back-office.  Use _date_pretty for end-customer display use.
2087
2088 =cut
2089
2090 sub _date_pretty_unlocalized {
2091   my $self = shift;
2092   time2str($date_format, $self->_date);
2093 }
2094
2095 =item email HASHREF
2096
2097 Emails this template.
2098
2099 Options are passed as a hashref.  Available options:
2100
2101 =over 4
2102
2103 =item from
2104
2105 If specified, overrides the default From: address.
2106
2107 =item notice_name
2108
2109 If specified, overrides the name of the sent document ("Invoice" or "Quotation")
2110
2111 =item template
2112
2113 (Deprecated) If specified, is the name of a suffix for alternate template files.
2114
2115 =back
2116
2117 Options accepted by generate_email can also be used.
2118
2119 =cut
2120
2121 sub email {
2122   my $self = shift;
2123   my $opt = shift || {};
2124   if ($opt and !ref($opt)) {
2125     die ref($self). '->email called with positional parameters';
2126   }
2127
2128   return if $self->hide;
2129
2130   my $error = send_email(
2131     $self->generate_email(
2132       'subject'     => $self->email_subject($opt->{template}),
2133       %$opt, # template, etc.
2134     )
2135   );
2136
2137   die "can't email: $error\n" if $error;
2138 }
2139
2140 =item generate_email OPTION => VALUE ...
2141
2142 Options:
2143
2144 =over 4
2145
2146 =item from
2147
2148 sender address, required
2149
2150 =item template
2151
2152 alternate template name, optional
2153
2154 =item subject
2155
2156 email subject, optional
2157
2158 =item notice_name
2159
2160 notice name instead of "Invoice", optional
2161
2162 =back
2163
2164 Returns an argument list to be passed to L<FS::Misc::send_email>.
2165
2166 =cut
2167
2168 use MIME::Entity;
2169 use Encode;
2170
2171 sub generate_email {
2172
2173   my $self = shift;
2174   my %args = @_;
2175   my $conf = $self->conf;
2176
2177   my $me = '[FS::Template_Mixin::generate_email]';
2178
2179   my %return = (
2180     'from'      => $args{'from'},
2181     'subject'   => ($args{'subject'} || $self->email_subject),
2182     'custnum'   => $self->custnum,
2183     'msgtype'   => 'invoice',
2184   );
2185
2186   $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
2187
2188   my $cust_main = $self->cust_main;
2189
2190   if (ref($args{'to'}) eq 'ARRAY') {
2191     $return{'to'} = $args{'to'};
2192   } elsif ( $cust_main ) {
2193     $return{'to'} = [ $cust_main->invoicing_list_emailonly ];
2194   }
2195
2196   my $tc = $self->template_conf;
2197
2198   my @text; # array of lines
2199   my $html; # a big string
2200   my @related_parts; # will contain the text/HTML alternative, and images
2201   my $related; # will contain the multipart/related object
2202
2203   if ( $conf->exists($tc. 'email_pdf') ) {
2204     if ( my $msgnum = $conf->config($tc.'email_pdf_msgnum') ) {
2205
2206       warn "$me using '${tc}email_pdf_msgnum' in multipart message"
2207         if $DEBUG;
2208
2209       my $msg_template = FS::msg_template->by_key($msgnum)
2210         or die "${tc}email_pdf_msgnum $msgnum not found\n";
2211       my %prepared = $msg_template->prepare(
2212         cust_main => $self->cust_main,
2213         object    => $self
2214       );
2215
2216       @text = split(/(?=\n)/, $prepared{'text_body'});
2217       $html = $prepared{'html_body'};
2218
2219     } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) {
2220
2221       warn "$me using '${tc}email_pdf_note' in multipart message"
2222         if $DEBUG;
2223       @text = $conf->config($tc.'email_pdf_note');
2224       $html = join('<BR>', @text);
2225   
2226     } # else use the plain text invoice
2227   }
2228
2229   if (!@text) {
2230
2231     if ( $conf->config($tc.'template') ) {
2232
2233       warn "$me generating plain text invoice"
2234         if $DEBUG;
2235
2236       # 'print_text' argument is no longer used
2237       @text = map Encode::encode_utf8($_), $self->print_text(\%args);
2238
2239     } else {
2240
2241       warn "$me no plain text version exists; sending empty message body"
2242         if $DEBUG;
2243
2244     }
2245
2246   }
2247
2248   my $text_part = build MIME::Entity (
2249     'Type'        => 'text/plain',
2250     'Encoding'    => 'quoted-printable',
2251     'Charset'     => 'UTF-8',
2252     #'Encoding'    => '7bit',
2253     'Data'        => \@text,
2254     'Disposition' => 'inline',
2255   );
2256
2257   if (!$html) {
2258
2259     if ( $conf->exists($tc.'html') ) {
2260       warn "$me generating HTML invoice"
2261         if $DEBUG;
2262
2263       $args{'from'} =~ /\@([\w\.\-]+)/;
2264       my $from = $1 || 'example.com';
2265       my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
2266
2267       my $logo;
2268       my $agentnum = $cust_main ? $cust_main->agentnum
2269                                 : $self->prospect_main->agentnum;
2270       if ( defined($args{'template'}) && length($args{'template'})
2271            && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
2272          )
2273       {
2274         $logo = 'logo_'. $args{'template'}. '.png';
2275       } else {
2276         $logo = "logo.png";
2277       }
2278       my $image_data = $conf->config_binary( $logo, $agentnum);
2279
2280       push @related_parts, build MIME::Entity
2281         'Type'       => 'image/png',
2282         'Encoding'   => 'base64',
2283         'Data'       => $image_data,
2284         'Filename'   => 'logo.png',
2285         'Content-ID' => "<$content_id>",
2286       ;
2287    
2288       if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) {
2289         my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
2290         push @related_parts, build MIME::Entity
2291           'Type'       => 'image/png',
2292           'Encoding'   => 'base64',
2293           'Data'       => $self->invoice_barcode(0),
2294           'Filename'   => 'barcode.png',
2295           'Content-ID' => "<$barcode_content_id>",
2296         ;
2297         $args{'barcode_cid'} = $barcode_content_id;
2298       }
2299
2300       $html = $self->print_html({ 'cid'=>$content_id, %args });
2301     }
2302
2303   }
2304
2305   if ( $html ) {
2306
2307     warn "$me creating HTML/text multipart message"
2308       if $DEBUG;
2309
2310     $return{'nobody'} = 1;
2311
2312     my $alternative = build MIME::Entity
2313       'Type'        => 'multipart/alternative',
2314       #'Encoding'    => '7bit',
2315       'Disposition' => 'inline'
2316     ;
2317
2318     if ( @text ) {
2319       $alternative->add_part($text_part);
2320     }
2321
2322     $alternative->attach(
2323       'Type'        => 'text/html',
2324       'Encoding'    => 'quoted-printable',
2325       'Data'        => [ '<html>',
2326                          '  <head>',
2327                          '    <title>',
2328                          '      '. encode_entities($return{'subject'}), 
2329                          '    </title>',
2330                          '  </head>',
2331                          '  <body bgcolor="#e8e8e8">',
2332                          Encode::encode_utf8($html),
2333                          '  </body>',
2334                          '</html>',
2335                        ],
2336       'Disposition' => 'inline',
2337       #'Filename'    => 'invoice.pdf',
2338     );
2339
2340     unshift @related_parts, $alternative;
2341
2342     $related = build MIME::Entity 'Type'     => 'multipart/related',
2343                                   'Encoding' => '7bit';
2344
2345     #false laziness w/Misc::send_email
2346     $related->head->replace('Content-type',
2347       $related->mime_type.
2348       '; boundary="'. $related->head->multipart_boundary. '"'.
2349       '; type=multipart/alternative'
2350     );
2351
2352     $related->add_part($_) foreach @related_parts;
2353
2354   }
2355
2356   my @otherparts = ();
2357   if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
2358
2359     if ( $conf->config('voip-cdr_email_attach') eq 'zip' ) {
2360
2361       my $data = join('', map "$_\n",
2362                    $self->call_details(prepend_billed_number=>1)
2363                  );
2364
2365       my $zip = new Archive::Zip;
2366       my $file = $zip->addString( $data, 'usage-'.$self->invnum.'.csv' );
2367       $file->desiredCompressionMethod( COMPRESSION_DEFLATED );
2368
2369       my $zipdata = '';
2370       my $SH = IO::Scalar->new(\$zipdata);
2371       my $status = $zip->writeToFileHandle($SH);
2372       die "Error zipping CDR attachment: $!" unless $status == AZ_OK;
2373
2374       push @otherparts, build MIME::Entity
2375         'Type'        => 'application/zip',
2376         'Encoding'    => 'base64',
2377         'Data'        => $zipdata,
2378         'Disposition' => 'attachment',
2379         'Filename'    => 'usage-'. $self->invnum. '.zip',
2380       ;
2381
2382     } else { # } elsif ( $conf->config('voip-cdr_email_attach') eq 'csv' ) {
2383  
2384       push @otherparts, build MIME::Entity
2385         'Type'        => 'text/csv',
2386         'Encoding'    => '7bit',
2387         'Data'        => [ map { "$_\n" }
2388                              $self->call_details('prepend_billed_number' => 1)
2389                          ],
2390         'Disposition' => 'attachment',
2391         'Filename'    => 'usage-'. $self->invnum. '.csv',
2392       ;
2393
2394     }
2395
2396   }
2397
2398   if ( $conf->exists($tc.'email_pdf') ) {
2399
2400     #attaching pdf too:
2401     # multipart/mixed
2402     #   multipart/related
2403     #     multipart/alternative
2404     #       text/plain
2405     #       text/html
2406     #     image/png
2407     #   application/pdf
2408
2409     my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
2410     push @otherparts, $pdf;
2411   }
2412
2413   if (@otherparts) {
2414     $return{'content-type'} = 'multipart/mixed'; # of the outer container
2415     if ( $html ) {
2416       $return{'mimeparts'} = [ $related, @otherparts ];
2417       $return{'type'} = 'multipart/related'; # of the first part
2418     } else {
2419       $return{'mimeparts'} = [ $text_part, @otherparts ];
2420       $return{'type'} = 'text/plain';
2421     }
2422   } elsif ( $html ) { # no PDF or CSV, strip the outer container
2423     $return{'mimeparts'} = \@related_parts;
2424     $return{'content-type'} = 'multipart/related';
2425     $return{'type'} = 'multipart/alternative';
2426   } else { # no HTML either
2427     $return{'body'} = \@text;
2428     $return{'content-type'} = 'text/plain';
2429   }
2430
2431   %return;
2432
2433 }
2434
2435 =item mimebuild_pdf
2436
2437 Returns a list suitable for passing to MIME::Entity->build(), representing
2438 this invoice as PDF attachment.
2439
2440 =cut
2441
2442 sub mimebuild_pdf {
2443   my $self = shift;
2444   (
2445     'Type'        => 'application/pdf',
2446     'Encoding'    => 'base64',
2447     'Data'        => [ $self->print_pdf(@_) ],
2448     'Disposition' => 'attachment',
2449     'Filename'    => 'invoice-'. $self->invnum. '.pdf',
2450   );
2451 }
2452
2453 =item postal_mail_fsinc
2454
2455 Sends this invoice to the Freeside Internet Services, Inc. print and mail
2456 service.
2457
2458 =cut
2459
2460 use CAM::PDF;
2461 use IO::Socket::SSL;
2462 use LWP::UserAgent;
2463 use HTTP::Request::Common qw( POST );
2464 use JSON::XS;
2465 use MIME::Base64;
2466 sub postal_mail_fsinc {
2467   my ( $self, %opt ) = @_;
2468
2469   my $url = 'https://ws.freeside.biz/print';
2470
2471   my $cust_main = $self->cust_main;
2472   my $agentnum = $cust_main->agentnum;
2473   my $bill_location = $cust_main->bill_location;
2474
2475   die "Extra charges for international mailing; contact support\@freeside.biz to enable\n"
2476     if $bill_location->country ne 'US';
2477
2478   my $conf = new FS::Conf;
2479
2480   my @company_address = $conf->config('company_address', $agentnum);
2481   my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip );
2482   if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
2483     $company_address1 = $company_address[0];
2484     $company_address2 = $company_address[1];
2485     $company_city  = $1;
2486     $company_state = $2;
2487     $company_zip   = $3;
2488   } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
2489     $company_address1 = $company_address[0];
2490     $company_address2 = '';
2491     $company_city  = $1;
2492     $company_state = $2;
2493     $company_zip   = $3;
2494   } else {
2495     die "Unparsable company_address; contact support\@freeside.biz\n";
2496   }
2497   $company_city =~ s/,$//;
2498
2499   my $file = $self->print_pdf(%opt, 'no_addresses' => 1);
2500   my $pages = CAM::PDF->new($file)->numPages;
2501
2502   my $ua = LWP::UserAgent->new(
2503     'ssl_opts' => { 
2504       verify_hostname => 0,
2505       SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
2506       SSL_version     => 'SSLv3',
2507     }
2508   );
2509   my $response = $ua->request( POST $url, [
2510     'support-key'      => scalar($conf->config('support-key')),
2511     'file'             => encode_base64($file),
2512     'pages'            => $pages,
2513
2514     #from:
2515     'company_name'     => scalar( $conf->config('company_name', $agentnum) ),
2516     'company_address1' => $company_address1,
2517     'company_address2' => $company_address2,
2518     'company_city'     => $company_city,
2519     'company_state'    => $company_state,
2520     'company_zip'      => $company_zip,
2521     'company_country'  => 'US',
2522     'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)),
2523     'company_email'    => scalar($conf->config('invoice_from', $agentnum)),
2524
2525     #to:
2526     'name'             => ( $cust_main->payname
2527                               && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/
2528                                 ? $cust_main->payname
2529                                 : $cust_main->contact_firstlast
2530                           ),
2531     'company'          => $cust_main->company,
2532     'address1'         => $bill_location->address1,
2533     'address2'         => $bill_location->address2,
2534     'city'             => $bill_location->city,
2535     'state'            => $bill_location->state,
2536     'zip'              => $bill_location->zip,
2537     'country'          => $bill_location->country,
2538   ]);
2539
2540   die "Print connection error: ". $response->message.
2541       ' ('. $response->as_string. ")\n"
2542     unless $response->is_success;
2543
2544   local $@;
2545   my $content = eval { decode_json($response->content) };
2546   die "Print JSON error : $@\n" if $@;
2547
2548   die $content->{error}."\n"
2549     if $content->{error};
2550
2551   #TODO: store this so we can query for a status later
2552   warn "Invoice printed, ID ". $content->{id}. "\n";
2553
2554   $content->{id};
2555 }
2556
2557 =item _items_sections OPTIONS
2558
2559 Generate section information for all items appearing on this invoice.
2560 This will only be called for multi-section invoices.
2561
2562 For each line item (L<FS::cust_bill_pkg> record), this will fetch all 
2563 related display records (L<FS::cust_bill_pkg_display>) and organize 
2564 them into two groups ("early" and "late" according to whether they come 
2565 before or after the total), then into sections.  A subtotal is calculated 
2566 for each section.
2567
2568 Section descriptions are returned in sort weight order.  Each consists 
2569 of a hash containing:
2570
2571 description: the package category name, escaped
2572 subtotal: the total charges in that section
2573 tax_section: a flag indicating that the section contains only tax charges
2574 summarized: same as tax_section, for some reason
2575 sort_weight: the package category's sort weight
2576
2577 If 'condense' is set on the display record, it also contains everything 
2578 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
2579 coderefs to generate parts of the invoice.  This is not advised.
2580
2581 The method returns two arrayrefs, one of "early" sections and one of "late"
2582 sections.
2583
2584 OPTIONS may include:
2585
2586 by_location: a flag to divide the invoice into sections by location.  
2587 Each section hash will have a 'location' element containing a hashref of 
2588 the location fields (see L<FS::cust_location>).  The section description
2589 will be the location label, but the template can use any of the location 
2590 fields to create a suitable label.
2591
2592 by_category: a flag to divide the invoice into sections using display 
2593 records (see L<FS::cust_bill_pkg_display>).  This is the "traditional" 
2594 behavior.  Each section hash will have a 'category' element containing
2595 the section name from the display record (which probably equals the 
2596 category name of the package, but may not in some cases).
2597
2598 summary: a flag indicating that this is a summary-format invoice.
2599 Turning this on has the following effects:
2600 - Ignores display items with the 'summary' flag.
2601 - Places all sections in the "early" group even if they have post_total.
2602 - Creates sections for all non-disabled package categories, even if they 
2603 have no charges on this invoice, as well as a section with no name.
2604
2605 escape: an escape function to use for section titles.
2606
2607 extra_sections: an arrayref of additional sections to return after the 
2608 sorted list.  If there are any of these, section subtotals exclude 
2609 usage charges.
2610
2611 format: 'latex', 'html', or 'template' (i.e. text).  Not used, but 
2612 passed through to C<_condense_section()>.
2613
2614 =cut
2615
2616 use vars qw(%pkg_category_cache);
2617 sub _items_sections {
2618   my $self = shift;
2619   my %opt = @_;
2620   
2621   my $escape = $opt{escape};
2622   my @extra_sections = @{ $opt{extra_sections} || [] };
2623
2624   # $subtotal{$locationnum}{$categoryname} = amount.
2625   # if we're not using by_location, $locationnum is undef.
2626   # if we're not using by_category, you guessed it, $categoryname is undef.
2627   # if we're not using either one, we shouldn't be here in the first place...
2628   my %subtotal = ();
2629   my %late_subtotal = ();
2630   my %not_tax = ();
2631
2632   # About tax items + multisection invoices:
2633   # If either invoice_*summary option is enabled, AND there is a 
2634   # package category with the name of the tax, then there will be 
2635   # a display record assigning the tax item to that category.
2636   #
2637   # However, the taxes are always placed in the "Taxes, Surcharges,
2638   # and Fees" section regardless of that.  The only effect of the 
2639   # display record is to create a subtotal for the summary page.
2640
2641   # cache these
2642   my $pkg_hash = $self->cust_pkg_hash;
2643
2644   foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
2645   {
2646
2647       my $usage = $cust_bill_pkg->usage;
2648
2649       my $locationnum;
2650       if ( $opt{by_location} ) {
2651         if ( $cust_bill_pkg->pkgnum ) {
2652           $locationnum = $pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum;
2653         } else {
2654           $locationnum = '';
2655         }
2656       } else {
2657         $locationnum = undef;
2658       }
2659
2660       # as in _items_cust_pkg, if a line item has no display records,
2661       # cust_bill_pkg_display() returns a default record for it
2662
2663       foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
2664         next if ( $display->summary && $opt{summary} );
2665
2666         my $section = $display->section;
2667         my $type    = $display->type;
2668         # Set $section = undef if we're sectioning by location and this
2669         # line item _has_ a location (i.e. isn't a fee).
2670         $section = undef if $locationnum;
2671
2672         # set this flag if the section is not tax-only
2673         $not_tax{$locationnum}{$section} = 1
2674           if $cust_bill_pkg->pkgnum  or $cust_bill_pkg->feepart;
2675
2676         # there's actually a very important piece of logic buried in here:
2677         # incrementing $late_subtotal{$section} CREATES 
2678         # $late_subtotal{$section}.  keys(%late_subtotal) is later used 
2679         # to define the list of late sections, and likewise keys(%subtotal).
2680         # When _items_cust_bill_pkg is called to generate line items for 
2681         # real, it will be called with 'section' => $section for each 
2682         # of these.
2683         if ( $display->post_total && !$opt{summary} ) {
2684           if (! $type || $type eq 'S') {
2685             $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
2686               if $cust_bill_pkg->setup != 0
2687               || $cust_bill_pkg->setup_show_zero;
2688           }
2689
2690           if (! $type) {
2691             $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
2692               if $cust_bill_pkg->recur != 0
2693               || $cust_bill_pkg->recur_show_zero;
2694           }
2695
2696           if ($type && $type eq 'R') {
2697             $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
2698               if $cust_bill_pkg->recur != 0
2699               || $cust_bill_pkg->recur_show_zero;
2700           }
2701           
2702           if ($type && $type eq 'U') {
2703             $late_subtotal{$locationnum}{$section} += $usage
2704               unless scalar(@extra_sections);
2705           }
2706
2707         } else { # it's a pre-total (normal) section
2708
2709           # skip tax items unless they're explicitly included in a section
2710           next if $cust_bill_pkg->pkgnum == 0 and
2711                   ! $cust_bill_pkg->feepart   and
2712                   ! $section;
2713
2714           if ( $type eq 'S' ) {
2715             $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
2716               if $cust_bill_pkg->setup != 0
2717               || $cust_bill_pkg->setup_show_zero;
2718           } elsif ( $type eq 'R' ) {
2719             $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
2720               if $cust_bill_pkg->recur != 0
2721               || $cust_bill_pkg->recur_show_zero;
2722           } elsif ( $type eq 'U' ) {
2723             $subtotal{$locationnum}{$section} += $usage
2724               unless scalar(@extra_sections);
2725           } elsif ( !$type ) {
2726             $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
2727                                                + $cust_bill_pkg->recur;
2728           }
2729
2730         }
2731
2732       }
2733
2734   }
2735
2736   %pkg_category_cache = ();
2737
2738   # summary invoices need subtotals for all non-disabled package categories,
2739   # even if they're zero
2740   # but currently assume that there are no location sections, or at least
2741   # that the summary page doesn't care about them
2742   if ( $opt{summary} ) {
2743     foreach my $category (qsearch('pkg_category', {disabled => ''})) {
2744       $subtotal{''}{$category->categoryname} ||= 0;
2745     }
2746     $subtotal{''}{''} ||= 0;
2747   }
2748
2749   my @sections;
2750   foreach my $post_total (0,1) {
2751     my @these;
2752     my $s = $post_total ? \%late_subtotal : \%subtotal;
2753     foreach my $locationnum (keys %$s) {
2754       foreach my $sectionname (keys %{ $s->{$locationnum} }) {
2755         my $section = {
2756                         'subtotal'    => $s->{$locationnum}{$sectionname},
2757                         'sort_weight' => 0,
2758                       };
2759         if ( $locationnum ) {
2760           $section->{'locationnum'} = $locationnum;
2761           my $location = FS::cust_location->by_key($locationnum);
2762           $section->{'description'} = &{ $escape }($location->location_label);
2763           # Better ideas? This will roughly group them by proximity, 
2764           # which alpha sorting on any of the address fields won't.
2765           # Sorting by locationnum is meaningless.
2766           # We have to sort on _something_ or the order may change 
2767           # randomly from one invoice to the next, which will confuse
2768           # people.
2769           $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
2770                                       $locationnum;
2771           $section->{'location'} = {
2772             label_prefix => &{ $escape }($location->label_prefix),
2773             map { $_ => &{ $escape }($location->get($_)) }
2774               $location->fields
2775           };
2776         } else {
2777           $section->{'category'} = $sectionname;
2778           $section->{'description'} = &{ $escape }($sectionname);
2779           if ( _pkg_category($sectionname) ) {
2780             $section->{'sort_weight'} = _pkg_category($sectionname)->weight;
2781             if ( _pkg_category($sectionname)->condense ) {
2782               $section = { %$section, $self->_condense_section($opt{format}) };
2783             }
2784           }
2785         }
2786         if ( !$post_total and !$not_tax{$locationnum}{$sectionname} ) {
2787           # then it's a tax-only section
2788           $section->{'summarized'} = 'Y';
2789           $section->{'tax_section'} = 'Y';
2790         }
2791         push @these, $section;
2792       } # foreach $sectionname
2793     } #foreach $locationnum
2794     push @these, @extra_sections if $post_total == 0;
2795     # need an alpha sort for location sections, because postal codes can 
2796     # be non-numeric
2797     $sections[ $post_total ] = [ sort {
2798       $opt{'by_location'} ? 
2799         ($a->{sort_weight} cmp $b->{sort_weight}) :
2800         ($a->{sort_weight} <=> $b->{sort_weight})
2801       } @these ];
2802   } #foreach $post_total
2803
2804   return @sections; # early, late
2805 }
2806
2807 #helper subs for above
2808
2809 sub cust_pkg_hash {
2810   my $self = shift;
2811   $self->{cust_pkg} ||= { map { $_->pkgnum => $_ } $self->cust_pkg };
2812 }
2813
2814 sub _pkg_category {
2815   my $categoryname = shift;
2816   $pkg_category_cache{$categoryname} ||=
2817     qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
2818 }
2819
2820 my %condensed_format = (
2821   'label' => [ qw( Description Qty Amount ) ],
2822   'fields' => [
2823                 sub { shift->{description} },
2824                 sub { shift->{quantity} },
2825                 sub { my($href, %opt) = @_;
2826                       ($opt{dollar} || ''). $href->{amount};
2827                     },
2828               ],
2829   'align'  => [ qw( l r r ) ],
2830   'span'   => [ qw( 5 1 1 ) ],            # unitprices?
2831   'width'  => [ qw( 10.7cm 1.4cm 1.6cm ) ],   # don't like this
2832 );
2833
2834 sub _condense_section {
2835   my ( $self, $format ) = ( shift, shift );
2836   ( 'condensed' => 1,
2837     map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
2838       qw( description_generator
2839           header_generator
2840           total_generator
2841           total_line_generator
2842         )
2843   );
2844 }
2845
2846 sub _condensed_generator_defaults {
2847   my ( $self, $format ) = ( shift, shift );
2848   return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
2849 }
2850
2851 my %html_align = (
2852   'c' => 'center',
2853   'l' => 'left',
2854   'r' => 'right',
2855 );
2856
2857 sub _condensed_header_generator {
2858   my ( $self, $format ) = ( shift, shift );
2859
2860   my ( $f, $prefix, $suffix, $separator, $column ) =
2861     _condensed_generator_defaults($format);
2862
2863   if ($format eq 'latex') {
2864     $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
2865     $suffix = "\\\\\n\\hline";
2866     $separator = "&\n";
2867     $column =
2868       sub { my ($d,$a,$s,$w) = @_;
2869             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2870           };
2871   } elsif ( $format eq 'html' ) {
2872     $prefix = '<th></th>';
2873     $suffix = '';
2874     $separator = '';
2875     $column =
2876       sub { my ($d,$a,$s,$w) = @_;
2877             return qq!<th align="$html_align{$a}">$d</th>!;
2878       };
2879   }
2880
2881   sub {
2882     my @args = @_;
2883     my @result = ();
2884
2885     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
2886       push @result,
2887         &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
2888     }
2889
2890     $prefix. join($separator, @result). $suffix;
2891   };
2892
2893 }
2894
2895 sub _condensed_description_generator {
2896   my ( $self, $format ) = ( shift, shift );
2897
2898   my ( $f, $prefix, $suffix, $separator, $column ) =
2899     _condensed_generator_defaults($format);
2900
2901   my $money_char = '$';
2902   if ($format eq 'latex') {
2903     $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
2904     $suffix = '\\\\';
2905     $separator = " & \n";
2906     $column =
2907       sub { my ($d,$a,$s,$w) = @_;
2908             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2909           };
2910     $money_char = '\\dollar';
2911   }elsif ( $format eq 'html' ) {
2912     $prefix = '"><td align="center"></td>';
2913     $suffix = '';
2914     $separator = '';
2915     $column =
2916       sub { my ($d,$a,$s,$w) = @_;
2917             return qq!<td align="$html_align{$a}">$d</td>!;
2918       };
2919     #$money_char = $conf->config('money_char') || '$';
2920     $money_char = '';  # this is madness
2921   }
2922
2923   sub {
2924     #my @args = @_;
2925     my $href = shift;
2926     my @result = ();
2927
2928     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
2929       my $dollar = '';
2930       $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
2931       push @result,
2932         &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
2933                     map { $f->{$_}->[$i] } qw(align span width)
2934                   );
2935     }
2936
2937     $prefix. join( $separator, @result ). $suffix;
2938   };
2939
2940 }
2941
2942 sub _condensed_total_generator {
2943   my ( $self, $format ) = ( shift, shift );
2944
2945   my ( $f, $prefix, $suffix, $separator, $column ) =
2946     _condensed_generator_defaults($format);
2947   my $style = '';
2948
2949   if ($format eq 'latex') {
2950     $prefix = "& ";
2951     $suffix = "\\\\\n";
2952     $separator = " & \n";
2953     $column =
2954       sub { my ($d,$a,$s,$w) = @_;
2955             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2956           };
2957   }elsif ( $format eq 'html' ) {
2958     $prefix = '';
2959     $suffix = '';
2960     $separator = '';
2961     $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2962     $column =
2963       sub { my ($d,$a,$s,$w) = @_;
2964             return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2965       };
2966   }
2967
2968
2969   sub {
2970     my @args = @_;
2971     my @result = ();
2972
2973     #  my $r = &{$f->{fields}->[$i]}(@args);
2974     #  $r .= ' Total' unless $i;
2975
2976     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
2977       push @result,
2978         &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
2979                     map { $f->{$_}->[$i] } qw(align span width)
2980                   );
2981     }
2982
2983     $prefix. join( $separator, @result ). $suffix;
2984   };
2985
2986 }
2987
2988 =item total_line_generator FORMAT
2989
2990 Returns a coderef used for generation of invoice total line items for this
2991 usage_class.  FORMAT is either html or latex
2992
2993 =cut
2994
2995 # should not be used: will have issues with hash element names (description vs
2996 # total_item and amount vs total_amount -- another array of functions?
2997
2998 sub _condensed_total_line_generator {
2999   my ( $self, $format ) = ( shift, shift );
3000
3001   my ( $f, $prefix, $suffix, $separator, $column ) =
3002     _condensed_generator_defaults($format);
3003   my $style = '';
3004
3005   if ($format eq 'latex') {
3006     $prefix = "& ";
3007     $suffix = "\\\\\n";
3008     $separator = " & \n";
3009     $column =
3010       sub { my ($d,$a,$s,$w) = @_;
3011             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
3012           };
3013   }elsif ( $format eq 'html' ) {
3014     $prefix = '';
3015     $suffix = '';
3016     $separator = '';
3017     $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
3018     $column =
3019       sub { my ($d,$a,$s,$w) = @_;
3020             return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
3021       };
3022   }
3023
3024
3025   sub {
3026     my @args = @_;
3027     my @result = ();
3028
3029     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
3030       push @result,
3031         &{$column}( &{$f->{fields}->[$i]}(@args),
3032                     map { $f->{$_}->[$i] } qw(align span width)
3033                   );
3034     }
3035
3036     $prefix. join( $separator, @result ). $suffix;
3037   };
3038
3039 }
3040
3041 =item _items_pkg [ OPTIONS ]
3042
3043 Return line item hashes for each package item on this invoice. Nearly 
3044 equivalent to 
3045
3046 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
3047
3048 OPTIONS are passed through to _items_cust_bill_pkg, and should include
3049 'format' and 'escape_function' at minimum.
3050
3051 To produce items for a specific invoice section, OPTIONS should include
3052 'section', a hashref containing 'category' and/or 'locationnum' keys.
3053
3054 'section' may also contain a key named 'condensed'. If this is present
3055 and has a true value, _items_pkg will try to merge identical items into items
3056 with 'quantity' equal to the number of items (not the sum of their separate
3057 quantities, for some reason).
3058
3059 =cut
3060
3061 sub _items_nontax {
3062   my $self = shift;
3063   # The order of these is important.  Bundled line items will be merged into
3064   # the most recent non-hidden item, so it needs to be the one with:
3065   # - the same pkgnum
3066   # - the same start date
3067   # - no pkgpart_override
3068   #
3069   # So: sort by pkgnum,
3070   # then by sdate
3071   # then sort the base line item before any overrides
3072   # then sort hidden before non-hidden add-ons
3073   # then sort by override pkgpart (for consistency)
3074   sort { $a->pkgnum <=> $b->pkgnum        or
3075          $a->sdate  <=> $b->sdate         or
3076          ($a->pkgpart_override ? 0 : -1)  or
3077          ($b->pkgpart_override ? 0 : 1)   or
3078          $b->hidden cmp $a->hidden        or
3079          $a->pkgpart_override <=> $b->pkgpart_override
3080        }
3081   # and of course exclude taxes and fees
3082   grep { $_->pkgnum > 0 } $self->cust_bill_pkg;
3083 }
3084
3085 sub _items_fee {
3086   my $self = shift;
3087   my %options = @_;
3088   my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
3089   my $escape_function = $options{escape_function};
3090
3091   my @items;
3092   foreach my $cust_bill_pkg (@cust_bill_pkg) {
3093     # cache this, so we don't look it up again in every section
3094     my $part_fee = $cust_bill_pkg->get('part_fee')
3095        || $cust_bill_pkg->part_fee;
3096     $cust_bill_pkg->set('part_fee', $part_fee);
3097     if (!$part_fee) {
3098       #die "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; # might make more sense
3099       warn "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n";
3100       next;
3101     }
3102     if ( exists($options{section}) and exists($options{section}{category}) )
3103     {
3104       my $categoryname = $options{section}{category};
3105       # then filter for items that have that section
3106       if ( $part_fee->categoryname ne $categoryname ) {
3107         warn "skipping fee '".$part_fee->itemdesc."'--not in section $categoryname\n" if $DEBUG;
3108         next;
3109       }
3110     } # otherwise include them all in the main section
3111     # XXX what to do when sectioning by location?
3112     
3113     my @ext_desc;
3114     my %base_invnums; # invnum => invoice date
3115     foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
3116       if ($_->base_invnum) {
3117         # XXX what if base_bill has been voided?
3118         my $base_bill = FS::cust_bill->by_key($_->base_invnum);
3119         my $base_date = $self->time2str_local('short', $base_bill->_date)
3120           if $base_bill;
3121         $base_invnums{$_->base_invnum} = $base_date || '';
3122       }
3123     }
3124     foreach (sort keys(%base_invnums)) {
3125       next if $_ == $self->invnum;
3126       # per convention, we must escape ext_description lines
3127       push @ext_desc,
3128         &{$escape_function}(
3129           $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
3130         );
3131     }
3132     my $desc = $part_fee->itemdesc_locale($self->cust_main->locale);
3133     # but not escape the base description line
3134
3135     my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
3136       if $options{section_with_taxes};
3137
3138     push @items,
3139       { feepart     => $cust_bill_pkg->feepart,
3140         amount      => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
3141         description => $desc,
3142         pkg_tax     => \@pkg_tax,
3143         ext_description => \@ext_desc,
3144         # sdate/edate?
3145       };
3146   }
3147   @items;
3148 }
3149
3150 sub _items_pkg {
3151   my $self = shift;
3152   my %options = @_;
3153
3154   warn "$me _items_pkg searching for all package line items\n"
3155     if $DEBUG > 1;
3156
3157   my @cust_bill_pkg = $self->_items_nontax;
3158
3159   warn "$me _items_pkg filtering line items\n"
3160     if $DEBUG > 1;
3161   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
3162
3163   if ($options{section} && $options{section}->{condensed}) {
3164
3165     warn "$me _items_pkg condensing section\n"
3166       if $DEBUG > 1;
3167
3168     my %itemshash = ();
3169     local $Storable::canonical = 1;
3170     foreach ( @items ) {
3171       my $item = { %$_ };
3172       delete $item->{ref};
3173       delete $item->{ext_description};
3174       my $key = freeze($item);
3175       $itemshash{$key} ||= 0;
3176       $itemshash{$key} ++; # += $item->{quantity};
3177     }
3178     @items = sort { $a->{description} cmp $b->{description} }
3179              map { my $i = thaw($_);
3180                    $i->{quantity} = $itemshash{$_};
3181                    $i->{amount} =
3182                      sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
3183                    $i;
3184                  }
3185              keys %itemshash;
3186   }
3187
3188   warn "$me _items_pkg returning ". scalar(@items). " items\n"
3189     if $DEBUG > 1;
3190
3191   @items;
3192 }
3193
3194 sub _taxsort {
3195   return 0 unless $a->itemdesc cmp $b->itemdesc;
3196   return -1 if $b->itemdesc eq 'Tax';
3197   return 1 if $a->itemdesc eq 'Tax';
3198   return -1 if $b->itemdesc eq 'Other surcharges';
3199   return 1 if $a->itemdesc eq 'Other surcharges';
3200   $a->itemdesc cmp $b->itemdesc;
3201 }
3202
3203 sub _items_tax {
3204   my $self = shift;
3205   my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart } 
3206     $self->cust_bill_pkg;
3207   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
3208
3209   if ( $self->conf->exists('always_show_tax') ) {
3210     my $itemdesc = $self->conf->config('always_show_tax') || 'Tax';
3211     if (0 == grep { $_->{description} eq $itemdesc } @items) {
3212       push @items,
3213         { 'description' => $itemdesc,
3214           'amount'      => 0.00 };
3215     }
3216   }
3217   @items;
3218 }
3219
3220 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
3221
3222 Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
3223 list of hashrefs describing the line items they generate on the invoice.
3224
3225 OPTIONS may include:
3226
3227 format: the invoice format.
3228
3229 escape_function: the function used to escape strings.
3230
3231 DEPRECATED? (expensive, mostly unused?)
3232 format_function: the function used to format CDRs.
3233
3234 section: a hashref containing 'category' and/or 'locationnum'; if this 
3235 is present, only returns line items that belong to that category and/or
3236 location (whichever is defined).
3237
3238 multisection: a flag indicating that this is a multisection invoice,
3239 which does something complicated.
3240
3241 preref_callback: coderef run for each line item, code should return HTML to be
3242 displayed before that line item (quotations only)
3243
3244 section_with_taxes:  Look up and include applied taxes for each record
3245
3246 Returns a list of hashrefs, each of which may contain:
3247
3248 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
3249 ext_description, which is an arrayref of detail lines to show below 
3250 the package line.
3251
3252 =cut
3253
3254 sub _items_cust_bill_pkg {
3255   my $self = shift;
3256   my $conf = $self->conf;
3257   my $cust_bill_pkgs = shift;
3258   my %opt = @_;
3259
3260   my $format = $opt{format} || '';
3261   my $escape_function = $opt{escape_function} || sub { shift };
3262   my $format_function = $opt{format_function} || '';
3263   my $no_usage = $opt{no_usage} || '';
3264   my $unsquelched = $opt{unsquelched} || ''; #unused
3265   my ($section, $locationnum, $category);
3266   if ( $opt{section} ) {
3267     $category = $opt{section}->{category};
3268     $locationnum = $opt{section}->{locationnum};
3269   }
3270   my $summary_page = $opt{summary_page} || ''; #unused
3271   my $multisection = defined($category) || defined($locationnum);
3272   # this variable is the value of the config setting, not whether it applies
3273   # to this particular line item.
3274   my $discount_show_always = $conf->exists('discount-show-always');
3275
3276   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
3277
3278   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
3279
3280   # for location labels: use default location on the invoice date
3281   my $default_locationnum;
3282   if ( $conf->exists('invoice-all_pkg_addresses') ) {
3283     $default_locationnum = 0; # treat them all as non-default
3284   } elsif ( $self->custnum ) {
3285     my $h_cust_main;
3286     my @h_search = FS::h_cust_main->sql_h_search($self->_date);
3287     $h_cust_main = qsearchs({
3288         'table'     => 'h_cust_main',
3289         'hashref'   => { custnum => $self->custnum },
3290         'extra_sql' => $h_search[1],
3291         'addl_from' => $h_search[3],
3292     }) || $cust_main;
3293     $default_locationnum = $h_cust_main->ship_locationnum;
3294   } elsif ( $self->prospectnum ) {
3295     my $cust_location = qsearchs('cust_location',
3296       { prospectnum => $self->prospectnum,
3297         disabled => '' });
3298     $default_locationnum = $cust_location->locationnum if $cust_location;
3299   }
3300
3301   my @b = (); # accumulator for the line item hashes that we'll return
3302   my ($s, $r, $u, $d) = ( undef, undef, undef, undef );
3303             # the 'current' line item hashes for setup, recur, usage, discount
3304   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
3305   {
3306     # if the current line item is waiting to go out, and the one we're about
3307     # to start is not bundled, then push out the current one and start a new
3308     # one.
3309     foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
3310       if ( $_ && !$cust_bill_pkg->hidden ) {
3311         $_->{amount}      = sprintf( "%.2f", $_->{amount} );
3312         $_->{amount}      =~ s/^\-0\.00$/0.00/;
3313         if (exists($_->{unit_amount})) {
3314           $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
3315         }
3316         push @b, { %$_ };
3317         # we already decided to create this display line; don't reconsider it
3318         # now.
3319         #  if $_->{amount} != 0
3320         #  || $discount_show_always
3321         #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
3322         #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
3323         ;
3324         $_ = undef;
3325       }
3326     }
3327
3328     if ( $locationnum ) {
3329       # this is a location section; skip packages that aren't at this
3330       # service location.
3331       next if $cust_bill_pkg->pkgnum == 0; # skips fees...
3332       next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum 
3333               != $locationnum;
3334     }
3335
3336     # Consider display records for this item to determine if it belongs
3337     # in this section.  Note that if there are no display records, there
3338     # will be a default pseudo-record that includes all charge types 
3339     # and has no section name.
3340     my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
3341                                   ? $cust_bill_pkg->cust_bill_pkg_display
3342                                   : ( $cust_bill_pkg );
3343
3344     warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
3345          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
3346       if $DEBUG > 1;
3347
3348     if ( defined($category) ) {
3349       # then this is a package category section; process all display records
3350       # that belong to this section.
3351       @cust_bill_pkg_display = grep { $_->section eq $category }
3352                                 @cust_bill_pkg_display;
3353     } else {
3354       # otherwise, process all display records that aren't usage summaries
3355       # (I don't think there should be usage summaries if you aren't using 
3356       # category sections, but this is the historical behavior)
3357       @cust_bill_pkg_display = grep { !$_->summary }
3358                                 @cust_bill_pkg_display;
3359     }
3360
3361     my $classname = ''; # package class name, will fill in later
3362
3363     foreach my $display (@cust_bill_pkg_display) {
3364
3365       warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
3366            $display->billpkgdisplaynum. "\n"
3367         if $DEBUG > 1;
3368
3369       my $type = $display->type;
3370
3371       my $desc = $cust_bill_pkg->desc( $cust_main ? $cust_main->locale : '' );
3372       $desc = substr($desc, 0, $maxlength). '...'
3373         if $format eq 'latex' && length($desc) > $maxlength;
3374
3375       my %details_opt = ( 'format'          => $format,
3376                           'escape_function' => $escape_function,
3377                           'format_function' => $format_function,
3378                           'no_usage'        => $opt{'no_usage'},
3379                         );
3380
3381       my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
3382         if $opt{section_with_taxes};
3383
3384       if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
3385         # XXX this should be pulled out into quotation_pkg
3386
3387         warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
3388           if $DEBUG > 1;
3389         # quotation_pkgs are never fees, so don't worry about the case where
3390         # part_pkg is undefined
3391
3392         my @details = $cust_bill_pkg->details;
3393
3394         # and I guess they're never bundled either?
3395         if (( $cust_bill_pkg->setup != 0 ) || ( $cust_bill_pkg->setup_show_zero )) {
3396           my $description = $desc;
3397           $description .= ' Setup'
3398             if $cust_bill_pkg->recur != 0
3399             || $discount_show_always
3400             || $cust_bill_pkg->recur_show_zero;
3401           #push @b, {
3402           # keep it consistent, please
3403           $s = {
3404             'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
3405             'description' => $description,
3406             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
3407             'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup),
3408             'quantity'    => $cust_bill_pkg->quantity,
3409             'pkg_tax'     => \@pkg_tax,
3410             'ext_description' => \@details,
3411             'preref_html' => ( $opt{preref_callback}
3412                                  ? &{ $opt{preref_callback} }( $cust_bill_pkg )
3413                                  : ''
3414                              ),
3415           };
3416         }
3417         if (( $cust_bill_pkg->recur != 0 ) || ( $cust_bill_pkg->recur_show_zero )) {
3418           #push @b, {
3419           $r = {
3420             'pkgnum'      => $cust_bill_pkg->pkgpart, #so it displays in Ref
3421             'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
3422             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
3423             'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur),
3424             'quantity'    => $cust_bill_pkg->quantity,
3425             'pkg_tax'     => \@pkg_tax,
3426             'ext_description' => \@details,
3427            'preref_html'  => ( $opt{preref_callback}
3428                                  ? &{ $opt{preref_callback} }( $cust_bill_pkg )
3429                                  : ''
3430                              ),
3431           };
3432         }
3433
3434       } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
3435         # a "normal" package line item (not a quotation, not a fee, not a tax)
3436
3437         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
3438           if $DEBUG > 1;
3439  
3440         my $cust_pkg = $cust_bill_pkg->cust_pkg;
3441         my $part_pkg = $cust_pkg->part_pkg;
3442
3443         # which pkgpart to show for display purposes?
3444         my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
3445
3446         # start/end dates for invoice formats that do nonstandard 
3447         # things with them
3448         my %item_dates = ();
3449         %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
3450           unless $part_pkg->option('disable_line_item_date_ranges',1);
3451
3452         # not normally used, but pass this to the template anyway
3453         $classname = $part_pkg->classname;
3454
3455         if (    (!$type || $type eq 'S')
3456              && (    $cust_bill_pkg->setup != 0
3457                   || $cust_bill_pkg->setup_show_zero
3458                   || ($discount_show_always and $cust_bill_pkg->unitsetup > 0)
3459                 )
3460            )
3461          {
3462
3463           warn "$me _items_cust_bill_pkg adding setup\n"
3464             if $DEBUG > 1;
3465
3466           # append the word 'Setup' to the setup line if there's going to be
3467           # a recur line for the same package (i.e. not a one-time charge) 
3468           # XXX localization
3469           my $description = $desc;
3470           $description .= ' Setup'
3471             if $cust_bill_pkg->recur != 0
3472             || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
3473             || $cust_bill_pkg->recur_show_zero;
3474
3475           $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
3476                                                               $self->agentnum )
3477             if $part_pkg->is_prepaid #for prepaid, "display the validity period
3478                                      # triggered by the recurring charge freq
3479                                      # (RT#26274)
3480             && $cust_bill_pkg->recur == 0
3481             && ! $cust_bill_pkg->recur_show_zero;
3482
3483           my @d = ();
3484           my $svc_label;
3485
3486           # always pass the svc_label through to the template, even if 
3487           # not displaying it as an ext_description
3488           my @svc_labels = map &{$escape_function}($_),
3489             $cust_pkg->h_labels_short($self->_date,
3490                                       undef,
3491                                       'I',
3492                                       $self->conf->{locale},
3493                                      );
3494           $svc_label = $svc_labels[0];
3495
3496           unless ( $cust_pkg->part_pkg->hide_svc_detail
3497                 || $cust_bill_pkg->hidden )
3498           {
3499
3500             push @d, @svc_labels
3501               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
3502             # show the location label if it's not the customer's default
3503             # location, and we're not grouping items by location already
3504             if ( $cust_pkg->locationnum != $default_locationnum
3505                   and !defined($locationnum) ) {
3506               my $loc = $cust_pkg->location_label;
3507               $loc = substr($loc, 0, $maxlength). '...'
3508                 if $format eq 'latex' && length($loc) > $maxlength;
3509               push @d, &{$escape_function}($loc);
3510             }
3511
3512           } #unless hiding service details
3513
3514           push @d, $cust_bill_pkg->details(%details_opt)
3515             if $cust_bill_pkg->recur == 0;
3516
3517           if ( $cust_bill_pkg->hidden ) {
3518             $s->{amount}      += $cust_bill_pkg->setup;
3519             $s->{unit_amount} += $cust_bill_pkg->unitsetup;
3520             push @{ $s->{ext_description} }, @d;
3521           } else {
3522             $s = {
3523               _is_setup       => 1,
3524               description     => $description,
3525               pkgpart         => $pkgpart,
3526               pkgnum          => $cust_bill_pkg->pkgnum,
3527               amount          => $cust_bill_pkg->setup,
3528               setup_show_zero => $cust_bill_pkg->setup_show_zero,
3529               unit_amount     => $cust_bill_pkg->unitsetup,
3530               quantity        => $cust_bill_pkg->quantity,
3531               pkg_tax         => \@pkg_tax,
3532               ext_description => \@d,
3533               svc_label       => ($svc_label || ''),
3534               locationnum     => $cust_pkg->locationnum, # sure, why not?
3535             };
3536           };
3537
3538         }
3539
3540         # should we show a recur line?
3541         # if type eq 'S', then NO, because we've been told not to.
3542         # otherwise, show the recur line if:
3543         # - there's a recurring charge
3544         # - or recur_show_zero is on
3545         # - or there's a positive unitrecur (so it's been discounted to zero)
3546         #   and discount-show-always is on
3547         if (    ( !$type || $type eq 'R' || $type eq 'U' )
3548              && (
3549                      $cust_bill_pkg->recur != 0
3550                   || !defined($s)
3551                   || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
3552                   || $cust_bill_pkg->recur_show_zero
3553                 )
3554            )
3555         {
3556
3557           warn "$me _items_cust_bill_pkg adding recur/usage\n"
3558             if $DEBUG > 1;
3559
3560           my $is_summary = $display->summary;
3561           my $description = $desc;
3562           if ( $type eq 'U' and defined($r) ) {
3563             # don't just show the same description as the recur line
3564             $description = $self->mt('Usage charges');
3565           }
3566
3567           my $part_pkg = $cust_pkg->part_pkg;
3568
3569           $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
3570                                                               $self->agentnum );
3571
3572           my @d = ();
3573           my @seconds = (); # for display of usage info
3574           my $svc_label = '';
3575
3576           #at least until cust_bill_pkg has "past" ranges in addition to
3577           #the "future" sdate/edate ones... see #3032
3578           my @dates = ( $self->_date );
3579           my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
3580           push @dates, $prev->sdate if $prev;
3581           push @dates, undef if !$prev;
3582
3583           my @svc_labels = map &{$escape_function}($_),
3584             $cust_pkg->h_labels_short(@dates,
3585                                       'I',
3586                                       $self->conf->{locale});
3587           $svc_label = $svc_labels[0];
3588
3589           # show service labels, unless...
3590                     # the package is set not to display them
3591           unless ( $part_pkg->hide_svc_detail
3592                     # or this is a tax-like line item
3593                 || $cust_bill_pkg->itemdesc
3594                     # or this is a hidden (bundled) line item
3595                 || $cust_bill_pkg->hidden
3596                     # or this is a usage summary line
3597                 || $is_summary && $type && $type eq 'U'
3598                     # or this is a usage line and there's a recurring line
3599                     # for the package in the same section (which will 
3600                     # have service labels already)
3601                 || ($type eq 'U' and defined($r))
3602               )
3603           {
3604
3605             warn "$me _items_cust_bill_pkg adding service details\n"
3606               if $DEBUG > 1;
3607
3608             push @d, @svc_labels
3609               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
3610             warn "$me _items_cust_bill_pkg done adding service details\n"
3611               if $DEBUG > 1;
3612
3613             # show the location label if it's not the customer's default
3614             # location, and we're not grouping items by location already
3615             if ( $cust_pkg->locationnum != $default_locationnum
3616                   and !defined($locationnum) ) {
3617               my $loc = $cust_pkg->location_label;
3618               $loc = substr($loc, 0, $maxlength). '...'
3619                 if $format eq 'latex' && length($loc) > $maxlength;
3620               push @d, &{$escape_function}($loc);
3621             }
3622
3623             # Display of seconds_since_sqlradacct:
3624             # On the invoice, when processing @detail_items, look for a field
3625             # named 'seconds'.  This will contain total seconds for each 
3626             # service, in the same order as @ext_description.  For services 
3627             # that don't support this it will show undef.
3628             if ( $conf->exists('svc_acct-usage_seconds') 
3629                  and ! $cust_bill_pkg->pkgpart_override ) {
3630               foreach my $cust_svc ( 
3631                   $cust_pkg->h_cust_svc(@dates, 'I') 
3632                 ) {
3633
3634                 # eval because not having any part_export_usage exports 
3635                 # is a fatal error, last_bill/_date because that's how 
3636                 # sqlradius_hour billing does it
3637                 my $sec = eval {
3638                   $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
3639                 };
3640                 push @seconds, $sec;
3641               }
3642             } #if svc_acct-usage_seconds
3643
3644           } # if we are showing service labels
3645
3646           unless ( $is_summary ) {
3647             warn "$me _items_cust_bill_pkg adding details\n"
3648               if $DEBUG > 1;
3649
3650             #instead of omitting details entirely in this case (unwanted side
3651             # effects), just omit CDRs
3652             $details_opt{'no_usage'} = 1
3653               if $type && $type eq 'R';
3654
3655             push @d, $cust_bill_pkg->details(%details_opt);
3656           }
3657
3658           warn "$me _items_cust_bill_pkg calculating amount\n"
3659             if $DEBUG > 1;
3660   
3661           my $amount = 0;
3662           if (!$type) {
3663             $amount = $cust_bill_pkg->recur;
3664           } elsif ($type eq 'R') {
3665             $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
3666           } elsif ($type eq 'U') {
3667             $amount = $cust_bill_pkg->usage;
3668           }
3669   
3670           if ( !$type || $type eq 'R' ) {
3671
3672             warn "$me _items_cust_bill_pkg adding recur\n"
3673               if $DEBUG > 1;
3674
3675             my $unit_amount =
3676               ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
3677                                                 : $amount;
3678
3679             if ( $cust_bill_pkg->hidden ) {
3680               $r->{amount}      += $amount;
3681               $r->{unit_amount} += $unit_amount;
3682               push @{ $r->{ext_description} }, @d;
3683             } else {
3684               $r = {
3685                 description     => $description,
3686                 pkgpart         => $pkgpart,
3687                 pkgnum          => $cust_bill_pkg->pkgnum,
3688                 amount          => $amount,
3689                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
3690                 unit_amount     => $unit_amount,
3691                 quantity        => $cust_bill_pkg->quantity,
3692                 pkg_tax         => \@pkg_tax,
3693                 %item_dates,
3694                 ext_description => \@d,
3695                 svc_label       => ($svc_label || ''),
3696                 locationnum     => $cust_pkg->locationnum,
3697               };
3698               $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
3699             }
3700
3701           } else {  # $type eq 'U'
3702
3703             warn "$me _items_cust_bill_pkg adding usage\n"
3704               if $DEBUG > 1;
3705
3706             if ( $cust_bill_pkg->hidden and defined($u) ) {
3707               # if this is a hidden package and there's already a usage
3708               # line for the bundle, add this package's total amount and
3709               # usage details to it
3710               $u->{amount}      += $amount;
3711               push @{ $u->{ext_description} }, @d;
3712             } elsif ( $amount ) {
3713               # create a new usage line
3714               $u = {
3715                 description     => $description,
3716                 pkgpart         => $pkgpart,
3717                 pkgnum          => $cust_bill_pkg->pkgnum,
3718                 amount          => $amount,
3719                 usage_item      => 1,
3720                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
3721                 pkg_tax         => \@pkg_tax,
3722                 %item_dates,
3723                 ext_description => \@d,
3724                 locationnum     => $cust_pkg->locationnum,
3725               };
3726             } # else this has no usage, so don't create a usage section
3727           }
3728
3729         } # recurring or usage with recurring charge
3730
3731       } else { # taxes and fees
3732
3733         warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
3734           if $DEBUG > 1;
3735
3736         # items of this kind should normally not have sdate/edate.
3737         push @b, {
3738           'description' => $desc,
3739           'amount'      => sprintf('%.2f', $cust_bill_pkg->setup 
3740                                            + $cust_bill_pkg->recur)
3741         };
3742
3743       } # if quotation / package line item / other line item
3744
3745       # decide whether to show active discounts here
3746       if (
3747           # case 1: we are showing a single line for the package
3748           ( !$type )
3749           # case 2: we are showing a setup line for a package that has
3750           # no base recurring fee
3751           or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
3752           # case 3: we are showing a recur line for a package that has 
3753           # a base recurring fee
3754           or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
3755       ) {
3756
3757         my $item_discount = $cust_bill_pkg->_item_discount;
3758         if ( $item_discount ) {
3759           # $item_discount->{amount} is negative
3760
3761           if ( $d and $cust_bill_pkg->hidden ) {
3762             $d->{amount}      += $item_discount->{amount};
3763           } else {
3764             $d = $item_discount;
3765             $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} };
3766           }
3767
3768           # update the active line (before the discount) to show the 
3769           # original price (whether this is a hidden line or not)
3770           #
3771           # quotation discounts keep track of setup and recur; invoice 
3772           # discounts currently don't
3773           if ( exists $item_discount->{setup_amount} ) {
3774
3775             $s->{amount} -= $item_discount->{setup_amount} if $s;
3776             $r->{amount} -= $item_discount->{recur_amount} if $r;
3777
3778           } else {
3779
3780             # $active_line is the line item hashref for the line that will
3781             # show the original price
3782             # (use the recur or single line for the package, unless we're 
3783             # showing a setup line for a package with no recurring fee)
3784             my $active_line = $r;
3785             if ( $type eq 'S' ) {
3786               $active_line = $s;
3787             }
3788             $active_line->{amount} -= $item_discount->{amount};
3789
3790           }
3791
3792         } # if there are any discounts
3793       } # if this is an appropriate place to show discounts
3794
3795     } # foreach $display
3796
3797   }
3798
3799   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
3800     if ( $_  ) {
3801       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
3802         if exists($_->{amount});
3803       $_->{amount}      =~ s/^\-0\.00$/0.00/;
3804       if (exists($_->{unit_amount})) {
3805         $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
3806       }
3807
3808       push @b, { %$_ };
3809       #if $_->{amount} != 0
3810       #  || $discount_show_always
3811       #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
3812       #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
3813     }
3814   }
3815
3816   warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
3817     if $DEBUG > 1;
3818
3819   @b;
3820
3821 }
3822
3823 =item _items_discounts_avail
3824
3825 Returns an array of line item hashrefs representing available term discounts
3826 for this invoice.  This makes the same assumptions that apply to term 
3827 discounts in general: that the package is billed monthly, at a flat rate, 
3828 with no usage charges.  A prorated first month will be handled, as will 
3829 a setup fee if the discount is allowed to apply to setup fees.
3830
3831 =cut
3832
3833 sub _items_discounts_avail {
3834   my $self = shift;
3835
3836   #maybe move this method from cust_bill when quotations support discount_plans 
3837   return () unless $self->can('discount_plans');
3838   my %plans = $self->discount_plans;
3839
3840   my $list_pkgnums = 0; # if any packages are not eligible for all discounts
3841   $list_pkgnums = grep { $_->list_pkgnums } values %plans;
3842
3843   map {
3844     my $months = $_;
3845     my $plan = $plans{$months};
3846
3847     my $term_total = sprintf('%.2f', $plan->discounted_total);
3848     my $percent = sprintf('%.0f', 
3849                           100 * (1 - $term_total / $plan->base_total) );
3850     my $permonth = sprintf('%.2f', $term_total / $months);
3851     my $detail = $self->mt('discount on item'). ' '.
3852                  join(', ', map { "#$_" } $plan->pkgnums)
3853       if $list_pkgnums;
3854
3855     # discounts for non-integer months don't work anyway
3856     $months = sprintf("%d", $months);
3857
3858     +{
3859       description => $self->mt('Save [_1]% by paying for [_2] months',
3860                                 $percent, $months),
3861       amount      => $self->mt('[_1] ([_2] per month)', 
3862                                 $term_total, $money_char.$permonth),
3863       ext_description => ($detail || ''),
3864     }
3865   } #map
3866   sort { $b <=> $a } keys %plans;
3867
3868 }
3869
3870 =item has_sections AGENTNUM
3871
3872 Return true if invoice_sections should be enabled for this bill.
3873  (Inherited by both cust_bill and cust_bill_void)
3874
3875 Determination:
3876 * False if not an invoice
3877 * True always if conf invoice_sections is enabled
3878 * True always if sections_by_location is enabled
3879 * True if conf invoice_sections_multilocation > 1,
3880   and location_count >= invoice_sections_multilocation
3881 * Else, False
3882
3883 =cut
3884
3885 sub has_sections {
3886   my ($self, $agentnum) = @_;
3887
3888   return 0 unless $self->invnum > 0;
3889
3890   $agentnum ||= $self->agentnum;
3891   return 1 if $self->conf->config_bool('invoice_sections', $agentnum);
3892   return 1 if $self->conf->exists('sections_by_location', $agentnum);
3893
3894   my $location_min = $self->conf->config(
3895     'invoice_sections_multilocation', $agentnum,
3896   );
3897
3898   return 1
3899     if $location_min
3900     && $self->location_count >= $location_min;
3901
3902   0;
3903 }
3904
3905
3906 =item location_count
3907
3908 Return the number of locations billed on this invoice
3909
3910 =cut
3911
3912 sub location_count {
3913   my ($self) = @_;
3914   return 0 unless $self->invnum;
3915
3916   # SELECT COUNT( DISTINCT cust_pkg.locationnum )
3917   # FROM cust_bill_pkg
3918   # LEFT JOIN cust_pkg USING (pkgnum)
3919   # WHERE invnum = 278
3920   #   AND cust_bill_pkg.pkgnum > 0
3921
3922   my $result = qsearchs({
3923     select    => 'COUNT(DISTINCT cust_pkg.locationnum) as location_count',
3924     table     => 'cust_bill_pkg',
3925     addl_from => 'LEFT JOIN cust_pkg USING (pkgnum)',
3926     extra_sql => 'WHERE invnum = '.dbh->quote( $self->invnum )
3927                . '  AND cust_bill_pkg.pkgnum > 0'
3928   });
3929   ref $result ? $result->location_count : 0;
3930 }
3931
3932 1;