statement link display by default, better label, statements say "statement" and have...
[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 $date_format $rdate_format $date_format_long );
6              # but NOT $conf
7 use vars qw( $invoice_lines @buf ); #yuck
8 use List::Util qw(sum);
9 use Date::Format;
10 use Date::Language;
11 use Text::Template 1.20;
12 use File::Temp 0.14;
13 use HTML::Entities;
14 use Locale::Country;
15 use Cwd;
16 use FS::UID;
17 use FS::Record qw( qsearch qsearchs );
18 use FS::Misc qw( generate_ps generate_pdf );
19 use FS::pkg_category;
20 use FS::pkg_class;
21 use FS::L10N;
22
23 $DEBUG = 0;
24 $me = '[FS::Template_Mixin]';
25 FS::UID->install_callback( sub { 
26   my $conf = new FS::Conf; #global
27   $money_char       = $conf->config('money_char')       || '$';  
28   $date_format      = $conf->config('date_format')      || '%x'; #/YY
29   $rdate_format     = $conf->config('date_format')      || '%m/%d/%Y';  #/YYYY
30   $date_format_long = $conf->config('date_format_long') || '%b %o, %Y';
31 } );
32
33 =item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
34
35 Returns an text invoice, as a list of lines.
36
37 Options can be passed as a hashref (recommended) or as a list of time, template
38 and then any key/value pairs for any other options.
39
40 I<time>, if specified, is used to control the printing of overdue messages.  The
41 default is now.  It isn't the date of the invoice; that's the `_date' field.
42 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
43 L<Time::Local> and L<Date::Parse> for conversion functions.
44
45 I<template>, if specified, is the name of a suffix for alternate invoices.
46
47 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
48
49 =cut
50
51 sub print_text {
52   my $self = shift;
53   my( $today, $template, %opt );
54   if ( ref($_[0]) ) {
55     %opt = %{ shift() };
56     $today = delete($opt{'time'}) || '';
57     $template = delete($opt{template}) || '';
58   } else {
59     ( $today, $template, %opt ) = @_;
60   }
61
62   my %params = ( 'format' => 'template' );
63   $params{'time'} = $today if $today;
64   $params{'template'} = $template if $template;
65   $params{$_} = $opt{$_} 
66     foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
67
68   $self->print_generic( %params );
69 }
70
71 =item print_latex HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
72
73 Internal method - returns a filename of a filled-in LaTeX template for this
74 invoice (Note: add ".tex" to get the actual filename), and a filename of
75 an associated logo (with the .eps extension included).
76
77 See print_ps and print_pdf for methods that return PostScript and PDF output.
78
79 Options can be passed as a hashref (recommended) or as a list of time, template
80 and then any key/value pairs for any other options.
81
82 I<time>, if specified, is used to control the printing of overdue messages.  The
83 default is now.  It isn't the date of the invoice; that's the `_date' field.
84 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
85 L<Time::Local> and L<Date::Parse> for conversion functions.
86
87 I<template>, if specified, is the name of a suffix for alternate invoices.
88
89 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
90
91 =cut
92
93 sub print_latex {
94   my $self = shift;
95   my $conf = $self->conf;
96   my( $today, $template, %opt );
97   if ( ref($_[0]) ) {
98     %opt = %{ shift() };
99     $today = delete($opt{'time'}) || '';
100     $template = delete($opt{template}) || '';
101   } else {
102     ( $today, $template, %opt ) = @_;
103   }
104
105   my %params = ( 'format' => 'latex' );
106   $params{'time'} = $today if $today;
107   $params{'template'} = $template if $template;
108   $params{$_} = $opt{$_} 
109     foreach grep $opt{$_}, qw( unsquelch_cdr notice_name no_date no_number );
110
111   $template ||= $self->_agent_template
112     if $self->can('_agent_template');
113
114   my $pkey = $self->primary_key;
115   my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
116
117   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
118   my $lh = new File::Temp(
119     TEMPLATE => $tmp_template,
120     DIR      => $dir,
121     SUFFIX   => '.eps',
122     UNLINK   => 0,
123   ) or die "can't open temp file: $!\n";
124
125   my $agentnum = $self->agentnum;
126
127   if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
128     print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
129       or die "can't write temp file: $!\n";
130   } else {
131     print $lh $conf->config_binary('logo.eps', $agentnum)
132       or die "can't write temp file: $!\n";
133   }
134   close $lh;
135   $params{'logo_file'} = $lh->filename;
136
137   if( $conf->exists('invoice-barcode') 
138         && $self->can('invoice_barcode')
139         && $self->invnum ) { # don't try to barcode statements
140       my $png_file = $self->invoice_barcode($dir);
141       my $eps_file = $png_file;
142       $eps_file =~ s/\.png$/.eps/g;
143       $png_file =~ /(barcode.*png)/;
144       $png_file = $1;
145       $eps_file =~ /(barcode.*eps)/;
146       $eps_file = $1;
147
148       my $curr_dir = cwd();
149       chdir($dir); 
150       # after painfuly long experimentation, it was determined that sam2p won't
151       # accept : and other chars in the path, no matter how hard I tried to
152       # escape them, hence the chdir (and chdir back, just to be safe)
153       system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0
154         or die "sam2p failed: $!\n";
155       unlink($png_file);
156       chdir($curr_dir);
157
158       $params{'barcode_file'} = $eps_file;
159   }
160
161   my @filled_in = $self->print_generic( %params );
162   
163   my $fh = new File::Temp( TEMPLATE => $tmp_template,
164                            DIR      => $dir,
165                            SUFFIX   => '.tex',
166                            UNLINK   => 0,
167                          ) or die "can't open temp file: $!\n";
168   binmode($fh, ':utf8'); # language support
169   print $fh join('', @filled_in );
170   close $fh;
171
172   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
173   return ($1, $params{'logo_file'}, $params{'barcode_file'});
174
175 }
176
177 sub agentnum {
178   my $self = shift;
179   my $cust_main = $self->cust_main;
180   $cust_main ? $cust_main->agentnum : $self->prospect_main->agentnum;
181 }
182
183 =item print_generic OPTION => VALUE ...
184
185 Internal method - returns a filled-in template for this invoice as a scalar.
186
187 See print_ps and print_pdf for methods that return PostScript and PDF output.
188
189 Non optional options include 
190   format - latex, html, template
191
192 Optional options include
193
194 template - a value used as a suffix for a configuration template
195
196 time - a value used to control the printing of overdue messages.  The
197 default is now.  It isn't the date of the invoice; that's the `_date' field.
198 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
199 L<Time::Local> and L<Date::Parse> for conversion functions.
200
201 cid - 
202
203 unsquelch_cdr - overrides any per customer cdr squelching when true
204
205 notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
206
207 locale - override customer's locale
208
209 =cut
210
211 #what's with all the sprintf('%10.2f')'s in here?  will it cause any
212 # (alignment in text invoice?) problems to change them all to '%.2f' ?
213 # yes: fixed width/plain text printing will be borked
214 sub print_generic {
215   my( $self, %params ) = @_;
216   my $conf = $self->conf;
217   my $today = $params{today} ? $params{today} : time;
218   warn "$me print_generic called on $self with suffix $params{template}\n"
219     if $DEBUG;
220
221   my $format = $params{format};
222   die "Unknown format: $format"
223     unless $format =~ /^(latex|html|template)$/;
224
225   my $cust_main = $self->cust_main || $self->prospect_main;
226   $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
227     unless $cust_main->payname
228         && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
229
230   my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
231                      'html'     => [ '<%=', '%>' ],
232                      'template' => [ '{', '}' ],
233                    );
234
235   warn "$me print_generic creating template\n"
236     if $DEBUG > 1;
237
238   #create the template
239   my $template = $params{template} ? $params{template} : $self->_agent_template;
240   my $templatefile = $self->template_conf. $format;
241   $templatefile .= "_$template"
242     if length($template) && $conf->exists($templatefile."_$template");
243   my @invoice_template = map "$_\n", $conf->config($templatefile)
244     or die "cannot load config data $templatefile";
245
246   my $old_latex = '';
247   if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
248     #change this to a die when the old code is removed
249     warn "old-style invoice template $templatefile; ".
250          "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
251     $old_latex = 'true';
252     @invoice_template = _translate_old_latex_format(@invoice_template);
253   } 
254
255   warn "$me print_generic creating T:T object\n"
256     if $DEBUG > 1;
257
258   my $text_template = new Text::Template(
259     TYPE => 'ARRAY',
260     SOURCE => \@invoice_template,
261     DELIMITERS => $delimiters{$format},
262   );
263
264   warn "$me print_generic compiling T:T object\n"
265     if $DEBUG > 1;
266
267   $text_template->compile()
268     or die "Can't compile $templatefile: $Text::Template::ERROR\n";
269
270
271   # additional substitution could possibly cause breakage in existing templates
272   my %convert_maps = ( 
273     'latex' => {
274                  'notes'         => sub { map "$_", @_ },
275                  'footer'        => sub { map "$_", @_ },
276                  'smallfooter'   => sub { map "$_", @_ },
277                  'returnaddress' => sub { map "$_", @_ },
278                  'coupon'        => sub { map "$_", @_ },
279                  'summary'       => sub { map "$_", @_ },
280                },
281     'html'  => {
282                  'notes' =>
283                    sub {
284                      map { 
285                        s/%%(.*)$/<!-- $1 -->/g;
286                        s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
287                        s/\\begin\{enumerate\}/<ol>/g;
288                        s/\\item /  <li>/g;
289                        s/\\end\{enumerate\}/<\/ol>/g;
290                        s/\\textbf\{(.*)\}/<b>$1<\/b>/g;
291                        s/\\\\\*/<br>/g;
292                        s/\\dollar ?/\$/g;
293                        s/\\#/#/g;
294                        s/~/&nbsp;/g;
295                        $_;
296                      }  @_
297                    },
298                  'footer' =>
299                    sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
300                  'smallfooter' =>
301                    sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
302                  'returnaddress' =>
303                    sub {
304                      map { 
305                        s/~/&nbsp;/g;
306                        s/\\\\\*?\s*$/<BR>/;
307                        s/\\hyphenation\{[\w\s\-]+}//;
308                        s/\\([&])/$1/g;
309                        $_;
310                      }  @_
311                    },
312                  'coupon'        => sub { "" },
313                  'summary'       => sub { "" },
314                },
315     'template' => {
316                  'notes' =>
317                    sub {
318                      map { 
319                        s/%%.*$//g;
320                        s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
321                        s/\\begin\{enumerate\}//g;
322                        s/\\item /  * /g;
323                        s/\\end\{enumerate\}//g;
324                        s/\\textbf\{(.*)\}/$1/g;
325                        s/\\\\\*/ /;
326                        s/\\dollar ?/\$/g;
327                        $_;
328                      }  @_
329                    },
330                  'footer' =>
331                    sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
332                  'smallfooter' =>
333                    sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
334                  'returnaddress' =>
335                    sub {
336                      map { 
337                        s/~/ /g;
338                        s/\\\\\*?\s*$/\n/;             # dubious
339                        s/\\hyphenation\{[\w\s\-]+}//;
340                        $_;
341                      }  @_
342                    },
343                  'coupon'        => sub { "" },
344                  'summary'       => sub { "" },
345                },
346   );
347
348
349   # hashes for differing output formats
350   my %nbsps = ( 'latex'    => '~',
351                 'html'     => '',    # '&nbps;' would be nice
352                 'template' => '',    # not used
353               );
354   my $nbsp = $nbsps{$format};
355
356   my %escape_functions = ( 'latex'    => \&_latex_escape,
357                            'html'     => \&_html_escape_nbsp,#\&encode_entities,
358                            'template' => sub { shift },
359                          );
360   my $escape_function = $escape_functions{$format};
361   my $escape_function_nonbsp = ($format eq 'html')
362                                  ? \&_html_escape : $escape_function;
363
364   my %date_formats = ( 'latex'    => $date_format_long,
365                        'html'     => $date_format_long,
366                        'template' => '%s',
367                      );
368   $date_formats{'html'} =~ s/ /&nbsp;/g;
369
370   my $date_format = $date_formats{$format};
371
372   my %newline_tokens = (  'latex'     => '\\\\',
373                           'html'      => '<br>',
374                           'template'  => "\n",
375                         );
376   my $newline_token = $newline_tokens{$format};
377
378   warn "$me generating template variables\n"
379     if $DEBUG > 1;
380
381   # generate template variables
382   my $returnaddress;
383   if (
384          defined( $conf->config_orbase( "invoice_${format}returnaddress",
385                                         $template
386                                       )
387                 )
388        && length( $conf->config_orbase( "invoice_${format}returnaddress",
389                                         $template
390                                       )
391                 )
392   ) {
393
394     $returnaddress = join("\n",
395       $conf->config_orbase("invoice_${format}returnaddress", $template)
396     );
397
398   } elsif ( grep /\S/,
399             $conf->config_orbase('invoice_latexreturnaddress', $template) ) {
400
401     my $convert_map = $convert_maps{$format}{'returnaddress'};
402     $returnaddress =
403       join( "\n",
404             &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress",
405                                                  $template
406                                                )
407                          )
408           );
409   } elsif ( grep /\S/, $conf->config('company_address', $cust_main->agentnum) ) {
410
411     my $convert_map = $convert_maps{$format}{'returnaddress'};
412     $returnaddress = join( "\n", &$convert_map(
413                                    map { s/( {2,})/'~' x length($1)/eg;
414                                          s/$/\\\\\*/;
415                                          $_
416                                        }
417                                      ( $conf->config('company_name', $cust_main->agentnum),
418                                        $conf->config('company_address', $cust_main->agentnum),
419                                      )
420                                  )
421                      );
422
423   } else {
424
425     my $warning = "Couldn't find a return address; ".
426                   "do you need to set the company_address configuration value?";
427     warn "$warning\n";
428     $returnaddress = $nbsp;
429     #$returnaddress = $warning;
430
431   }
432
433   warn "$me generating invoice data\n"
434     if $DEBUG > 1;
435
436   my $agentnum = $cust_main->agentnum;
437
438   my %invoice_data = (
439
440     #invoice from info
441     'company_name'    => scalar( $conf->config('company_name', $agentnum) ),
442     'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
443     'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
444     'returnaddress'   => $returnaddress,
445     'agent'           => &$escape_function($cust_main->agent->agent),
446
447     #invoice/quotation info
448     'no_number'       => $params{'no_number'},
449     'invnum'          => ( $params{'no_number'} ? '' : $self->invnum ),
450     'quotationnum'    => $self->quotationnum,
451     'no_date'         => $params{'no_date'},
452     'date'            => ( $params{'no_date'}
453                              ? ''
454                              : time2str($date_format, $self->_date)
455                          ),
456     'today'           => time2str($date_format_long, $today),
457     'terms'           => $self->terms,
458     'template'        => $template, #params{'template'},
459     'notice_name'     => ($params{'notice_name'} || $self->notice_name),#escape_function?
460     'current_charges' => sprintf("%.2f", $self->charged),
461     'duedate'         => $self->due_date2str($rdate_format), #date_format?
462
463     #customer info
464     'custnum'         => $cust_main->display_custnum,
465     'prospectnum'     => $cust_main->prospectnum,
466     'agent_custid'    => &$escape_function($cust_main->agent_custid),
467     ( map { $_ => &$escape_function($cust_main->$_()) } qw(
468       payname company address1 address2 city state zip fax
469     )),
470
471     #global config
472     'ship_enable'     => $conf->exists('invoice-ship_address'),
473     'unitprices'      => $conf->exists('invoice-unitprice'),
474     'smallernotes'    => $conf->exists('invoice-smallernotes'),
475     'smallerfooter'   => $conf->exists('invoice-smallerfooter'),
476     'balance_due_below_line' => $conf->exists('balance_due_below_line'),
477    
478     #layout info -- would be fancy to calc some of this and bury the template
479     #               here in the code
480     'topmargin'             => scalar($conf->config('invoice_latextopmargin', $agentnum)),
481     'headsep'               => scalar($conf->config('invoice_latexheadsep', $agentnum)),
482     'textheight'            => scalar($conf->config('invoice_latextextheight', $agentnum)),
483     'extracouponspace'      => scalar($conf->config('invoice_latexextracouponspace', $agentnum)),
484     'couponfootsep'         => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)),
485     'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum),
486     'addresssep'            => scalar($conf->config('invoice_latexaddresssep', $agentnum)),
487     'amountenclosedsep'     => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
488     'coupontoaddresssep'    => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
489     'addcompanytoaddress'   => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
490
491     # better hang on to conf_dir for a while (for old templates)
492     'conf_dir'        => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
493
494     #these are only used when doing paged plaintext
495     'page'            => 1,
496     'total_pages'     => 1,
497
498   );
499  
500   #localization
501   my $lh = FS::L10N->get_handle( $params{'locale'} || $cust_main->locale );
502   $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
503   my %info = FS::Locales->locale_info($cust_main->locale || 'en_US');
504   # eval to avoid death for unimplemented languages
505   my $dh = eval { Date::Language->new($info{'name'}) } ||
506            Date::Language->new(); # fall back to English
507   # prototype here to silence warnings
508   $invoice_data{'time2str'} = sub ($;$$) { $dh->time2str(@_) };
509   # eventually use this date handle everywhere in here, too
510
511   my $min_sdate = 999999999999;
512   my $max_edate = 0;
513   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
514     next unless $cust_bill_pkg->pkgnum > 0;
515     $min_sdate = $cust_bill_pkg->sdate
516       if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate;
517     $max_edate = $cust_bill_pkg->edate
518       if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate;
519   }
520
521   $invoice_data{'bill_period'} = '';
522   $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate) 
523     . " to " . time2str('%e %h', $max_edate)
524     if ($max_edate != 0 && $min_sdate != 999999999999);
525
526   $invoice_data{finance_section} = '';
527   if ( $conf->config('finance_pkgclass') ) {
528     my $pkg_class =
529       qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
530     $invoice_data{finance_section} = $pkg_class->categoryname;
531   } 
532   $invoice_data{finance_amount} = '0.00';
533   $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
534
535   my $countrydefault = $conf->config('countrydefault') || 'US';
536   foreach ( qw( address1 address2 city state zip country fax) ){
537     my $method = 'ship_'.$_;
538     $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
539   }
540   foreach ( qw( contact company ) ) { #compatibility
541     $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
542   }
543   $invoice_data{'ship_country'} = ''
544     if ( $invoice_data{'ship_country'} eq $countrydefault );
545   
546   $invoice_data{'cid'} = $params{'cid'}
547     if $params{'cid'};
548
549   if ( $cust_main->country eq $countrydefault ) {
550     $invoice_data{'country'} = '';
551   } else {
552     $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
553   }
554
555   my @address = ();
556   $invoice_data{'address'} = \@address;
557   push @address,
558     $cust_main->payname.
559       ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
560         ? " (P.O. #". $cust_main->payinfo. ")"
561         : ''
562       )
563   ;
564   push @address, $cust_main->company
565     if $cust_main->company;
566   push @address, $cust_main->address1;
567   push @address, $cust_main->address2
568     if $cust_main->address2;
569   push @address,
570     $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
571   push @address, $invoice_data{'country'}
572     if $invoice_data{'country'};
573   push @address, ''
574     while (scalar(@address) < 5);
575
576   $invoice_data{'logo_file'} = $params{'logo_file'}
577     if $params{'logo_file'};
578   $invoice_data{'barcode_file'} = $params{'barcode_file'}
579     if $params{'barcode_file'};
580   $invoice_data{'barcode_img'} = $params{'barcode_img'}
581     if $params{'barcode_img'};
582   $invoice_data{'barcode_cid'} = $params{'barcode_cid'}
583     if $params{'barcode_cid'};
584
585   my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
586 #  my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
587   #my $balance_due = $self->owed + $pr_total - $cr_total;
588   my $balance_due = $self->owed + $pr_total;
589
590   #these are used on the summary page only
591
592     # the customer's current balance as shown on the invoice before this one
593     $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
594
595     # the change in balance from that invoice to this one
596     $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
597
598     # the sum of amount owed on all previous invoices
599     # ($pr_total is used elsewhere but not as $previous_balance)
600     $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
601
602   # the sum of amount owed on all invoices
603   # (this is used in the summary & on the payment coupon)
604   $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
605
606   # info from customer's last invoice before this one, for some 
607   # summary formats
608   $invoice_data{'last_bill'} = {};
609
610   # returns the last unpaid bill, not the last bill
611   #my $last_bill = $pr_cust_bill[-1];
612
613   if ( $self->custnum && $self->invnum ) {
614
615     # THIS returns the customer's last bill before  this one
616     my $last_bill = qsearchs({
617         'table'   => 'cust_bill',
618         'hashref' => { 'custnum' => $self->custnum,
619                        'invnum'  => { op => '<', value => $self->invnum },
620                      },
621         'order_by'  => ' ORDER BY invnum DESC LIMIT 1'
622     });
623     if ( $last_bill ) {
624       $invoice_data{'last_bill'} = {
625         '_date'     => $last_bill->_date, #unformatted
626         # all we need for now
627       };
628       my (@payments, @credits);
629       # for formats that itemize previous payments
630       foreach my $cust_pay ( qsearch('cust_pay', {
631                               'custnum' => $self->custnum,
632                               '_date'   => { op => '>=',
633                                              value => $last_bill->_date }
634                              } ) )
635       {
636         next if $cust_pay->_date > $self->_date;
637         push @payments, {
638             '_date'       => $cust_pay->_date,
639             'date'        => time2str($date_format, $cust_pay->_date),
640             'payinfo'     => $cust_pay->payby_payinfo_pretty,
641             'amount'      => sprintf('%.2f', $cust_pay->paid),
642         };
643         # not concerned about applications
644       }
645       foreach my $cust_credit ( qsearch('cust_credit', {
646                               'custnum' => $self->custnum,
647                               '_date'   => { op => '>=',
648                                              value => $last_bill->_date }
649                              } ) )
650       {
651         next if $cust_credit->_date > $self->_date;
652         push @credits, {
653             '_date'       => $cust_credit->_date,
654             'date'        => time2str($date_format, $cust_credit->_date),
655             'creditreason'=> $cust_credit->reason,
656             'amount'      => sprintf('%.2f', $cust_credit->amount),
657         };
658       }
659       $invoice_data{'previous_payments'} = \@payments;
660       $invoice_data{'previous_credits'}  = \@credits;
661     }
662
663   }
664
665   my $summarypage = '';
666   if ( $conf->exists('invoice_usesummary', $agentnum) ) {
667     $summarypage = 1;
668   }
669   $invoice_data{'summarypage'} = $summarypage;
670
671   warn "$me substituting variables in notes, footer, smallfooter\n"
672     if $DEBUG > 1;
673
674   my $tc = $self->template_conf;
675   my @include = ( [ $tc,        'notes' ],
676                   [ 'invoice_', 'footer' ],
677                   [ 'invoice_', 'smallfooter', ],
678                 );
679   push @include, [ $tc,        'coupon', ]
680     unless $params{'no_coupon'};
681
682   foreach my $i (@include) {
683
684     my($base, $include) = @$i;
685
686     my $inc_file = $conf->key_orbase("$base$format$include", $template);
687     my @inc_src;
688
689     if ( $conf->exists($inc_file, $agentnum)
690          && length( $conf->config($inc_file, $agentnum) ) ) {
691
692       @inc_src = $conf->config($inc_file, $agentnum);
693
694     } else {
695
696       $inc_file = $conf->key_orbase("${base}latex$include", $template);
697
698       my $convert_map = $convert_maps{$format}{$include};
699
700       @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
701                        s/--\@\]/$delimiters{$format}[1]/g;
702                        $_;
703                      } 
704                  &$convert_map( $conf->config($inc_file, $agentnum) );
705
706     }
707
708     my $inc_tt = new Text::Template (
709       TYPE       => 'ARRAY',
710       SOURCE     => [ map "$_\n", @inc_src ],
711       DELIMITERS => $delimiters{$format},
712     ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
713
714     unless ( $inc_tt->compile() ) {
715       my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
716       warn $error. "Template:\n". join('', map "$_\n", @inc_src);
717       die $error;
718     }
719
720     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
721
722     $invoice_data{$include} =~ s/\n+$//
723       if ($format eq 'latex');
724   }
725
726   # let invoices use either of these as needed
727   $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') 
728     ? $cust_main->payinfo : '';
729   $invoice_data{'po_line'} = 
730     (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
731       ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
732       : $nbsp;
733
734   my %money_chars = ( 'latex'    => '',
735                       'html'     => $conf->config('money_char') || '$',
736                       'template' => '',
737                     );
738   my $money_char = $money_chars{$format};
739
740   my %other_money_chars = ( 'latex'    => '\dollar ',#XXX should be a config too
741                             'html'     => $conf->config('money_char') || '$',
742                             'template' => '',
743                           );
744   my $other_money_char = $other_money_chars{$format};
745   $invoice_data{'dollar'} = $other_money_char;
746
747   my %minus_signs = ( 'latex'    => '$-$',
748                       'html'     => '&minus;',
749                       'template' => '- ' );
750   my $minus = $minus_signs{$format};
751
752   my @detail_items = ();
753   my @total_items = ();
754   my @buf = ();
755   my @sections = ();
756
757   $invoice_data{'detail_items'} = \@detail_items;
758   $invoice_data{'total_items'} = \@total_items;
759   $invoice_data{'buf'} = \@buf;
760   $invoice_data{'sections'} = \@sections;
761
762   warn "$me generating sections\n"
763     if $DEBUG > 1;
764
765   # Previous Charges section
766   # subtotal is the first return value from $self->previous
767   my $previous_section = { 'description' => $self->mt('Previous Charges'),
768                            'subtotal'    => $other_money_char.
769                                             sprintf('%.2f', $pr_total),
770                            'summarized'  => '', #why? $summarypage ? 'Y' : '',
771                          };
772   $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. 
773     join(' / ', map { $cust_main->balance_date_range(@$_) }
774                 $self->_prior_month30s
775         )
776     if $conf->exists('invoice_include_aging');
777
778   my $taxtotal = 0;
779   my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
780                       'subtotal'    => $taxtotal,   # adjusted below
781                     };
782   my $tax_weight = _pkg_category($tax_section->{description})
783                         ? _pkg_category($tax_section->{description})->weight
784                         : 0;
785   $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
786   $tax_section->{'sort_weight'} = $tax_weight;
787
788
789   my $adjusttotal = 0;
790   my $adjust_section = {
791     'description'    => $self->mt('Credits, Payments, and Adjustments'),
792     'adjust_section' => 1,
793     'subtotal'       => 0,   # adjusted below
794   };
795   my $adjust_weight = _pkg_category($adjust_section->{description})
796                         ? _pkg_category($adjust_section->{description})->weight
797                         : 0;
798   $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
799   $adjust_section->{'sort_weight'} = $adjust_weight;
800
801   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
802   my $multisection = $conf->exists($tc.'_sections', $cust_main->agentnum);
803   $invoice_data{'multisection'} = $multisection;
804   my $late_sections = [];
805   my $extra_sections = [];
806   my $extra_lines = ();
807
808   my $default_section = { 'description' => '',
809                           'subtotal'    => '', 
810                           'no_subtotal' => 1,
811                         };
812
813   if ( $multisection ) {
814     ($extra_sections, $extra_lines) =
815       $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
816       if $conf->exists('usage_class_as_a_section', $cust_main->agentnum)
817       && $self->can('_items_extra_usage_sections');
818
819     push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
820
821     push @detail_items, @$extra_lines if $extra_lines;
822     push @sections,
823       $self->_items_sections( $late_sections,      # this could stand a refactor
824                               $summarypage,
825                               $escape_function_nonbsp,
826                               $extra_sections,
827                               $format,             #bah
828                             );
829     if (    $conf->exists('svc_phone_sections')
830          && $self->can('_items_svc_phone_sections')
831        )
832     {
833       my ($phone_sections, $phone_lines) =
834         $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
835       push @{$late_sections}, @$phone_sections;
836       push @detail_items, @$phone_lines;
837     }
838     if ( $conf->exists('voip-cust_accountcode_cdr')
839          && $cust_main->accountcode_cdr
840          && $self->can('_items_accountcode_cdr')
841        )
842     {
843       my ($accountcode_section, $accountcode_lines) =
844         $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
845       if ( scalar(@$accountcode_lines) ) {
846           push @{$late_sections}, $accountcode_section;
847           push @detail_items, @$accountcode_lines;
848       }
849     }
850   } else {# not multisection
851     # make a default section
852     push @sections, $default_section;
853     # and calculate the finance charge total, since it won't get done otherwise.
854     # XXX possibly other totals?
855     # XXX possibly finance_pkgclass should not be used in this manner?
856     if ( $conf->exists('finance_pkgclass') ) {
857       my @finance_charges;
858       foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
859         if ( grep { $_->section eq $invoice_data{finance_section} }
860              $cust_bill_pkg->cust_bill_pkg_display ) {
861           # I think these are always setup fees, but just to be sure...
862           push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
863         }
864       }
865       $invoice_data{finance_amount} = 
866         sprintf('%.2f', sum( @finance_charges ) || 0);
867     }
868   }
869
870   # previous invoice balances in the Previous Charges section if there
871   # is one, otherwise in the main detail section
872   if ( $self->can('_items_previous') &&
873        $self->enable_previous &&
874        ! $conf->exists('previous_balance-summary_only') ) {
875
876     warn "$me adding previous balances\n"
877       if $DEBUG > 1;
878
879     foreach my $line_item ( $self->_items_previous ) {
880
881       my $detail = {
882         ext_description => [],
883       };
884       $detail->{'ref'} = $line_item->{'pkgnum'};
885       $detail->{'pkgpart'} = $line_item->{'pkgpart'};
886       $detail->{'quantity'} = 1;
887       $detail->{'section'} = $multisection ? $previous_section
888                                            : $default_section;
889       $detail->{'description'} = &$escape_function($line_item->{'description'});
890       if ( exists $line_item->{'ext_description'} ) {
891         @{$detail->{'ext_description'}} = map {
892           &$escape_function($_);
893         } @{$line_item->{'ext_description'}};
894       }
895       $detail->{'amount'} = ( $old_latex ? '' : $money_char).
896                             $line_item->{'amount'};
897       $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
898
899       push @detail_items, $detail;
900       push @buf, [ $detail->{'description'},
901                    $money_char. sprintf("%10.2f", $line_item->{'amount'}),
902                  ];
903     }
904
905   }
906
907   if ( @pr_cust_bill && $self->enable_previous ) {
908     push @buf, ['','-----------'];
909     push @buf, [ $self->mt('Total Previous Balance'),
910                  $money_char. sprintf("%10.2f", $pr_total) ];
911     push @buf, ['',''];
912   }
913  
914   if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) {
915       warn "$me adding DID summary\n"
916         if $DEBUG > 1;
917
918       my ($didsummary,$minutes) = $self->_did_summary;
919       my $didsummary_desc = 'DID Activity Summary (since last invoice)';
920       push @detail_items, 
921        { 'description' => $didsummary_desc,
922            'ext_description' => [ $didsummary, $minutes ],
923        };
924   }
925
926   foreach my $section (@sections, @$late_sections) {
927
928     warn "$me adding section \n". Dumper($section)
929       if $DEBUG > 1;
930
931     # begin some normalization
932     $section->{'subtotal'} = $section->{'amount'}
933       if $multisection
934          && !exists($section->{subtotal})
935          && exists($section->{amount});
936
937     $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
938       if ( $invoice_data{finance_section} &&
939            $section->{'description'} eq $invoice_data{finance_section} );
940
941     $section->{'subtotal'} = $other_money_char.
942                              sprintf('%.2f', $section->{'subtotal'})
943       if $multisection;
944
945     # continue some normalization
946     $section->{'amount'}   = $section->{'subtotal'}
947       if $multisection;
948
949
950     if ( $section->{'description'} ) {
951       push @buf, ( [ &$escape_function($section->{'description'}), '' ],
952                    [ '', '' ],
953                  );
954     }
955
956     warn "$me   setting options\n"
957       if $DEBUG > 1;
958
959     my %options = ();
960     $options{'section'} = $section if $multisection;
961     $options{'format'} = $format;
962     $options{'escape_function'} = $escape_function;
963     $options{'no_usage'} = 1 unless $unsquelched;
964     $options{'unsquelched'} = $unsquelched;
965     $options{'summary_page'} = $summarypage;
966     $options{'skip_usage'} =
967       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
968     $options{'multisection'} = $multisection;
969
970     warn "$me   searching for line items\n"
971       if $DEBUG > 1;
972
973     foreach my $line_item ( $self->_items_pkg(%options) ) {
974
975       warn "$me     adding line item $line_item\n"
976         if $DEBUG > 1;
977
978       my $detail = {
979         ext_description => [],
980       };
981       $detail->{'ref'} = $line_item->{'pkgnum'};
982       $detail->{'pkgpart'} = $line_item->{'pkgpart'};
983       $detail->{'quantity'} = $line_item->{'quantity'};
984       $detail->{'section'} = $section;
985       $detail->{'description'} = &$escape_function($line_item->{'description'});
986       if ( exists $line_item->{'ext_description'} ) {
987         @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
988       }
989       $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
990                               $line_item->{'amount'};
991       if ( exists $line_item->{'unit_amount'} ) {
992         $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
993                                    $line_item->{'unit_amount'};
994       }
995       $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
996
997       $detail->{'sdate'} = $line_item->{'sdate'};
998       $detail->{'edate'} = $line_item->{'edate'};
999       $detail->{'seconds'} = $line_item->{'seconds'};
1000       $detail->{'svc_label'} = $line_item->{'svc_label'};
1001   
1002       push @detail_items, $detail;
1003       push @buf, ( [ $detail->{'description'},
1004                      $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1005                    ],
1006                    map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
1007                  );
1008     }
1009
1010     if ( $section->{'description'} ) {
1011       push @buf, ( ['','-----------'],
1012                    [ $section->{'description'}. ' sub-total',
1013                       $section->{'subtotal'} # already formatted this 
1014                    ],
1015                    [ '', '' ],
1016                    [ '', '' ],
1017                  );
1018     }
1019   
1020   }
1021
1022   $invoice_data{current_less_finance} =
1023     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
1024
1025   # create a major section for previous balance if we have major sections,
1026   # or if previous_section is in summary form
1027   if ( ( $multisection && $self->enable_previous )
1028     || $conf->exists('previous_balance-summary_only') )
1029   {
1030     unshift @sections, $previous_section if $pr_total;
1031   }
1032
1033   warn "$me adding taxes\n"
1034     if $DEBUG > 1;
1035
1036   my @items_tax = $self->_items_tax;
1037   foreach my $tax ( @items_tax ) {
1038
1039     $taxtotal += $tax->{'amount'};
1040
1041     my $description = &$escape_function( $tax->{'description'} );
1042     my $amount      = sprintf( '%.2f', $tax->{'amount'} );
1043
1044     if ( $multisection ) {
1045
1046       my $money = $old_latex ? '' : $money_char;
1047       push @detail_items, {
1048         ext_description => [],
1049         ref          => '',
1050         quantity     => '',
1051         description  => $description,
1052         amount       => $money. $amount,
1053         product_code => '',
1054         section      => $tax_section,
1055       };
1056
1057     } else {
1058
1059       push @total_items, {
1060         'total_item'   => $description,
1061         'total_amount' => $other_money_char. $amount,
1062       };
1063
1064     }
1065
1066     push @buf,[ $description,
1067                 $money_char. $amount,
1068               ];
1069
1070   }
1071   
1072   if ( @items_tax ) {
1073     my $total = {};
1074     $total->{'total_item'} = $self->mt('Sub-total');
1075     $total->{'total_amount'} =
1076       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
1077
1078     if ( $multisection ) {
1079       $tax_section->{'subtotal'} = $other_money_char.
1080                                    sprintf('%.2f', $taxtotal);
1081       $tax_section->{'pretotal'} = 'New charges sub-total '.
1082                                    $total->{'total_amount'};
1083       push @sections, $tax_section if $taxtotal;
1084     }else{
1085       unshift @total_items, $total;
1086     }
1087   }
1088   $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
1089
1090   push @buf,['','-----------'];
1091   push @buf,[$self->mt( 
1092               (!$self->enable_previous)
1093                ? 'Total Charges'
1094                : 'Total New Charges'
1095              ),
1096              $money_char. sprintf("%10.2f",$self->charged) ];
1097   push @buf,['',''];
1098
1099
1100   ###
1101   # Totals
1102   ###
1103
1104   my %embolden_functions = (
1105     'latex'    => sub { return '\textbf{'. shift(). '}' },
1106     'html'     => sub { return '<b>'. shift(). '</b>' },
1107     'template' => sub { shift },
1108   );
1109   my $embolden_function = $embolden_functions{$format};
1110
1111   if ( $self->can('_items_total') ) { # quotations
1112
1113     $self->_items_total(\@total_items);
1114
1115     foreach ( @total_items ) {
1116       $_->{'total_item'}   = &$embolden_function( $_->{'total_item'} );
1117       $_->{'total_amount'} = &$embolden_function( $other_money_char.
1118                                                    $_->{'total_amount'}
1119                                                 );
1120     }
1121
1122   } else { #normal invoice case
1123
1124     # calculate total, possibly including total owed on previous
1125     # invoices
1126     my $total = {};
1127     my $item = 'Total';
1128     $item = $conf->config('previous_balance-exclude_from_total')
1129          || 'Total New Charges'
1130       if $conf->exists('previous_balance-exclude_from_total');
1131     my $amount = $self->charged;
1132     if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) {
1133       $amount += $pr_total;
1134     }
1135
1136     $total->{'total_item'} = &$embolden_function($self->mt($item));
1137     $total->{'total_amount'} =
1138       &$embolden_function( $other_money_char.  sprintf( '%.2f', $amount ) );
1139     if ( $multisection ) {
1140       if ( $adjust_section->{'sort_weight'} ) {
1141         $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
1142           $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
1143       } else {
1144         $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
1145           $other_money_char.  sprintf('%.2f', $self->charged );
1146       } 
1147     }else{
1148       push @total_items, $total;
1149     }
1150     push @buf,['','-----------'];
1151     push @buf,[$item,
1152                $money_char.
1153                sprintf( '%10.2f', $amount )
1154               ];
1155     push @buf,['',''];
1156
1157     # if we're showing previous invoices, also show previous
1158     # credits and payments 
1159     if ( $self->enable_previous 
1160           and $self->can('_items_credits')
1161           and $self->can('_items_payments') )
1162       {
1163       #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
1164     
1165       # credits
1166       my $credittotal = 0;
1167       foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
1168
1169         my $total;
1170         $total->{'total_item'} = &$escape_function($credit->{'description'});
1171         $credittotal += $credit->{'amount'};
1172         $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
1173         $adjusttotal += $credit->{'amount'};
1174         if ( $multisection ) {
1175           my $money = $old_latex ? '' : $money_char;
1176           push @detail_items, {
1177             ext_description => [],
1178             ref          => '',
1179             quantity     => '',
1180             description  => &$escape_function($credit->{'description'}),
1181             amount       => $money. $credit->{'amount'},
1182             product_code => '',
1183             section      => $adjust_section,
1184           };
1185         } else {
1186           push @total_items, $total;
1187         }
1188
1189       }
1190       $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
1191
1192       #credits (again)
1193       foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
1194         push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
1195       }
1196
1197       # payments
1198       my $paymenttotal = 0;
1199       foreach my $payment ( $self->_items_payments ) {
1200         my $total = {};
1201         $total->{'total_item'} = &$escape_function($payment->{'description'});
1202         $paymenttotal += $payment->{'amount'};
1203         $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
1204         $adjusttotal += $payment->{'amount'};
1205         if ( $multisection ) {
1206           my $money = $old_latex ? '' : $money_char;
1207           push @detail_items, {
1208             ext_description => [],
1209             ref          => '',
1210             quantity     => '',
1211             description  => &$escape_function($payment->{'description'}),
1212             amount       => $money. $payment->{'amount'},
1213             product_code => '',
1214             section      => $adjust_section,
1215           };
1216         }else{
1217           push @total_items, $total;
1218         }
1219         push @buf, [ $payment->{'description'},
1220                      $money_char. sprintf("%10.2f", $payment->{'amount'}),
1221                    ];
1222       }
1223       $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
1224     
1225       if ( $multisection ) {
1226         $adjust_section->{'subtotal'} = $other_money_char.
1227                                         sprintf('%.2f', $adjusttotal);
1228         push @sections, $adjust_section
1229           unless $adjust_section->{sort_weight};
1230       }
1231
1232       # create Balance Due message
1233       { 
1234         my $total;
1235         $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1236         $total->{'total_amount'} =
1237           &$embolden_function(
1238             $other_money_char. sprintf('%.2f', #why? $summarypage 
1239                                                #  ? $self->charged +
1240                                                #    $self->billing_balance
1241                                                #  :
1242                                                    $self->owed + $pr_total
1243                                       )
1244           );
1245         if ( $multisection && !$adjust_section->{sort_weight} ) {
1246           $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
1247                                            $total->{'total_amount'};
1248         }else{
1249           push @total_items, $total;
1250         }
1251         push @buf,['','-----------'];
1252         push @buf,[$self->balance_due_msg, $money_char. 
1253           sprintf("%10.2f", $balance_due ) ];
1254       }
1255
1256       if ( $conf->exists('previous_balance-show_credit')
1257           and $cust_main->balance < 0 ) {
1258         my $credit_total = {
1259           'total_item'    => &$embolden_function($self->credit_balance_msg),
1260           'total_amount'  => &$embolden_function(
1261             $other_money_char. sprintf('%.2f', -$cust_main->balance)
1262           ),
1263         };
1264         if ( $multisection ) {
1265           $adjust_section->{'posttotal'} .= $newline_token .
1266             $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
1267         }
1268         else {
1269           push @total_items, $credit_total;
1270         }
1271         push @buf,['','-----------'];
1272         push @buf,[$self->credit_balance_msg, $money_char. 
1273           sprintf("%10.2f", -$cust_main->balance ) ];
1274       }
1275     }
1276
1277   } #end of default total adding ! can('_items_total')
1278
1279   if ( $multisection ) {
1280     if (    $conf->exists('svc_phone_sections')
1281          && $self->can('_items_svc_phone_sections')
1282        )
1283     {
1284       my $total;
1285       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1286       $total->{'total_amount'} =
1287         &$embolden_function(
1288           $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
1289         );
1290       my $last_section = pop @sections;
1291       $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
1292                                      $total->{'total_amount'};
1293       push @sections, $last_section;
1294     }
1295     push @sections, @$late_sections
1296       if $unsquelched;
1297   }
1298
1299   # make a discounts-available section, even without multisection
1300   if ( $conf->exists('discount-show_available') 
1301        and my @discounts_avail = $self->_items_discounts_avail ) {
1302     my $discount_section = {
1303       'description' => $self->mt('Discounts Available'),
1304       'subtotal'    => '',
1305       'no_subtotal' => 1,
1306     };
1307
1308     push @sections, $discount_section;
1309     push @detail_items, map { +{
1310         'ref'         => '', #should this be something else?
1311         'section'     => $discount_section,
1312         'description' => &$escape_function( $_->{description} ),
1313         'amount'      => $money_char . &$escape_function( $_->{amount} ),
1314         'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
1315     } } @discounts_avail;
1316   }
1317
1318   # debugging hook: call this with 'diag' => 1 to just get a hash of 
1319   # the invoice variables
1320   return \%invoice_data if ( $params{'diag'} );
1321
1322   # All sections and items are built; now fill in templates.
1323   my @includelist = ();
1324   push @includelist, 'summary' if $summarypage;
1325   foreach my $include ( @includelist ) {
1326
1327     my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
1328     my @inc_src;
1329
1330     if ( length( $conf->config($inc_file, $agentnum) ) ) {
1331
1332       @inc_src = $conf->config($inc_file, $agentnum);
1333
1334     } else {
1335
1336       $inc_file = $conf->key_orbase("invoice_latex$include", $template);
1337
1338       my $convert_map = $convert_maps{$format}{$include};
1339
1340       @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
1341                        s/--\@\]/$delimiters{$format}[1]/g;
1342                        $_;
1343                      } 
1344                  &$convert_map( $conf->config($inc_file, $agentnum) );
1345
1346     }
1347
1348     my $inc_tt = new Text::Template (
1349       TYPE       => 'ARRAY',
1350       SOURCE     => [ map "$_\n", @inc_src ],
1351       DELIMITERS => $delimiters{$format},
1352     ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
1353
1354     unless ( $inc_tt->compile() ) {
1355       my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
1356       warn $error. "Template:\n". join('', map "$_\n", @inc_src);
1357       die $error;
1358     }
1359
1360     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
1361
1362     $invoice_data{$include} =~ s/\n+$//
1363       if ($format eq 'latex');
1364   }
1365
1366   $invoice_lines = 0;
1367   my $wasfunc = 0;
1368   foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
1369     /invoice_lines\((\d*)\)/;
1370     $invoice_lines += $1 || scalar(@buf);
1371     $wasfunc=1;
1372   }
1373   die "no invoice_lines() functions in template?"
1374     if ( $format eq 'template' && !$wasfunc );
1375
1376   if ($format eq 'template') {
1377
1378     if ( $invoice_lines ) {
1379       $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
1380       $invoice_data{'total_pages'}++
1381         if scalar(@buf) % $invoice_lines;
1382     }
1383
1384     #setup subroutine for the template
1385     $invoice_data{invoice_lines} = sub {
1386       my $lines = shift || scalar(@buf);
1387       map { 
1388         scalar(@buf)
1389           ? shift @buf
1390           : [ '', '' ];
1391       }
1392       ( 1 .. $lines );
1393     };
1394
1395     my $lines;
1396     my @collect;
1397     while (@buf) {
1398       push @collect, split("\n",
1399         $text_template->fill_in( HASH => \%invoice_data )
1400       );
1401       $invoice_data{'page'}++;
1402     }
1403     map "$_\n", @collect;
1404
1405   } else { # this is where we actually create the invoice
1406
1407     warn "filling in template for invoice ". $self->invnum. "\n"
1408       if $DEBUG;
1409     warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
1410       if $DEBUG > 1;
1411
1412     $text_template->fill_in(HASH => \%invoice_data);
1413   }
1414 }
1415
1416 sub notice_name { '('.shift->table.')'; }
1417
1418 sub template_conf { 'invoice_'; }
1419
1420 # helper routine for generating date ranges
1421 sub _prior_month30s {
1422   my $self = shift;
1423   my @ranges = (
1424    [ 1,       2592000 ], # 0-30 days ago
1425    [ 2592000, 5184000 ], # 30-60 days ago
1426    [ 5184000, 7776000 ], # 60-90 days ago
1427    [ 7776000, 0       ], # 90+   days ago
1428   );
1429
1430   map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
1431           $_->[1] ? $self->_date - $_->[1] - 1 : '',
1432       ] }
1433   @ranges;
1434 }
1435
1436 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
1437
1438 Returns an postscript invoice, as a scalar.
1439
1440 Options can be passed as a hashref (recommended) or as a list of time, template
1441 and then any key/value pairs for any other options.
1442
1443 I<time> an optional value used to control the printing of overdue messages.  The
1444 default is now.  It isn't the date of the invoice; that's the `_date' field.
1445 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1446 L<Time::Local> and L<Date::Parse> for conversion functions.
1447
1448 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1449
1450 =cut
1451
1452 sub print_ps {
1453   my $self = shift;
1454
1455   my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1456   my $ps = generate_ps($file);
1457   unlink($logofile);
1458   unlink($barcodefile) if $barcodefile;
1459
1460   $ps;
1461 }
1462
1463 =item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
1464
1465 Returns an PDF invoice, as a scalar.
1466
1467 Options can be passed as a hashref (recommended) or as a list of time, template
1468 and then any key/value pairs for any other options.
1469
1470 I<time> an optional value used to control the printing of overdue messages.  The
1471 default is now.  It isn't the date of the invoice; that's the `_date' field.
1472 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1473 L<Time::Local> and L<Date::Parse> for conversion functions.
1474
1475 I<template>, if specified, is the name of a suffix for alternate invoices.
1476
1477 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1478
1479 =cut
1480
1481 sub print_pdf {
1482   my $self = shift;
1483
1484   my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1485   my $pdf = generate_pdf($file);
1486   unlink($logofile);
1487   unlink($barcodefile) if $barcodefile;
1488
1489   $pdf;
1490 }
1491
1492 =item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
1493
1494 Returns an HTML invoice, as a scalar.
1495
1496 I<time> an optional value used to control the printing of overdue messages.  The
1497 default is now.  It isn't the date of the invoice; that's the `_date' field.
1498 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1499 L<Time::Local> and L<Date::Parse> for conversion functions.
1500
1501 I<template>, if specified, is the name of a suffix for alternate invoices.
1502
1503 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1504
1505 I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
1506 when emailing the invoice as part of a multipart/related MIME email.
1507
1508 =cut
1509
1510 sub print_html {
1511   my $self = shift;
1512   my %params;
1513   if ( ref($_[0]) ) {
1514     %params = %{ shift() }; 
1515   }else{
1516     $params{'time'} = shift;
1517     $params{'template'} = shift;
1518     $params{'cid'} = shift;
1519   }
1520
1521   $params{'format'} = 'html';
1522   
1523   $self->print_generic( %params );
1524 }
1525
1526 # quick subroutine for print_latex
1527 #
1528 # There are ten characters that LaTeX treats as special characters, which
1529 # means that they do not simply typeset themselves: 
1530 #      # $ % & ~ _ ^ \ { }
1531 #
1532 # TeX ignores blanks following an escaped character; if you want a blank (as
1533 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
1534
1535 sub _latex_escape {
1536   my $value = shift;
1537   $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
1538   $value =~ s/([<>])/\$$1\$/g;
1539   $value;
1540 }
1541
1542 sub _html_escape {
1543   my $value = shift;
1544   encode_entities($value);
1545   $value;
1546 }
1547
1548 sub _html_escape_nbsp {
1549   my $value = _html_escape(shift);
1550   $value =~ s/ +/&nbsp;/g;
1551   $value;
1552 }
1553
1554 #utility methods for print_*
1555
1556 sub _translate_old_latex_format {
1557   warn "_translate_old_latex_format called\n"
1558     if $DEBUG; 
1559
1560   my @template = ();
1561   while ( @_ ) {
1562     my $line = shift;
1563   
1564     if ( $line =~ /^%%Detail\s*$/ ) {
1565   
1566       push @template, q![@--!,
1567                       q!  foreach my $_tr_line (@detail_items) {!,
1568                       q!    if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
1569                       q!      $_tr_line->{'description'} .= !, 
1570                       q!        "\\tabularnewline\n~~".!,
1571                       q!        join( "\\tabularnewline\n~~",!,
1572                       q!          @{$_tr_line->{'ext_description'}}!,
1573                       q!        );!,
1574                       q!    }!;
1575
1576       while ( ( my $line_item_line = shift )
1577               !~ /^%%EndDetail\s*$/                            ) {
1578         $line_item_line =~ s/'/\\'/g;    # nice LTS
1579         $line_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
1580         $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1581         push @template, "    \$OUT .= '$line_item_line';";
1582       }
1583
1584       push @template, '}',
1585                       '--@]';
1586       #' doh, gvim
1587     } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1588
1589       push @template, '[@--',
1590                       '  foreach my $_tr_line (@total_items) {';
1591
1592       while ( ( my $total_item_line = shift )
1593               !~ /^%%EndTotalDetails\s*$/                      ) {
1594         $total_item_line =~ s/'/\\'/g;    # nice LTS
1595         $total_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
1596         $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1597         push @template, "    \$OUT .= '$total_item_line';";
1598       }
1599
1600       push @template, '}',
1601                       '--@]';
1602
1603     } else {
1604       $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
1605       push @template, $line;  
1606     }
1607   
1608   }
1609
1610   if ($DEBUG) {
1611     warn "$_\n" foreach @template;
1612   }
1613
1614   (@template);
1615 }
1616
1617 sub terms {
1618   my $self = shift;
1619   my $conf = $self->conf;
1620
1621   #check for an invoice-specific override
1622   return $self->invoice_terms if $self->invoice_terms;
1623   
1624   #check for a customer- specific override
1625   my $cust_main = $self->cust_main;
1626   return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
1627
1628   #use configured default
1629   $conf->config('invoice_default_terms') || '';
1630 }
1631
1632 sub due_date {
1633   my $self = shift;
1634   my $duedate = '';
1635   if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
1636     $duedate = $self->_date() + ( $1 * 86400 );
1637   }
1638   $duedate;
1639 }
1640
1641 sub due_date2str {
1642   my $self = shift;
1643   $self->due_date ? time2str(shift, $self->due_date) : '';
1644 }
1645
1646 sub balance_due_msg {
1647   my $self = shift;
1648   my $msg = $self->mt('Balance Due');
1649   return $msg unless $self->terms;
1650   if ( $self->due_date ) {
1651     $msg .= ' - ' . $self->mt('Please pay by'). ' '.
1652       $self->due_date2str($date_format);
1653   } elsif ( $self->terms ) {
1654     $msg .= ' - '. $self->terms;
1655   }
1656   $msg;
1657 }
1658
1659 sub balance_due_date {
1660   my $self = shift;
1661   my $conf = $self->conf;
1662   my $duedate = '';
1663   if (    $conf->exists('invoice_default_terms') 
1664        && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
1665     $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
1666   }
1667   $duedate;
1668 }
1669
1670 sub credit_balance_msg { 
1671   my $self = shift;
1672   $self->mt('Credit Balance Remaining')
1673 }
1674
1675 =item _date_pretty
1676
1677 Returns a string with the date, for example: "3/20/2008"
1678
1679 =cut
1680
1681 sub _date_pretty {
1682   my $self = shift;
1683   time2str($date_format, $self->_date);
1684 }
1685
1686 =item _items_sections LATE SUMMARYPAGE ESCAPE EXTRA_SECTIONS FORMAT
1687
1688 Generate section information for all items appearing on this invoice.
1689 This will only be called for multi-section invoices.
1690
1691 For each line item (L<FS::cust_bill_pkg> record), this will fetch all 
1692 related display records (L<FS::cust_bill_pkg_display>) and organize 
1693 them into two groups ("early" and "late" according to whether they come 
1694 before or after the total), then into sections.  A subtotal is calculated 
1695 for each section.
1696
1697 Section descriptions are returned in sort weight order.  Each consists 
1698 of a hash containing:
1699
1700 description: the package category name, escaped
1701 subtotal: the total charges in that section
1702 tax_section: a flag indicating that the section contains only tax charges
1703 summarized: same as tax_section, for some reason
1704 sort_weight: the package category's sort weight
1705
1706 If 'condense' is set on the display record, it also contains everything 
1707 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
1708 coderefs to generate parts of the invoice.  This is not advised.
1709
1710 Arguments:
1711
1712 LATE: an arrayref to push the "late" section hashes onto.  The "early"
1713 group is simply returned from the method.
1714
1715 SUMMARYPAGE: a flag indicating whether this is a summary-format invoice.
1716 Turning this on has the following effects:
1717 - Ignores display items with the 'summary' flag.
1718 - Combines all items into the "early" group.
1719 - Creates sections for all non-disabled package categories, even if they 
1720 have no charges on this invoice, as well as a section with no name.
1721
1722 ESCAPE: an escape function to use for section titles.
1723
1724 EXTRA_SECTIONS: an arrayref of additional sections to return after the 
1725 sorted list.  If there are any of these, section subtotals exclude 
1726 usage charges.
1727
1728 FORMAT: 'latex', 'html', or 'template' (i.e. text).  Not used, but 
1729 passed through to C<_condense_section()>.
1730
1731 =cut
1732
1733 use vars qw(%pkg_category_cache);
1734 sub _items_sections {
1735   my $self = shift;
1736   my $late = shift;
1737   my $summarypage = shift;
1738   my $escape = shift;
1739   my $extra_sections = shift;
1740   my $format = shift;
1741
1742   my %subtotal = ();
1743   my %late_subtotal = ();
1744   my %not_tax = ();
1745
1746   foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
1747   {
1748
1749       my $usage = $cust_bill_pkg->usage;
1750
1751       foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
1752         next if ( $display->summary && $summarypage );
1753
1754         my $section = $display->section;
1755         my $type    = $display->type;
1756
1757         $not_tax{$section} = 1
1758           unless $cust_bill_pkg->pkgnum == 0;
1759
1760         # there's actually a very important piece of logic buried in here:
1761         # incrementing $late_subtotal{$section} CREATES 
1762         # $late_subtotal{$section}.  keys(%late_subtotal) is later used 
1763         # to define the list of late sections, and likewise keys(%subtotal).
1764         # When _items_cust_bill_pkg is called to generate line items for 
1765         # real, it will be called with 'section' => $section for each 
1766         # of these.
1767         if ( $display->post_total && !$summarypage ) {
1768           if (! $type || $type eq 'S') {
1769             $late_subtotal{$section} += $cust_bill_pkg->setup
1770               if $cust_bill_pkg->setup != 0
1771               || $cust_bill_pkg->setup_show_zero;
1772           }
1773
1774           if (! $type) {
1775             $late_subtotal{$section} += $cust_bill_pkg->recur
1776               if $cust_bill_pkg->recur != 0
1777               || $cust_bill_pkg->recur_show_zero;
1778           }
1779
1780           if ($type && $type eq 'R') {
1781             $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
1782               if $cust_bill_pkg->recur != 0
1783               || $cust_bill_pkg->recur_show_zero;
1784           }
1785           
1786           if ($type && $type eq 'U') {
1787             $late_subtotal{$section} += $usage
1788               unless scalar(@$extra_sections);
1789           }
1790
1791         } else {
1792
1793           next if $cust_bill_pkg->pkgnum == 0 && ! $section;
1794
1795           if (! $type || $type eq 'S') {
1796             $subtotal{$section} += $cust_bill_pkg->setup
1797               if $cust_bill_pkg->setup != 0
1798               || $cust_bill_pkg->setup_show_zero;
1799           }
1800
1801           if (! $type) {
1802             $subtotal{$section} += $cust_bill_pkg->recur
1803               if $cust_bill_pkg->recur != 0
1804               || $cust_bill_pkg->recur_show_zero;
1805           }
1806
1807           if ($type && $type eq 'R') {
1808             $subtotal{$section} += $cust_bill_pkg->recur - $usage
1809               if $cust_bill_pkg->recur != 0
1810               || $cust_bill_pkg->recur_show_zero;
1811           }
1812           
1813           if ($type && $type eq 'U') {
1814             $subtotal{$section} += $usage
1815               unless scalar(@$extra_sections);
1816           }
1817
1818         }
1819
1820       }
1821
1822   }
1823
1824   %pkg_category_cache = ();
1825
1826   push @$late, map { { 'description' => &{$escape}($_),
1827                        'subtotal'    => $late_subtotal{$_},
1828                        'post_total'  => 1,
1829                        'sort_weight' => ( _pkg_category($_)
1830                                             ? _pkg_category($_)->weight
1831                                             : 0
1832                                        ),
1833                        ((_pkg_category($_) && _pkg_category($_)->condense)
1834                                            ? $self->_condense_section($format)
1835                                            : ()
1836                        ),
1837                    } }
1838                  sort _sectionsort keys %late_subtotal;
1839
1840   my @sections;
1841   if ( $summarypage ) {
1842     @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
1843                 map { $_->categoryname } qsearch('pkg_category', {});
1844     push @sections, '' if exists($subtotal{''});
1845   } else {
1846     @sections = keys %subtotal;
1847   }
1848
1849   my @early = map { { 'description' => &{$escape}($_),
1850                       'subtotal'    => $subtotal{$_},
1851                       'summarized'  => $not_tax{$_} ? '' : 'Y',
1852                       'tax_section' => $not_tax{$_} ? '' : 'Y',
1853                       'sort_weight' => ( _pkg_category($_)
1854                                            ? _pkg_category($_)->weight
1855                                            : 0
1856                                        ),
1857                        ((_pkg_category($_) && _pkg_category($_)->condense)
1858                                            ? $self->_condense_section($format)
1859                                            : ()
1860                        ),
1861                     }
1862                   } @sections;
1863   push @early, @$extra_sections if $extra_sections;
1864
1865   sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
1866
1867 }
1868
1869 #helper subs for above
1870
1871 sub _sectionsort {
1872   _pkg_category($a)->weight <=> _pkg_category($b)->weight;
1873 }
1874
1875 sub _pkg_category {
1876   my $categoryname = shift;
1877   $pkg_category_cache{$categoryname} ||=
1878     qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
1879 }
1880
1881 my %condensed_format = (
1882   'label' => [ qw( Description Qty Amount ) ],
1883   'fields' => [
1884                 sub { shift->{description} },
1885                 sub { shift->{quantity} },
1886                 sub { my($href, %opt) = @_;
1887                       ($opt{dollar} || ''). $href->{amount};
1888                     },
1889               ],
1890   'align'  => [ qw( l r r ) ],
1891   'span'   => [ qw( 5 1 1 ) ],            # unitprices?
1892   'width'  => [ qw( 10.7cm 1.4cm 1.6cm ) ],   # don't like this
1893 );
1894
1895 sub _condense_section {
1896   my ( $self, $format ) = ( shift, shift );
1897   ( 'condensed' => 1,
1898     map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
1899       qw( description_generator
1900           header_generator
1901           total_generator
1902           total_line_generator
1903         )
1904   );
1905 }
1906
1907 sub _condensed_generator_defaults {
1908   my ( $self, $format ) = ( shift, shift );
1909   return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
1910 }
1911
1912 my %html_align = (
1913   'c' => 'center',
1914   'l' => 'left',
1915   'r' => 'right',
1916 );
1917
1918 sub _condensed_header_generator {
1919   my ( $self, $format ) = ( shift, shift );
1920
1921   my ( $f, $prefix, $suffix, $separator, $column ) =
1922     _condensed_generator_defaults($format);
1923
1924   if ($format eq 'latex') {
1925     $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
1926     $suffix = "\\\\\n\\hline";
1927     $separator = "&\n";
1928     $column =
1929       sub { my ($d,$a,$s,$w) = @_;
1930             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
1931           };
1932   } elsif ( $format eq 'html' ) {
1933     $prefix = '<th></th>';
1934     $suffix = '';
1935     $separator = '';
1936     $column =
1937       sub { my ($d,$a,$s,$w) = @_;
1938             return qq!<th align="$html_align{$a}">$d</th>!;
1939       };
1940   }
1941
1942   sub {
1943     my @args = @_;
1944     my @result = ();
1945
1946     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
1947       push @result,
1948         &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
1949     }
1950
1951     $prefix. join($separator, @result). $suffix;
1952   };
1953
1954 }
1955
1956 sub _condensed_description_generator {
1957   my ( $self, $format ) = ( shift, shift );
1958
1959   my ( $f, $prefix, $suffix, $separator, $column ) =
1960     _condensed_generator_defaults($format);
1961
1962   my $money_char = '$';
1963   if ($format eq 'latex') {
1964     $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
1965     $suffix = '\\\\';
1966     $separator = " & \n";
1967     $column =
1968       sub { my ($d,$a,$s,$w) = @_;
1969             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
1970           };
1971     $money_char = '\\dollar';
1972   }elsif ( $format eq 'html' ) {
1973     $prefix = '"><td align="center"></td>';
1974     $suffix = '';
1975     $separator = '';
1976     $column =
1977       sub { my ($d,$a,$s,$w) = @_;
1978             return qq!<td align="$html_align{$a}">$d</td>!;
1979       };
1980     #$money_char = $conf->config('money_char') || '$';
1981     $money_char = '';  # this is madness
1982   }
1983
1984   sub {
1985     #my @args = @_;
1986     my $href = shift;
1987     my @result = ();
1988
1989     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
1990       my $dollar = '';
1991       $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
1992       push @result,
1993         &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
1994                     map { $f->{$_}->[$i] } qw(align span width)
1995                   );
1996     }
1997
1998     $prefix. join( $separator, @result ). $suffix;
1999   };
2000
2001 }
2002
2003 sub _condensed_total_generator {
2004   my ( $self, $format ) = ( shift, shift );
2005
2006   my ( $f, $prefix, $suffix, $separator, $column ) =
2007     _condensed_generator_defaults($format);
2008   my $style = '';
2009
2010   if ($format eq 'latex') {
2011     $prefix = "& ";
2012     $suffix = "\\\\\n";
2013     $separator = " & \n";
2014     $column =
2015       sub { my ($d,$a,$s,$w) = @_;
2016             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2017           };
2018   }elsif ( $format eq 'html' ) {
2019     $prefix = '';
2020     $suffix = '';
2021     $separator = '';
2022     $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2023     $column =
2024       sub { my ($d,$a,$s,$w) = @_;
2025             return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2026       };
2027   }
2028
2029
2030   sub {
2031     my @args = @_;
2032     my @result = ();
2033
2034     #  my $r = &{$f->{fields}->[$i]}(@args);
2035     #  $r .= ' Total' unless $i;
2036
2037     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
2038       push @result,
2039         &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
2040                     map { $f->{$_}->[$i] } qw(align span width)
2041                   );
2042     }
2043
2044     $prefix. join( $separator, @result ). $suffix;
2045   };
2046
2047 }
2048
2049 =item total_line_generator FORMAT
2050
2051 Returns a coderef used for generation of invoice total line items for this
2052 usage_class.  FORMAT is either html or latex
2053
2054 =cut
2055
2056 # should not be used: will have issues with hash element names (description vs
2057 # total_item and amount vs total_amount -- another array of functions?
2058
2059 sub _condensed_total_line_generator {
2060   my ( $self, $format ) = ( shift, shift );
2061
2062   my ( $f, $prefix, $suffix, $separator, $column ) =
2063     _condensed_generator_defaults($format);
2064   my $style = '';
2065
2066   if ($format eq 'latex') {
2067     $prefix = "& ";
2068     $suffix = "\\\\\n";
2069     $separator = " & \n";
2070     $column =
2071       sub { my ($d,$a,$s,$w) = @_;
2072             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2073           };
2074   }elsif ( $format eq 'html' ) {
2075     $prefix = '';
2076     $suffix = '';
2077     $separator = '';
2078     $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2079     $column =
2080       sub { my ($d,$a,$s,$w) = @_;
2081             return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2082       };
2083   }
2084
2085
2086   sub {
2087     my @args = @_;
2088     my @result = ();
2089
2090     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
2091       push @result,
2092         &{$column}( &{$f->{fields}->[$i]}(@args),
2093                     map { $f->{$_}->[$i] } qw(align span width)
2094                   );
2095     }
2096
2097     $prefix. join( $separator, @result ). $suffix;
2098   };
2099
2100 }
2101
2102 #  sub _items { # seems to be unused
2103 #    my $self = shift;
2104 #  
2105 #    #my @display = scalar(@_)
2106 #    #              ? @_
2107 #    #              : qw( _items_previous _items_pkg );
2108 #    #              #: qw( _items_pkg );
2109 #    #              #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
2110 #    my @display = qw( _items_previous _items_pkg );
2111 #  
2112 #    my @b = ();
2113 #    foreach my $display ( @display ) {
2114 #      push @b, $self->$display(@_);
2115 #    }
2116 #    @b;
2117 #  }
2118
2119 =item _items_pkg [ OPTIONS ]
2120
2121 Return line item hashes for each package item on this invoice. Nearly 
2122 equivalent to 
2123
2124 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
2125
2126 The only OPTIONS accepted is 'section', which may point to a hashref 
2127 with a key named 'condensed', which may have a true value.  If it 
2128 does, this method tries to merge identical items into items with 
2129 'quantity' equal to the number of items (not the sum of their 
2130 separate quantities, for some reason).
2131
2132 =cut
2133
2134 sub _items_nontax {
2135   my $self = shift;
2136   grep { $_->pkgnum } $self->cust_bill_pkg;
2137 }
2138
2139 sub _items_pkg {
2140   my $self = shift;
2141   my %options = @_;
2142
2143   warn "$me _items_pkg searching for all package line items\n"
2144     if $DEBUG > 1;
2145
2146   my @cust_bill_pkg = $self->_items_nontax;
2147
2148   warn "$me _items_pkg filtering line items\n"
2149     if $DEBUG > 1;
2150   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2151
2152   if ($options{section} && $options{section}->{condensed}) {
2153
2154     warn "$me _items_pkg condensing section\n"
2155       if $DEBUG > 1;
2156
2157     my %itemshash = ();
2158     local $Storable::canonical = 1;
2159     foreach ( @items ) {
2160       my $item = { %$_ };
2161       delete $item->{ref};
2162       delete $item->{ext_description};
2163       my $key = freeze($item);
2164       $itemshash{$key} ||= 0;
2165       $itemshash{$key} ++; # += $item->{quantity};
2166     }
2167     @items = sort { $a->{description} cmp $b->{description} }
2168              map { my $i = thaw($_);
2169                    $i->{quantity} = $itemshash{$_};
2170                    $i->{amount} =
2171                      sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
2172                    $i;
2173                  }
2174              keys %itemshash;
2175   }
2176
2177   warn "$me _items_pkg returning ". scalar(@items). " items\n"
2178     if $DEBUG > 1;
2179
2180   @items;
2181 }
2182
2183 sub _taxsort {
2184   return 0 unless $a->itemdesc cmp $b->itemdesc;
2185   return -1 if $b->itemdesc eq 'Tax';
2186   return 1 if $a->itemdesc eq 'Tax';
2187   return -1 if $b->itemdesc eq 'Other surcharges';
2188   return 1 if $a->itemdesc eq 'Other surcharges';
2189   $a->itemdesc cmp $b->itemdesc;
2190 }
2191
2192 sub _items_tax {
2193   my $self = shift;
2194   my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
2195   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2196
2197   if ( $self->conf->exists('always_show_tax') ) {
2198     my $itemdesc = $self->conf->config('always_show_tax') || 'Tax';
2199     if (0 == grep { $_->{description} eq $itemdesc } @items) {
2200       push @items,
2201         { 'description' => $itemdesc,
2202           'amount'      => 0.00 };
2203     }
2204   }
2205   @items;
2206 }
2207
2208 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
2209
2210 Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
2211 list of hashrefs describing the line items they generate on the invoice.
2212
2213 OPTIONS may include:
2214
2215 format: the invoice format.
2216
2217 escape_function: the function used to escape strings.
2218
2219 DEPRECATED? (expensive, mostly unused?)
2220 format_function: the function used to format CDRs.
2221
2222 section: a hashref containing 'description'; if this is present, 
2223 cust_bill_pkg_display records not belonging to this section are 
2224 ignored.
2225
2226 multisection: a flag indicating that this is a multisection invoice,
2227 which does something complicated.
2228
2229 Returns a list of hashrefs, each of which may contain:
2230
2231 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
2232 ext_description, which is an arrayref of detail lines to show below 
2233 the package line.
2234
2235 =cut
2236
2237 sub _items_cust_bill_pkg {
2238   my $self = shift;
2239   my $conf = $self->conf;
2240   my $cust_bill_pkgs = shift;
2241   my %opt = @_;
2242
2243   my $format = $opt{format} || '';
2244   my $escape_function = $opt{escape_function} || sub { shift };
2245   my $format_function = $opt{format_function} || '';
2246   my $no_usage = $opt{no_usage} || '';
2247   my $unsquelched = $opt{unsquelched} || ''; #unused
2248   my $section = $opt{section}->{description} if $opt{section};
2249   my $summary_page = $opt{summary_page} || ''; #unused
2250   my $multisection = $opt{multisection} || '';
2251   my $discount_show_always = 0;
2252
2253   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2254
2255   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
2256                                    # and location labels
2257
2258   my @b = ();
2259   my ($s, $r, $u) = ( undef, undef, undef );
2260   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
2261   {
2262
2263     foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2264       if ( $_ && !$cust_bill_pkg->hidden ) {
2265         $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
2266         $_->{amount}      =~ s/^\-0\.00$/0.00/;
2267         $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2268         push @b, { %$_ }
2269           if $_->{amount} != 0
2270           || $discount_show_always
2271           || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2272           || (   $_->{_is_setup} && $_->{setup_show_zero} )
2273         ;
2274         $_ = undef;
2275       }
2276     }
2277
2278     my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
2279                                   ? $cust_bill_pkg->cust_bill_pkg_display
2280                                   : ( $cust_bill_pkg );
2281
2282     warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
2283          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
2284       if $DEBUG > 1;
2285
2286     foreach my $display ( grep { defined($section)
2287                             ? $_->section eq $section
2288                             : 1
2289                           }
2290                           grep { !$_->summary || $multisection }
2291                           @cust_bill_pkg_display
2292                         )
2293       {
2294
2295       warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
2296            $display->billpkgdisplaynum. "\n"
2297         if $DEBUG > 1;
2298
2299       my $type = $display->type;
2300
2301       my $desc = $cust_bill_pkg->desc( $cust_main ? $cust_main->locale : '' );
2302       $desc = substr($desc, 0, $maxlength). '...'
2303         if $format eq 'latex' && length($desc) > $maxlength;
2304
2305       my %details_opt = ( 'format'          => $format,
2306                           'escape_function' => $escape_function,
2307                           'format_function' => $format_function,
2308                           'no_usage'        => $opt{'no_usage'},
2309                         );
2310
2311       if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
2312
2313         warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
2314           if $DEBUG > 1;
2315
2316         if ( $cust_bill_pkg->setup != 0 ) {
2317           my $description = $desc;
2318           $description .= ' Setup'
2319             if $cust_bill_pkg->recur != 0
2320             || $discount_show_always
2321             || $cust_bill_pkg->recur_show_zero;
2322           push @b, {
2323             'description' => $description,
2324             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
2325           };
2326         }
2327         if ( $cust_bill_pkg->recur != 0 ) {
2328           push @b, {
2329             'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
2330             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
2331           };
2332         }
2333
2334       } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
2335
2336         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
2337           if $DEBUG > 1;
2338  
2339         my $cust_pkg = $cust_bill_pkg->cust_pkg;
2340
2341         # which pkgpart to show for display purposes?
2342         my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
2343
2344         # start/end dates for invoice formats that do nonstandard 
2345         # things with them
2346         my %item_dates = ();
2347         %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
2348           unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
2349
2350         if (    (!$type || $type eq 'S')
2351              && (    $cust_bill_pkg->setup != 0
2352                   || $cust_bill_pkg->setup_show_zero
2353                 )
2354            )
2355          {
2356
2357           warn "$me _items_cust_bill_pkg adding setup\n"
2358             if $DEBUG > 1;
2359
2360           my $description = $desc;
2361           $description .= ' Setup'
2362             if $cust_bill_pkg->recur != 0
2363             || $discount_show_always
2364             || $cust_bill_pkg->recur_show_zero;
2365
2366           my @d = ();
2367           my $svc_label;
2368           unless ( $cust_pkg->part_pkg->hide_svc_detail
2369                 || $cust_bill_pkg->hidden )
2370           {
2371
2372             my @svc_labels = map &{$escape_function}($_),
2373                         $cust_pkg->h_labels_short($self->_date, undef, 'I');
2374             push @d, @svc_labels
2375               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2376             $svc_label = $svc_labels[0];
2377
2378             my $lnum = $cust_main ? $cust_main->ship_locationnum
2379                                   : $self->prospect_main->locationnum;
2380             if ( ! $cust_pkg->locationnum or $cust_pkg->locationnum != $lnum ) {
2381               my $loc = $cust_pkg->location_label;
2382               $loc = substr($loc, 0, $maxlength). '...'
2383                 if $format eq 'latex' && length($loc) > $maxlength;
2384               push @d, &{$escape_function}($loc);
2385             }
2386
2387           } #unless hiding service details
2388
2389           push @d, $cust_bill_pkg->details(%details_opt)
2390             if $cust_bill_pkg->recur == 0;
2391
2392           if ( $cust_bill_pkg->hidden ) {
2393             $s->{amount}      += $cust_bill_pkg->setup;
2394             $s->{unit_amount} += $cust_bill_pkg->unitsetup;
2395             push @{ $s->{ext_description} }, @d;
2396           } else {
2397             $s = {
2398               _is_setup       => 1,
2399               description     => $description,
2400               pkgpart         => $pkgpart,
2401               pkgnum          => $cust_bill_pkg->pkgnum,
2402               amount          => $cust_bill_pkg->setup,
2403               setup_show_zero => $cust_bill_pkg->setup_show_zero,
2404               unit_amount     => $cust_bill_pkg->unitsetup,
2405               quantity        => $cust_bill_pkg->quantity,
2406               ext_description => \@d,
2407               svc_label       => ($svc_label || ''),
2408             };
2409           };
2410
2411         }
2412
2413         if (    ( !$type || $type eq 'R' || $type eq 'U' )
2414              && (
2415                      $cust_bill_pkg->recur != 0
2416                   || $cust_bill_pkg->setup == 0
2417                   || $discount_show_always
2418                   || $cust_bill_pkg->recur_show_zero
2419                 )
2420            )
2421         {
2422
2423           warn "$me _items_cust_bill_pkg adding recur/usage\n"
2424             if $DEBUG > 1;
2425
2426           my $is_summary = $display->summary;
2427           my $description = ($is_summary && $type && $type eq 'U')
2428                             ? "Usage charges" : $desc;
2429
2430           my $part_pkg = $cust_pkg->part_pkg;
2431
2432           #pry be a bit more efficient to look some of this conf stuff up
2433           # outside the loop
2434           unless (
2435             $conf->exists('disable_line_item_date_ranges')
2436               || $part_pkg->option('disable_line_item_date_ranges',1)
2437               || ! $cust_bill_pkg->sdate
2438               || ! $cust_bill_pkg->edate
2439           ) {
2440             my $time_period;
2441             my $date_style = '';
2442             $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monhtly',
2443                                          $self->agentnum
2444                                        )
2445               if $part_pkg && $part_pkg->freq !~ /^1m?$/;
2446             $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
2447                                             $self->agentnum
2448                                          );
2449             if ( defined($date_style) && $date_style eq 'month_of' ) {
2450               $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
2451             } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
2452               my $desc = $conf->config( 'cust_bill-line_item-date_description',
2453                                          $self->agentnum
2454                                       );
2455               $desc .= ' ' unless $desc =~ /\s$/;
2456               $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
2457             } else {
2458               $time_period =      time2str($date_format, $cust_bill_pkg->sdate).
2459                            " - ". time2str($date_format, $cust_bill_pkg->edate);
2460             }
2461             $description .= " ($time_period)";
2462           }
2463
2464           my @d = ();
2465           my @seconds = (); # for display of usage info
2466           my $svc_label = '';
2467
2468           #at least until cust_bill_pkg has "past" ranges in addition to
2469           #the "future" sdate/edate ones... see #3032
2470           my @dates = ( $self->_date );
2471           my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
2472           push @dates, $prev->sdate if $prev;
2473           push @dates, undef if !$prev;
2474
2475           unless ( $part_pkg->hide_svc_detail
2476                 || $cust_bill_pkg->itemdesc
2477                 || $cust_bill_pkg->hidden
2478                 || $is_summary && $type && $type eq 'U'
2479               )
2480           {
2481
2482             warn "$me _items_cust_bill_pkg adding service details\n"
2483               if $DEBUG > 1;
2484
2485             my @svc_labels = map &{$escape_function}($_),
2486                         $cust_pkg->h_labels_short($self->_date, undef, 'I');
2487             push @d, @svc_labels
2488               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2489             $svc_label = $svc_labels[0];
2490
2491             warn "$me _items_cust_bill_pkg done adding service details\n"
2492               if $DEBUG > 1;
2493
2494             my $lnum = $cust_main ? $cust_main->ship_locationnum
2495                                   : $self->prospect_main->locationnum;
2496             if ( $cust_pkg->locationnum != $lnum ) {
2497               my $loc = $cust_pkg->location_label;
2498               $loc = substr($loc, 0, $maxlength). '...'
2499                 if $format eq 'latex' && length($loc) > $maxlength;
2500               push @d, &{$escape_function}($loc);
2501             }
2502
2503             # Display of seconds_since_sqlradacct:
2504             # On the invoice, when processing @detail_items, look for a field
2505             # named 'seconds'.  This will contain total seconds for each 
2506             # service, in the same order as @ext_description.  For services 
2507             # that don't support this it will show undef.
2508             if ( $conf->exists('svc_acct-usage_seconds') 
2509                  and ! $cust_bill_pkg->pkgpart_override ) {
2510               foreach my $cust_svc ( 
2511                   $cust_pkg->h_cust_svc(@dates, 'I') 
2512                 ) {
2513
2514                 # eval because not having any part_export_usage exports 
2515                 # is a fatal error, last_bill/_date because that's how 
2516                 # sqlradius_hour billing does it
2517                 my $sec = eval {
2518                   $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
2519                 };
2520                 push @seconds, $sec;
2521               }
2522             } #if svc_acct-usage_seconds
2523
2524           }
2525
2526           unless ( $is_summary ) {
2527             warn "$me _items_cust_bill_pkg adding details\n"
2528               if $DEBUG > 1;
2529
2530             #instead of omitting details entirely in this case (unwanted side
2531             # effects), just omit CDRs
2532             $details_opt{'no_usage'} = 1
2533               if $type && $type eq 'R';
2534
2535             push @d, $cust_bill_pkg->details(%details_opt);
2536           }
2537
2538           warn "$me _items_cust_bill_pkg calculating amount\n"
2539             if $DEBUG > 1;
2540   
2541           my $amount = 0;
2542           if (!$type) {
2543             $amount = $cust_bill_pkg->recur;
2544           } elsif ($type eq 'R') {
2545             $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
2546           } elsif ($type eq 'U') {
2547             $amount = $cust_bill_pkg->usage;
2548           }
2549   
2550           my $unit_amount =
2551             ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
2552                                               : $amount;
2553
2554           if ( !$type || $type eq 'R' ) {
2555
2556             warn "$me _items_cust_bill_pkg adding recur\n"
2557               if $DEBUG > 1;
2558
2559             if ( $cust_bill_pkg->hidden ) {
2560               $r->{amount}      += $amount;
2561               $r->{unit_amount} += $unit_amount;
2562               push @{ $r->{ext_description} }, @d;
2563             } else {
2564               $r = {
2565                 description     => $description,
2566                 pkgpart         => $pkgpart,
2567                 pkgnum          => $cust_bill_pkg->pkgnum,
2568                 amount          => $amount,
2569                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2570                 unit_amount     => $unit_amount,
2571                 quantity        => $cust_bill_pkg->quantity,
2572                 %item_dates,
2573                 ext_description => \@d,
2574                 svc_label       => ($svc_label || ''),
2575               };
2576               $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
2577             }
2578
2579           } else {  # $type eq 'U'
2580
2581             warn "$me _items_cust_bill_pkg adding usage\n"
2582               if $DEBUG > 1;
2583
2584             if ( $cust_bill_pkg->hidden ) {
2585               $u->{amount}      += $amount;
2586               $u->{unit_amount} += $unit_amount,
2587               push @{ $u->{ext_description} }, @d;
2588             } else {
2589               $u = {
2590                 description     => $description,
2591                 pkgpart         => $pkgpart,
2592                 pkgnum          => $cust_bill_pkg->pkgnum,
2593                 amount          => $amount,
2594                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2595                 unit_amount     => $unit_amount,
2596                 quantity        => $cust_bill_pkg->quantity,
2597                 %item_dates,
2598                 ext_description => \@d,
2599               };
2600             }
2601           }
2602
2603         } # recurring or usage with recurring charge
2604
2605       } else { #pkgnum tax or one-shot line item (??)
2606
2607         warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
2608           if $DEBUG > 1;
2609
2610         if ( $cust_bill_pkg->setup != 0 ) {
2611           push @b, {
2612             'description' => $desc,
2613             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
2614           };
2615         }
2616         if ( $cust_bill_pkg->recur != 0 ) {
2617           push @b, {
2618             'description' => "$desc (".
2619                              time2str($date_format, $cust_bill_pkg->sdate). ' - '.
2620                              time2str($date_format, $cust_bill_pkg->edate). ')',
2621             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
2622           };
2623         }
2624
2625       }
2626
2627     }
2628
2629     $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
2630                                 && $conf->exists('discount-show-always'));
2631
2632   }
2633
2634   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2635     if ( $_  ) {
2636       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
2637       $_->{amount}      =~ s/^\-0\.00$/0.00/;
2638       $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2639       push @b, { %$_ }
2640         if $_->{amount} != 0
2641         || $discount_show_always
2642         || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2643         || (   $_->{_is_setup} && $_->{setup_show_zero} )
2644     }
2645   }
2646
2647   warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
2648     if $DEBUG > 1;
2649
2650   @b;
2651
2652 }
2653
2654 =item _items_discounts_avail
2655
2656 Returns an array of line item hashrefs representing available term discounts
2657 for this invoice.  This makes the same assumptions that apply to term 
2658 discounts in general: that the package is billed monthly, at a flat rate, 
2659 with no usage charges.  A prorated first month will be handled, as will 
2660 a setup fee if the discount is allowed to apply to setup fees.
2661
2662 =cut
2663
2664 sub _items_discounts_avail {
2665   my $self = shift;
2666
2667   #maybe move this method from cust_bill when quotations support discount_plans 
2668   return () unless $self->can('discount_plans');
2669   my %plans = $self->discount_plans;
2670
2671   my $list_pkgnums = 0; # if any packages are not eligible for all discounts
2672   $list_pkgnums = grep { $_->list_pkgnums } values %plans;
2673
2674   map {
2675     my $months = $_;
2676     my $plan = $plans{$months};
2677
2678     my $term_total = sprintf('%.2f', $plan->discounted_total);
2679     my $percent = sprintf('%.0f', 
2680                           100 * (1 - $term_total / $plan->base_total) );
2681     my $permonth = sprintf('%.2f', $term_total / $months);
2682     my $detail = $self->mt('discount on item'). ' '.
2683                  join(', ', map { "#$_" } $plan->pkgnums)
2684       if $list_pkgnums;
2685
2686     # discounts for non-integer months don't work anyway
2687     $months = sprintf("%d", $months);
2688
2689     +{
2690       description => $self->mt('Save [_1]% by paying for [_2] months',
2691                                 $percent, $months),
2692       amount      => $self->mt('[_1] ([_2] per month)', 
2693                                 $term_total, $money_char.$permonth),
2694       ext_description => ($detail || ''),
2695     }
2696   } #map
2697   sort { $b <=> $a } keys %plans;
2698
2699 }
2700
2701 1;