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