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