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