hide display of date ranges when including a setup package as an add-on with a recurr...
[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   
945       push @detail_items, $detail;
946       push @buf, ( [ $detail->{'description'},
947                      $money_char. sprintf("%10.2f", $line_item->{'amount'}),
948                    ],
949                    map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
950                  );
951     }
952
953     if ( $section->{'description'} ) {
954       push @buf, ( ['','-----------'],
955                    [ $section->{'description'}. ' sub-total',
956                       $section->{'subtotal'} # already formatted this 
957                    ],
958                    [ '', '' ],
959                    [ '', '' ],
960                  );
961     }
962   
963   }
964
965   $invoice_data{current_less_finance} =
966     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
967
968   # create a major section for previous balance if we have major sections,
969   # or if previous_section is in summary form
970   if ( ( $multisection && $self->enable_previous )
971     || $conf->exists('previous_balance-summary_only') )
972   {
973     unshift @sections, $previous_section if $pr_total;
974   }
975
976   warn "$me adding taxes\n"
977     if $DEBUG > 1;
978
979   foreach my $tax ( $self->_items_tax ) {
980
981     $taxtotal += $tax->{'amount'};
982
983     my $description = &$escape_function( $tax->{'description'} );
984     my $amount      = sprintf( '%.2f', $tax->{'amount'} );
985
986     if ( $multisection ) {
987
988       my $money = $old_latex ? '' : $money_char;
989       push @detail_items, {
990         ext_description => [],
991         ref          => '',
992         quantity     => '',
993         description  => $description,
994         amount       => $money. $amount,
995         product_code => '',
996         section      => $tax_section,
997       };
998
999     } else {
1000
1001       push @total_items, {
1002         'total_item'   => $description,
1003         'total_amount' => $other_money_char. $amount,
1004       };
1005
1006     }
1007
1008     push @buf,[ $description,
1009                 $money_char. $amount,
1010               ];
1011
1012   }
1013   
1014   if ( $taxtotal ) {
1015     my $total = {};
1016     $total->{'total_item'} = $self->mt('Sub-total');
1017     $total->{'total_amount'} =
1018       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
1019
1020     if ( $multisection ) {
1021       $tax_section->{'subtotal'} = $other_money_char.
1022                                    sprintf('%.2f', $taxtotal);
1023       $tax_section->{'pretotal'} = 'New charges sub-total '.
1024                                    $total->{'total_amount'};
1025       push @sections, $tax_section if $taxtotal;
1026     }else{
1027       unshift @total_items, $total;
1028     }
1029   }
1030   $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
1031
1032   push @buf,['','-----------'];
1033   push @buf,[$self->mt( 
1034               (!$self->enable_previous)
1035                ? 'Total Charges'
1036                : 'Total New Charges'
1037              ),
1038              $money_char. sprintf("%10.2f",$self->charged) ];
1039   push @buf,['',''];
1040
1041   # calculate total, possibly including total owed on previous
1042   # invoices
1043   {
1044     my $total = {};
1045     my $item = 'Total';
1046     $item = $conf->config('previous_balance-exclude_from_total')
1047          || 'Total New Charges'
1048       if $conf->exists('previous_balance-exclude_from_total');
1049     my $amount = $self->charged;
1050     if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) {
1051       $amount += $pr_total;
1052     }
1053
1054     $total->{'total_item'} = &$embolden_function($self->mt($item));
1055     $total->{'total_amount'} =
1056       &$embolden_function( $other_money_char.  sprintf( '%.2f', $amount ) );
1057     if ( $multisection ) {
1058       if ( $adjust_section->{'sort_weight'} ) {
1059         $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
1060           $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
1061       } else {
1062         $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
1063           $other_money_char.  sprintf('%.2f', $self->charged );
1064       } 
1065     }else{
1066       push @total_items, $total;
1067     }
1068     push @buf,['','-----------'];
1069     push @buf,[$item,
1070                $money_char.
1071                sprintf( '%10.2f', $amount )
1072               ];
1073     push @buf,['',''];
1074   }
1075
1076   # if we're showing previous invoices, also show previous
1077   # credits and payments 
1078   if ( $self->enable_previous 
1079         and $self->can('_items_credits')
1080         and $self->can('_items_payments') )
1081     {
1082     #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
1083   
1084     # credits
1085     my $credittotal = 0;
1086     foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
1087
1088       my $total;
1089       $total->{'total_item'} = &$escape_function($credit->{'description'});
1090       $credittotal += $credit->{'amount'};
1091       $total->{'total_amount'} = '-'. $other_money_char. $credit->{'amount'};
1092       $adjusttotal += $credit->{'amount'};
1093       if ( $multisection ) {
1094         my $money = $old_latex ? '' : $money_char;
1095         push @detail_items, {
1096           ext_description => [],
1097           ref          => '',
1098           quantity     => '',
1099           description  => &$escape_function($credit->{'description'}),
1100           amount       => $money. $credit->{'amount'},
1101           product_code => '',
1102           section      => $adjust_section,
1103         };
1104       } else {
1105         push @total_items, $total;
1106       }
1107
1108     }
1109     $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
1110
1111     #credits (again)
1112     foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
1113       push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
1114     }
1115
1116     # payments
1117     my $paymenttotal = 0;
1118     foreach my $payment ( $self->_items_payments ) {
1119       my $total = {};
1120       $total->{'total_item'} = &$escape_function($payment->{'description'});
1121       $paymenttotal += $payment->{'amount'};
1122       $total->{'total_amount'} = '-'. $other_money_char. $payment->{'amount'};
1123       $adjusttotal += $payment->{'amount'};
1124       if ( $multisection ) {
1125         my $money = $old_latex ? '' : $money_char;
1126         push @detail_items, {
1127           ext_description => [],
1128           ref          => '',
1129           quantity     => '',
1130           description  => &$escape_function($payment->{'description'}),
1131           amount       => $money. $payment->{'amount'},
1132           product_code => '',
1133           section      => $adjust_section,
1134         };
1135       }else{
1136         push @total_items, $total;
1137       }
1138       push @buf, [ $payment->{'description'},
1139                    $money_char. sprintf("%10.2f", $payment->{'amount'}),
1140                  ];
1141     }
1142     $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
1143   
1144     if ( $multisection ) {
1145       $adjust_section->{'subtotal'} = $other_money_char.
1146                                       sprintf('%.2f', $adjusttotal);
1147       push @sections, $adjust_section
1148         unless $adjust_section->{sort_weight};
1149     }
1150
1151     # create Balance Due message
1152     { 
1153       my $total;
1154       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1155       $total->{'total_amount'} =
1156         &$embolden_function(
1157           $other_money_char. sprintf('%.2f', #why? $summarypage 
1158                                              #  ? $self->charged +
1159                                              #    $self->billing_balance
1160                                              #  :
1161                                                  $self->owed + $pr_total
1162                                     )
1163         );
1164       if ( $multisection && !$adjust_section->{sort_weight} ) {
1165         $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
1166                                          $total->{'total_amount'};
1167       }else{
1168         push @total_items, $total;
1169       }
1170       push @buf,['','-----------'];
1171       push @buf,[$self->balance_due_msg, $money_char. 
1172         sprintf("%10.2f", $balance_due ) ];
1173     }
1174
1175     if ( $conf->exists('previous_balance-show_credit')
1176         and $cust_main->balance < 0 ) {
1177       my $credit_total = {
1178         'total_item'    => &$embolden_function($self->credit_balance_msg),
1179         'total_amount'  => &$embolden_function(
1180           $other_money_char. sprintf('%.2f', -$cust_main->balance)
1181         ),
1182       };
1183       if ( $multisection ) {
1184         $adjust_section->{'posttotal'} .= $newline_token .
1185           $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
1186       }
1187       else {
1188         push @total_items, $credit_total;
1189       }
1190       push @buf,['','-----------'];
1191       push @buf,[$self->credit_balance_msg, $money_char. 
1192         sprintf("%10.2f", -$cust_main->balance ) ];
1193     }
1194   }
1195
1196   if ( $multisection ) {
1197     if (    $conf->exists('svc_phone_sections')
1198          && $self->can('_items_svc_phone_sections')
1199        )
1200     {
1201       my $total;
1202       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1203       $total->{'total_amount'} =
1204         &$embolden_function(
1205           $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
1206         );
1207       my $last_section = pop @sections;
1208       $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
1209                                      $total->{'total_amount'};
1210       push @sections, $last_section;
1211     }
1212     push @sections, @$late_sections
1213       if $unsquelched;
1214   }
1215
1216   # make a discounts-available section, even without multisection
1217   if ( $conf->exists('discount-show_available') 
1218        and my @discounts_avail = $self->_items_discounts_avail ) {
1219     my $discount_section = {
1220       'description' => $self->mt('Discounts Available'),
1221       'subtotal'    => '',
1222       'no_subtotal' => 1,
1223     };
1224
1225     push @sections, $discount_section;
1226     push @detail_items, map { +{
1227         'ref'         => '', #should this be something else?
1228         'section'     => $discount_section,
1229         'description' => &$escape_function( $_->{description} ),
1230         'amount'      => $money_char . &$escape_function( $_->{amount} ),
1231         'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
1232     } } @discounts_avail;
1233   }
1234
1235   # debugging hook: call this with 'diag' => 1 to just get a hash of 
1236   # the invoice variables
1237   return \%invoice_data if ( $params{'diag'} );
1238
1239   # All sections and items are built; now fill in templates.
1240   my @includelist = ();
1241   push @includelist, 'summary' if $summarypage;
1242   foreach my $include ( @includelist ) {
1243
1244     my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
1245     my @inc_src;
1246
1247     if ( length( $conf->config($inc_file, $agentnum) ) ) {
1248
1249       @inc_src = $conf->config($inc_file, $agentnum);
1250
1251     } else {
1252
1253       $inc_file = $conf->key_orbase("invoice_latex$include", $template);
1254
1255       my $convert_map = $convert_maps{$format}{$include};
1256
1257       @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
1258                        s/--\@\]/$delimiters{$format}[1]/g;
1259                        $_;
1260                      } 
1261                  &$convert_map( $conf->config($inc_file, $agentnum) );
1262
1263     }
1264
1265     my $inc_tt = new Text::Template (
1266       TYPE       => 'ARRAY',
1267       SOURCE     => [ map "$_\n", @inc_src ],
1268       DELIMITERS => $delimiters{$format},
1269     ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
1270
1271     unless ( $inc_tt->compile() ) {
1272       my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
1273       warn $error. "Template:\n". join('', map "$_\n", @inc_src);
1274       die $error;
1275     }
1276
1277     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
1278
1279     $invoice_data{$include} =~ s/\n+$//
1280       if ($format eq 'latex');
1281   }
1282
1283   $invoice_lines = 0;
1284   my $wasfunc = 0;
1285   foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
1286     /invoice_lines\((\d*)\)/;
1287     $invoice_lines += $1 || scalar(@buf);
1288     $wasfunc=1;
1289   }
1290   die "no invoice_lines() functions in template?"
1291     if ( $format eq 'template' && !$wasfunc );
1292
1293   if ($format eq 'template') {
1294
1295     if ( $invoice_lines ) {
1296       $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
1297       $invoice_data{'total_pages'}++
1298         if scalar(@buf) % $invoice_lines;
1299     }
1300
1301     #setup subroutine for the template
1302     $invoice_data{invoice_lines} = sub {
1303       my $lines = shift || scalar(@buf);
1304       map { 
1305         scalar(@buf)
1306           ? shift @buf
1307           : [ '', '' ];
1308       }
1309       ( 1 .. $lines );
1310     };
1311
1312     my $lines;
1313     my @collect;
1314     while (@buf) {
1315       push @collect, split("\n",
1316         $text_template->fill_in( HASH => \%invoice_data )
1317       );
1318       $invoice_data{'page'}++;
1319     }
1320     map "$_\n", @collect;
1321
1322   } else { # this is where we actually create the invoice
1323
1324     warn "filling in template for invoice ". $self->invnum. "\n"
1325       if $DEBUG;
1326     warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
1327       if $DEBUG > 1;
1328
1329     $text_template->fill_in(HASH => \%invoice_data);
1330   }
1331 }
1332
1333 sub notice_name { '('.shift->table.')'; }
1334
1335 sub template_conf { 'invoice_'; }
1336
1337 # helper routine for generating date ranges
1338 sub _prior_month30s {
1339   my $self = shift;
1340   my @ranges = (
1341    [ 1,       2592000 ], # 0-30 days ago
1342    [ 2592000, 5184000 ], # 30-60 days ago
1343    [ 5184000, 7776000 ], # 60-90 days ago
1344    [ 7776000, 0       ], # 90+   days ago
1345   );
1346
1347   map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
1348           $_->[1] ? $self->_date - $_->[1] - 1 : '',
1349       ] }
1350   @ranges;
1351 }
1352
1353 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
1354
1355 Returns an postscript invoice, as a scalar.
1356
1357 Options can be passed as a hashref (recommended) or as a list of time, template
1358 and then any key/value pairs for any other options.
1359
1360 I<time> an optional value used to control the printing of overdue messages.  The
1361 default is now.  It isn't the date of the invoice; that's the `_date' field.
1362 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1363 L<Time::Local> and L<Date::Parse> for conversion functions.
1364
1365 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1366
1367 =cut
1368
1369 sub print_ps {
1370   my $self = shift;
1371
1372   my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1373   my $ps = generate_ps($file);
1374   unlink($logofile);
1375   unlink($barcodefile) if $barcodefile;
1376
1377   $ps;
1378 }
1379
1380 =item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
1381
1382 Returns an PDF invoice, as a scalar.
1383
1384 Options can be passed as a hashref (recommended) or as a list of time, template
1385 and then any key/value pairs for any other options.
1386
1387 I<time> an optional value used to control the printing of overdue messages.  The
1388 default is now.  It isn't the date of the invoice; that's the `_date' field.
1389 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1390 L<Time::Local> and L<Date::Parse> for conversion functions.
1391
1392 I<template>, if specified, is the name of a suffix for alternate invoices.
1393
1394 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1395
1396 =cut
1397
1398 sub print_pdf {
1399   my $self = shift;
1400
1401   my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1402   my $pdf = generate_pdf($file);
1403   unlink($logofile);
1404   unlink($barcodefile) if $barcodefile;
1405
1406   $pdf;
1407 }
1408
1409 =item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
1410
1411 Returns an HTML invoice, as a scalar.
1412
1413 I<time> an optional value used to control the printing of overdue messages.  The
1414 default is now.  It isn't the date of the invoice; that's the `_date' field.
1415 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1416 L<Time::Local> and L<Date::Parse> for conversion functions.
1417
1418 I<template>, if specified, is the name of a suffix for alternate invoices.
1419
1420 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1421
1422 I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
1423 when emailing the invoice as part of a multipart/related MIME email.
1424
1425 =cut
1426
1427 sub print_html {
1428   my $self = shift;
1429   my %params;
1430   if ( ref($_[0]) ) {
1431     %params = %{ shift() }; 
1432   }else{
1433     $params{'time'} = shift;
1434     $params{'template'} = shift;
1435     $params{'cid'} = shift;
1436   }
1437
1438   $params{'format'} = 'html';
1439   
1440   $self->print_generic( %params );
1441 }
1442
1443 # quick subroutine for print_latex
1444 #
1445 # There are ten characters that LaTeX treats as special characters, which
1446 # means that they do not simply typeset themselves: 
1447 #      # $ % & ~ _ ^ \ { }
1448 #
1449 # TeX ignores blanks following an escaped character; if you want a blank (as
1450 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
1451
1452 sub _latex_escape {
1453   my $value = shift;
1454   $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
1455   $value =~ s/([<>])/\$$1\$/g;
1456   $value;
1457 }
1458
1459 sub _html_escape {
1460   my $value = shift;
1461   encode_entities($value);
1462   $value;
1463 }
1464
1465 sub _html_escape_nbsp {
1466   my $value = _html_escape(shift);
1467   $value =~ s/ +/&nbsp;/g;
1468   $value;
1469 }
1470
1471 #utility methods for print_*
1472
1473 sub _translate_old_latex_format {
1474   warn "_translate_old_latex_format called\n"
1475     if $DEBUG; 
1476
1477   my @template = ();
1478   while ( @_ ) {
1479     my $line = shift;
1480   
1481     if ( $line =~ /^%%Detail\s*$/ ) {
1482   
1483       push @template, q![@--!,
1484                       q!  foreach my $_tr_line (@detail_items) {!,
1485                       q!    if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
1486                       q!      $_tr_line->{'description'} .= !, 
1487                       q!        "\\tabularnewline\n~~".!,
1488                       q!        join( "\\tabularnewline\n~~",!,
1489                       q!          @{$_tr_line->{'ext_description'}}!,
1490                       q!        );!,
1491                       q!    }!;
1492
1493       while ( ( my $line_item_line = shift )
1494               !~ /^%%EndDetail\s*$/                            ) {
1495         $line_item_line =~ s/'/\\'/g;    # nice LTS
1496         $line_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
1497         $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1498         push @template, "    \$OUT .= '$line_item_line';";
1499       }
1500
1501       push @template, '}',
1502                       '--@]';
1503       #' doh, gvim
1504     } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1505
1506       push @template, '[@--',
1507                       '  foreach my $_tr_line (@total_items) {';
1508
1509       while ( ( my $total_item_line = shift )
1510               !~ /^%%EndTotalDetails\s*$/                      ) {
1511         $total_item_line =~ s/'/\\'/g;    # nice LTS
1512         $total_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
1513         $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1514         push @template, "    \$OUT .= '$total_item_line';";
1515       }
1516
1517       push @template, '}',
1518                       '--@]';
1519
1520     } else {
1521       $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
1522       push @template, $line;  
1523     }
1524   
1525   }
1526
1527   if ($DEBUG) {
1528     warn "$_\n" foreach @template;
1529   }
1530
1531   (@template);
1532 }
1533
1534 sub terms {
1535   my $self = shift;
1536   my $conf = $self->conf;
1537
1538   #check for an invoice-specific override
1539   return $self->invoice_terms if $self->invoice_terms;
1540   
1541   #check for a customer- specific override
1542   my $cust_main = $self->cust_main;
1543   return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
1544
1545   #use configured default
1546   $conf->config('invoice_default_terms') || '';
1547 }
1548
1549 sub due_date {
1550   my $self = shift;
1551   my $duedate = '';
1552   if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
1553     $duedate = $self->_date() + ( $1 * 86400 );
1554   }
1555   $duedate;
1556 }
1557
1558 sub due_date2str {
1559   my $self = shift;
1560   $self->due_date ? time2str(shift, $self->due_date) : '';
1561 }
1562
1563 sub balance_due_msg {
1564   my $self = shift;
1565   my $msg = $self->mt('Balance Due');
1566   return $msg unless $self->terms;
1567   if ( $self->due_date ) {
1568     $msg .= ' - ' . $self->mt('Please pay by'). ' '.
1569       $self->due_date2str($date_format);
1570   } elsif ( $self->terms ) {
1571     $msg .= ' - '. $self->terms;
1572   }
1573   $msg;
1574 }
1575
1576 sub balance_due_date {
1577   my $self = shift;
1578   my $conf = $self->conf;
1579   my $duedate = '';
1580   if (    $conf->exists('invoice_default_terms') 
1581        && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
1582     $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
1583   }
1584   $duedate;
1585 }
1586
1587 sub credit_balance_msg { 
1588   my $self = shift;
1589   $self->mt('Credit Balance Remaining')
1590 }
1591
1592 =item _date_pretty
1593
1594 Returns a string with the date, for example: "3/20/2008"
1595
1596 =cut
1597
1598 sub _date_pretty {
1599   my $self = shift;
1600   time2str($date_format, $self->_date);
1601 }
1602
1603 =item _items_sections LATE SUMMARYPAGE ESCAPE EXTRA_SECTIONS FORMAT
1604
1605 Generate section information for all items appearing on this invoice.
1606 This will only be called for multi-section invoices.
1607
1608 For each line item (L<FS::cust_bill_pkg> record), this will fetch all 
1609 related display records (L<FS::cust_bill_pkg_display>) and organize 
1610 them into two groups ("early" and "late" according to whether they come 
1611 before or after the total), then into sections.  A subtotal is calculated 
1612 for each section.
1613
1614 Section descriptions are returned in sort weight order.  Each consists 
1615 of a hash containing:
1616
1617 description: the package category name, escaped
1618 subtotal: the total charges in that section
1619 tax_section: a flag indicating that the section contains only tax charges
1620 summarized: same as tax_section, for some reason
1621 sort_weight: the package category's sort weight
1622
1623 If 'condense' is set on the display record, it also contains everything 
1624 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
1625 coderefs to generate parts of the invoice.  This is not advised.
1626
1627 Arguments:
1628
1629 LATE: an arrayref to push the "late" section hashes onto.  The "early"
1630 group is simply returned from the method.
1631
1632 SUMMARYPAGE: a flag indicating whether this is a summary-format invoice.
1633 Turning this on has the following effects:
1634 - Ignores display items with the 'summary' flag.
1635 - Combines all items into the "early" group.
1636 - Creates sections for all non-disabled package categories, even if they 
1637 have no charges on this invoice, as well as a section with no name.
1638
1639 ESCAPE: an escape function to use for section titles.
1640
1641 EXTRA_SECTIONS: an arrayref of additional sections to return after the 
1642 sorted list.  If there are any of these, section subtotals exclude 
1643 usage charges.
1644
1645 FORMAT: 'latex', 'html', or 'template' (i.e. text).  Not used, but 
1646 passed through to C<_condense_section()>.
1647
1648 =cut
1649
1650 use vars qw(%pkg_category_cache);
1651 sub _items_sections {
1652   my $self = shift;
1653   my $late = shift;
1654   my $summarypage = shift;
1655   my $escape = shift;
1656   my $extra_sections = shift;
1657   my $format = shift;
1658
1659   my %subtotal = ();
1660   my %late_subtotal = ();
1661   my %not_tax = ();
1662
1663   foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
1664   {
1665
1666       my $usage = $cust_bill_pkg->usage;
1667
1668       foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
1669         next if ( $display->summary && $summarypage );
1670
1671         my $section = $display->section;
1672         my $type    = $display->type;
1673
1674         $not_tax{$section} = 1
1675           unless $cust_bill_pkg->pkgnum == 0;
1676
1677         # there's actually a very important piece of logic buried in here:
1678         # incrementing $late_subtotal{$section} CREATES 
1679         # $late_subtotal{$section}.  keys(%late_subtotal) is later used 
1680         # to define the list of late sections, and likewise keys(%subtotal).
1681         # When _items_cust_bill_pkg is called to generate line items for 
1682         # real, it will be called with 'section' => $section for each 
1683         # of these.
1684         if ( $display->post_total && !$summarypage ) {
1685           if (! $type || $type eq 'S') {
1686             $late_subtotal{$section} += $cust_bill_pkg->setup
1687               if $cust_bill_pkg->setup != 0
1688               || $cust_bill_pkg->setup_show_zero;
1689           }
1690
1691           if (! $type) {
1692             $late_subtotal{$section} += $cust_bill_pkg->recur
1693               if $cust_bill_pkg->recur != 0
1694               || $cust_bill_pkg->recur_show_zero;
1695           }
1696
1697           if ($type && $type eq 'R') {
1698             $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
1699               if $cust_bill_pkg->recur != 0
1700               || $cust_bill_pkg->recur_show_zero;
1701           }
1702           
1703           if ($type && $type eq 'U') {
1704             $late_subtotal{$section} += $usage
1705               unless scalar(@$extra_sections);
1706           }
1707
1708         } else {
1709
1710           next if $cust_bill_pkg->pkgnum == 0 && ! $section;
1711
1712           if (! $type || $type eq 'S') {
1713             $subtotal{$section} += $cust_bill_pkg->setup
1714               if $cust_bill_pkg->setup != 0
1715               || $cust_bill_pkg->setup_show_zero;
1716           }
1717
1718           if (! $type) {
1719             $subtotal{$section} += $cust_bill_pkg->recur
1720               if $cust_bill_pkg->recur != 0
1721               || $cust_bill_pkg->recur_show_zero;
1722           }
1723
1724           if ($type && $type eq 'R') {
1725             $subtotal{$section} += $cust_bill_pkg->recur - $usage
1726               if $cust_bill_pkg->recur != 0
1727               || $cust_bill_pkg->recur_show_zero;
1728           }
1729           
1730           if ($type && $type eq 'U') {
1731             $subtotal{$section} += $usage
1732               unless scalar(@$extra_sections);
1733           }
1734
1735         }
1736
1737       }
1738
1739   }
1740
1741   %pkg_category_cache = ();
1742
1743   push @$late, map { { 'description' => &{$escape}($_),
1744                        'subtotal'    => $late_subtotal{$_},
1745                        'post_total'  => 1,
1746                        'sort_weight' => ( _pkg_category($_)
1747                                             ? _pkg_category($_)->weight
1748                                             : 0
1749                                        ),
1750                        ((_pkg_category($_) && _pkg_category($_)->condense)
1751                                            ? $self->_condense_section($format)
1752                                            : ()
1753                        ),
1754                    } }
1755                  sort _sectionsort keys %late_subtotal;
1756
1757   my @sections;
1758   if ( $summarypage ) {
1759     @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
1760                 map { $_->categoryname } qsearch('pkg_category', {});
1761     push @sections, '' if exists($subtotal{''});
1762   } else {
1763     @sections = keys %subtotal;
1764   }
1765
1766   my @early = map { { 'description' => &{$escape}($_),
1767                       'subtotal'    => $subtotal{$_},
1768                       'summarized'  => $not_tax{$_} ? '' : 'Y',
1769                       'tax_section' => $not_tax{$_} ? '' : 'Y',
1770                       'sort_weight' => ( _pkg_category($_)
1771                                            ? _pkg_category($_)->weight
1772                                            : 0
1773                                        ),
1774                        ((_pkg_category($_) && _pkg_category($_)->condense)
1775                                            ? $self->_condense_section($format)
1776                                            : ()
1777                        ),
1778                     }
1779                   } @sections;
1780   push @early, @$extra_sections if $extra_sections;
1781
1782   sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
1783
1784 }
1785
1786 #helper subs for above
1787
1788 sub _sectionsort {
1789   _pkg_category($a)->weight <=> _pkg_category($b)->weight;
1790 }
1791
1792 sub _pkg_category {
1793   my $categoryname = shift;
1794   $pkg_category_cache{$categoryname} ||=
1795     qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
1796 }
1797
1798 my %condensed_format = (
1799   'label' => [ qw( Description Qty Amount ) ],
1800   'fields' => [
1801                 sub { shift->{description} },
1802                 sub { shift->{quantity} },
1803                 sub { my($href, %opt) = @_;
1804                       ($opt{dollar} || ''). $href->{amount};
1805                     },
1806               ],
1807   'align'  => [ qw( l r r ) ],
1808   'span'   => [ qw( 5 1 1 ) ],            # unitprices?
1809   'width'  => [ qw( 10.7cm 1.4cm 1.6cm ) ],   # don't like this
1810 );
1811
1812 sub _condense_section {
1813   my ( $self, $format ) = ( shift, shift );
1814   ( 'condensed' => 1,
1815     map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
1816       qw( description_generator
1817           header_generator
1818           total_generator
1819           total_line_generator
1820         )
1821   );
1822 }
1823
1824 sub _condensed_generator_defaults {
1825   my ( $self, $format ) = ( shift, shift );
1826   return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
1827 }
1828
1829 my %html_align = (
1830   'c' => 'center',
1831   'l' => 'left',
1832   'r' => 'right',
1833 );
1834
1835 sub _condensed_header_generator {
1836   my ( $self, $format ) = ( shift, shift );
1837
1838   my ( $f, $prefix, $suffix, $separator, $column ) =
1839     _condensed_generator_defaults($format);
1840
1841   if ($format eq 'latex') {
1842     $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
1843     $suffix = "\\\\\n\\hline";
1844     $separator = "&\n";
1845     $column =
1846       sub { my ($d,$a,$s,$w) = @_;
1847             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
1848           };
1849   } elsif ( $format eq 'html' ) {
1850     $prefix = '<th></th>';
1851     $suffix = '';
1852     $separator = '';
1853     $column =
1854       sub { my ($d,$a,$s,$w) = @_;
1855             return qq!<th align="$html_align{$a}">$d</th>!;
1856       };
1857   }
1858
1859   sub {
1860     my @args = @_;
1861     my @result = ();
1862
1863     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
1864       push @result,
1865         &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
1866     }
1867
1868     $prefix. join($separator, @result). $suffix;
1869   };
1870
1871 }
1872
1873 sub _condensed_description_generator {
1874   my ( $self, $format ) = ( shift, shift );
1875
1876   my ( $f, $prefix, $suffix, $separator, $column ) =
1877     _condensed_generator_defaults($format);
1878
1879   my $money_char = '$';
1880   if ($format eq 'latex') {
1881     $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
1882     $suffix = '\\\\';
1883     $separator = " & \n";
1884     $column =
1885       sub { my ($d,$a,$s,$w) = @_;
1886             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
1887           };
1888     $money_char = '\\dollar';
1889   }elsif ( $format eq 'html' ) {
1890     $prefix = '"><td align="center"></td>';
1891     $suffix = '';
1892     $separator = '';
1893     $column =
1894       sub { my ($d,$a,$s,$w) = @_;
1895             return qq!<td align="$html_align{$a}">$d</td>!;
1896       };
1897     #$money_char = $conf->config('money_char') || '$';
1898     $money_char = '';  # this is madness
1899   }
1900
1901   sub {
1902     #my @args = @_;
1903     my $href = shift;
1904     my @result = ();
1905
1906     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
1907       my $dollar = '';
1908       $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
1909       push @result,
1910         &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
1911                     map { $f->{$_}->[$i] } qw(align span width)
1912                   );
1913     }
1914
1915     $prefix. join( $separator, @result ). $suffix;
1916   };
1917
1918 }
1919
1920 sub _condensed_total_generator {
1921   my ( $self, $format ) = ( shift, shift );
1922
1923   my ( $f, $prefix, $suffix, $separator, $column ) =
1924     _condensed_generator_defaults($format);
1925   my $style = '';
1926
1927   if ($format eq 'latex') {
1928     $prefix = "& ";
1929     $suffix = "\\\\\n";
1930     $separator = " & \n";
1931     $column =
1932       sub { my ($d,$a,$s,$w) = @_;
1933             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
1934           };
1935   }elsif ( $format eq 'html' ) {
1936     $prefix = '';
1937     $suffix = '';
1938     $separator = '';
1939     $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
1940     $column =
1941       sub { my ($d,$a,$s,$w) = @_;
1942             return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
1943       };
1944   }
1945
1946
1947   sub {
1948     my @args = @_;
1949     my @result = ();
1950
1951     #  my $r = &{$f->{fields}->[$i]}(@args);
1952     #  $r .= ' Total' unless $i;
1953
1954     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
1955       push @result,
1956         &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
1957                     map { $f->{$_}->[$i] } qw(align span width)
1958                   );
1959     }
1960
1961     $prefix. join( $separator, @result ). $suffix;
1962   };
1963
1964 }
1965
1966 =item total_line_generator FORMAT
1967
1968 Returns a coderef used for generation of invoice total line items for this
1969 usage_class.  FORMAT is either html or latex
1970
1971 =cut
1972
1973 # should not be used: will have issues with hash element names (description vs
1974 # total_item and amount vs total_amount -- another array of functions?
1975
1976 sub _condensed_total_line_generator {
1977   my ( $self, $format ) = ( shift, shift );
1978
1979   my ( $f, $prefix, $suffix, $separator, $column ) =
1980     _condensed_generator_defaults($format);
1981   my $style = '';
1982
1983   if ($format eq 'latex') {
1984     $prefix = "& ";
1985     $suffix = "\\\\\n";
1986     $separator = " & \n";
1987     $column =
1988       sub { my ($d,$a,$s,$w) = @_;
1989             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
1990           };
1991   }elsif ( $format eq 'html' ) {
1992     $prefix = '';
1993     $suffix = '';
1994     $separator = '';
1995     $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
1996     $column =
1997       sub { my ($d,$a,$s,$w) = @_;
1998             return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
1999       };
2000   }
2001
2002
2003   sub {
2004     my @args = @_;
2005     my @result = ();
2006
2007     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
2008       push @result,
2009         &{$column}( &{$f->{fields}->[$i]}(@args),
2010                     map { $f->{$_}->[$i] } qw(align span width)
2011                   );
2012     }
2013
2014     $prefix. join( $separator, @result ). $suffix;
2015   };
2016
2017 }
2018
2019 #  sub _items { # seems to be unused
2020 #    my $self = shift;
2021 #  
2022 #    #my @display = scalar(@_)
2023 #    #              ? @_
2024 #    #              : qw( _items_previous _items_pkg );
2025 #    #              #: qw( _items_pkg );
2026 #    #              #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
2027 #    my @display = qw( _items_previous _items_pkg );
2028 #  
2029 #    my @b = ();
2030 #    foreach my $display ( @display ) {
2031 #      push @b, $self->$display(@_);
2032 #    }
2033 #    @b;
2034 #  }
2035
2036 =item _items_pkg [ OPTIONS ]
2037
2038 Return line item hashes for each package item on this invoice. Nearly 
2039 equivalent to 
2040
2041 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
2042
2043 The only OPTIONS accepted is 'section', which may point to a hashref 
2044 with a key named 'condensed', which may have a true value.  If it 
2045 does, this method tries to merge identical items into items with 
2046 'quantity' equal to the number of items (not the sum of their 
2047 separate quantities, for some reason).
2048
2049 =cut
2050
2051 sub _items_pkg {
2052   my $self = shift;
2053   my %options = @_;
2054
2055   warn "$me _items_pkg searching for all package line items\n"
2056     if $DEBUG > 1;
2057
2058   my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg;
2059
2060   warn "$me _items_pkg filtering line items\n"
2061     if $DEBUG > 1;
2062   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2063
2064   if ($options{section} && $options{section}->{condensed}) {
2065
2066     warn "$me _items_pkg condensing section\n"
2067       if $DEBUG > 1;
2068
2069     my %itemshash = ();
2070     local $Storable::canonical = 1;
2071     foreach ( @items ) {
2072       my $item = { %$_ };
2073       delete $item->{ref};
2074       delete $item->{ext_description};
2075       my $key = freeze($item);
2076       $itemshash{$key} ||= 0;
2077       $itemshash{$key} ++; # += $item->{quantity};
2078     }
2079     @items = sort { $a->{description} cmp $b->{description} }
2080              map { my $i = thaw($_);
2081                    $i->{quantity} = $itemshash{$_};
2082                    $i->{amount} =
2083                      sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
2084                    $i;
2085                  }
2086              keys %itemshash;
2087   }
2088
2089   warn "$me _items_pkg returning ". scalar(@items). " items\n"
2090     if $DEBUG > 1;
2091
2092   @items;
2093 }
2094
2095 sub _taxsort {
2096   return 0 unless $a->itemdesc cmp $b->itemdesc;
2097   return -1 if $b->itemdesc eq 'Tax';
2098   return 1 if $a->itemdesc eq 'Tax';
2099   return -1 if $b->itemdesc eq 'Other surcharges';
2100   return 1 if $a->itemdesc eq 'Other surcharges';
2101   $a->itemdesc cmp $b->itemdesc;
2102 }
2103
2104 sub _items_tax {
2105   my $self = shift;
2106   my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
2107   $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2108 }
2109
2110 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
2111
2112 Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
2113 list of hashrefs describing the line items they generate on the invoice.
2114
2115 OPTIONS may include:
2116
2117 format: the invoice format.
2118
2119 escape_function: the function used to escape strings.
2120
2121 DEPRECATED? (expensive, mostly unused?)
2122 format_function: the function used to format CDRs.
2123
2124 section: a hashref containing 'description'; if this is present, 
2125 cust_bill_pkg_display records not belonging to this section are 
2126 ignored.
2127
2128 multisection: a flag indicating that this is a multisection invoice,
2129 which does something complicated.
2130
2131 Returns a list of hashrefs, each of which may contain:
2132
2133 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
2134 ext_description, which is an arrayref of detail lines to show below 
2135 the package line.
2136
2137 =cut
2138
2139 sub _items_cust_bill_pkg {
2140   my $self = shift;
2141   my $conf = $self->conf;
2142   my $cust_bill_pkgs = shift;
2143   my %opt = @_;
2144
2145   my $format = $opt{format} || '';
2146   my $escape_function = $opt{escape_function} || sub { shift };
2147   my $format_function = $opt{format_function} || '';
2148   my $no_usage = $opt{no_usage} || '';
2149   my $unsquelched = $opt{unsquelched} || ''; #unused
2150   my $section = $opt{section}->{description} if $opt{section};
2151   my $summary_page = $opt{summary_page} || ''; #unused
2152   my $multisection = $opt{multisection} || '';
2153   my $discount_show_always = 0;
2154
2155   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2156
2157   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
2158                                    # and location labels
2159
2160   my @b = ();
2161   my ($s, $r, $u) = ( undef, undef, undef );
2162   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
2163   {
2164
2165     foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2166       if ( $_ && !$cust_bill_pkg->hidden ) {
2167         $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
2168         $_->{amount}      =~ s/^\-0\.00$/0.00/;
2169         $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2170         push @b, { %$_ }
2171           if $_->{amount} != 0
2172           || $discount_show_always
2173           || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2174           || (   $_->{_is_setup} && $_->{setup_show_zero} )
2175         ;
2176         $_ = undef;
2177       }
2178     }
2179
2180     my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
2181                                   ? $cust_bill_pkg->cust_bill_pkg_display
2182                                   : ( $cust_bill_pkg );
2183
2184     warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
2185          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
2186       if $DEBUG > 1;
2187
2188     foreach my $display ( grep { defined($section)
2189                             ? $_->section eq $section
2190                             : 1
2191                           }
2192                           grep { !$_->summary || $multisection }
2193                           @cust_bill_pkg_display
2194                         )
2195       {
2196
2197       warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
2198            $display->billpkgdisplaynum. "\n"
2199         if $DEBUG > 1;
2200
2201       my $type = $display->type;
2202
2203       my $desc = $cust_bill_pkg->desc;
2204       $desc = substr($desc, 0, $maxlength). '...'
2205         if $format eq 'latex' && length($desc) > $maxlength;
2206
2207       my %details_opt = ( 'format'          => $format,
2208                           'escape_function' => $escape_function,
2209                           'format_function' => $format_function,
2210                           'no_usage'        => $opt{'no_usage'},
2211                         );
2212
2213       if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
2214
2215         warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
2216           if $DEBUG > 1;
2217
2218         if ( $cust_bill_pkg->setup != 0 ) {
2219           my $description = $desc;
2220           $description .= ' Setup'
2221             if $cust_bill_pkg->recur != 0
2222             || $discount_show_always
2223             || $cust_bill_pkg->recur_show_zero;
2224           push @b, {
2225             'description' => $description,
2226             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
2227           };
2228         }
2229         if ( $cust_bill_pkg->recur != 0 ) {
2230           push @b, {
2231             'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
2232             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
2233           };
2234         }
2235
2236       } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
2237
2238         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
2239           if $DEBUG > 1;
2240  
2241         my $cust_pkg = $cust_bill_pkg->cust_pkg;
2242
2243         # which pkgpart to show for display purposes?
2244         my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
2245
2246         # start/end dates for invoice formats that do nonstandard 
2247         # things with them
2248         my %item_dates = ();
2249         %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
2250           unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
2251
2252         if (    (!$type || $type eq 'S')
2253              && (    $cust_bill_pkg->setup != 0
2254                   || $cust_bill_pkg->setup_show_zero
2255                 )
2256            )
2257          {
2258
2259           warn "$me _items_cust_bill_pkg adding setup\n"
2260             if $DEBUG > 1;
2261
2262           my $description = $desc;
2263           $description .= ' Setup'
2264             if $cust_bill_pkg->recur != 0
2265             || $discount_show_always
2266             || $cust_bill_pkg->recur_show_zero;
2267
2268           my @d = ();
2269           unless ( $cust_pkg->part_pkg->hide_svc_detail
2270                 || $cust_bill_pkg->hidden )
2271           {
2272
2273             push @d, map &{$escape_function}($_),
2274                          $cust_pkg->h_labels_short($self->_date, undef, 'I')
2275               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2276
2277             if ( ! $cust_pkg->locationnum or
2278                    $cust_pkg->locationnum != $cust_main->ship_locationnum  ) {
2279               my $loc = $cust_pkg->location_label;
2280               $loc = substr($loc, 0, $maxlength). '...'
2281                 if $format eq 'latex' && length($loc) > $maxlength;
2282               push @d, &{$escape_function}($loc);
2283             }
2284
2285           } #unless hiding service details
2286
2287           push @d, $cust_bill_pkg->details(%details_opt)
2288             if $cust_bill_pkg->recur == 0;
2289
2290           if ( $cust_bill_pkg->hidden ) {
2291             $s->{amount}      += $cust_bill_pkg->setup;
2292             $s->{unit_amount} += $cust_bill_pkg->unitsetup;
2293             push @{ $s->{ext_description} }, @d;
2294           } else {
2295             $s = {
2296               _is_setup       => 1,
2297               description     => $description,
2298               pkgpart         => $pkgpart,
2299               pkgnum          => $cust_bill_pkg->pkgnum,
2300               amount          => $cust_bill_pkg->setup,
2301               setup_show_zero => $cust_bill_pkg->setup_show_zero,
2302               unit_amount     => $cust_bill_pkg->unitsetup,
2303               quantity        => $cust_bill_pkg->quantity,
2304               ext_description => \@d,
2305             };
2306           };
2307
2308         }
2309
2310         if (    ( !$type || $type eq 'R' || $type eq 'U' )
2311              && (
2312                      $cust_bill_pkg->recur != 0
2313                   || $cust_bill_pkg->setup == 0
2314                   || $discount_show_always
2315                   || $cust_bill_pkg->recur_show_zero
2316                 )
2317            )
2318         {
2319
2320           warn "$me _items_cust_bill_pkg adding recur/usage\n"
2321             if $DEBUG > 1;
2322
2323           my $is_summary = $display->summary;
2324           my $description = ($is_summary && $type && $type eq 'U')
2325                             ? "Usage charges" : $desc;
2326
2327           my $part_pkg = $cust_pkg->part_pkg;
2328
2329           #pry be a bit more efficient to look some of this conf stuff up
2330           # outside the loop
2331           unless (
2332             $conf->exists('disable_line_item_date_ranges')
2333               || $part_pkg->option('disable_line_item_date_ranges',1)
2334               || ! $cust_bill_pkg->sdate
2335               || ! $cust_bill_pkg->edate
2336           ) {
2337             my $time_period;
2338             my $date_style = '';
2339             $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monhtly',
2340                                          $cust_main->agentnum
2341                                        )
2342               if $part_pkg && $part_pkg->freq !~ /^1m?$/;
2343             $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
2344                                             $cust_main->agentnum
2345                                          );
2346             if ( defined($date_style) && $date_style eq 'month_of' ) {
2347               $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
2348             } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
2349               my $desc = $conf->config( 'cust_bill-line_item-date_description',
2350                                          $cust_main->agentnum
2351                                       );
2352               $desc .= ' ' unless $desc =~ /\s$/;
2353               $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
2354             } else {
2355               $time_period =      time2str($date_format, $cust_bill_pkg->sdate).
2356                            " - ". time2str($date_format, $cust_bill_pkg->edate);
2357             }
2358             $description .= " ($time_period)";
2359           }
2360
2361           my @d = ();
2362           my @seconds = (); # for display of usage info
2363
2364           #at least until cust_bill_pkg has "past" ranges in addition to
2365           #the "future" sdate/edate ones... see #3032
2366           my @dates = ( $self->_date );
2367           my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
2368           push @dates, $prev->sdate if $prev;
2369           push @dates, undef if !$prev;
2370
2371           unless ( $part_pkg->hide_svc_detail
2372                 || $cust_bill_pkg->itemdesc
2373                 || $cust_bill_pkg->hidden
2374                 || $is_summary && $type && $type eq 'U'
2375               )
2376           {
2377
2378             warn "$me _items_cust_bill_pkg adding service details\n"
2379               if $DEBUG > 1;
2380
2381             push @d, map &{$escape_function}($_),
2382                          $cust_pkg->h_labels_short(@dates, 'I')
2383                                                    #$cust_bill_pkg->edate,
2384                                                    #$cust_bill_pkg->sdate)
2385               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2386
2387             warn "$me _items_cust_bill_pkg done adding service details\n"
2388               if $DEBUG > 1;
2389
2390             if ( $cust_pkg->locationnum != $cust_main->ship_locationnum ) {
2391               my $loc = $cust_pkg->location_label;
2392               $loc = substr($loc, 0, $maxlength). '...'
2393                 if $format eq 'latex' && length($loc) > $maxlength;
2394               push @d, &{$escape_function}($loc);
2395             }
2396
2397             # Display of seconds_since_sqlradacct:
2398             # On the invoice, when processing @detail_items, look for a field
2399             # named 'seconds'.  This will contain total seconds for each 
2400             # service, in the same order as @ext_description.  For services 
2401             # that don't support this it will show undef.
2402             if ( $conf->exists('svc_acct-usage_seconds') 
2403                  and ! $cust_bill_pkg->pkgpart_override ) {
2404               foreach my $cust_svc ( 
2405                   $cust_pkg->h_cust_svc(@dates, 'I') 
2406                 ) {
2407
2408                 # eval because not having any part_export_usage exports 
2409                 # is a fatal error, last_bill/_date because that's how 
2410                 # sqlradius_hour billing does it
2411                 my $sec = eval {
2412                   $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
2413                 };
2414                 push @seconds, $sec;
2415               }
2416             } #if svc_acct-usage_seconds
2417
2418           }
2419
2420           unless ( $is_summary ) {
2421             warn "$me _items_cust_bill_pkg adding details\n"
2422               if $DEBUG > 1;
2423
2424             #instead of omitting details entirely in this case (unwanted side
2425             # effects), just omit CDRs
2426             $details_opt{'no_usage'} = 1
2427               if $type && $type eq 'R';
2428
2429             push @d, $cust_bill_pkg->details(%details_opt);
2430           }
2431
2432           warn "$me _items_cust_bill_pkg calculating amount\n"
2433             if $DEBUG > 1;
2434   
2435           my $amount = 0;
2436           if (!$type) {
2437             $amount = $cust_bill_pkg->recur;
2438           } elsif ($type eq 'R') {
2439             $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
2440           } elsif ($type eq 'U') {
2441             $amount = $cust_bill_pkg->usage;
2442           }
2443   
2444           my $unit_amount =
2445             ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
2446                                               : $amount;
2447
2448           if ( !$type || $type eq 'R' ) {
2449
2450             warn "$me _items_cust_bill_pkg adding recur\n"
2451               if $DEBUG > 1;
2452
2453             if ( $cust_bill_pkg->hidden ) {
2454               $r->{amount}      += $amount;
2455               $r->{unit_amount} += $unit_amount;
2456               push @{ $r->{ext_description} }, @d;
2457             } else {
2458               $r = {
2459                 description     => $description,
2460                 pkgpart         => $pkgpart,
2461                 pkgnum          => $cust_bill_pkg->pkgnum,
2462                 amount          => $amount,
2463                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2464                 unit_amount     => $unit_amount,
2465                 quantity        => $cust_bill_pkg->quantity,
2466                 %item_dates,
2467                 ext_description => \@d,
2468               };
2469               $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
2470             }
2471
2472           } else {  # $type eq 'U'
2473
2474             warn "$me _items_cust_bill_pkg adding usage\n"
2475               if $DEBUG > 1;
2476
2477             if ( $cust_bill_pkg->hidden ) {
2478               $u->{amount}      += $amount;
2479               $u->{unit_amount} += $unit_amount,
2480               push @{ $u->{ext_description} }, @d;
2481             } else {
2482               $u = {
2483                 description     => $description,
2484                 pkgpart         => $pkgpart,
2485                 pkgnum          => $cust_bill_pkg->pkgnum,
2486                 amount          => $amount,
2487                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2488                 unit_amount     => $unit_amount,
2489                 quantity        => $cust_bill_pkg->quantity,
2490                 %item_dates,
2491                 ext_description => \@d,
2492               };
2493             }
2494           }
2495
2496         } # recurring or usage with recurring charge
2497
2498       } else { #pkgnum tax or one-shot line item (??)
2499
2500         warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
2501           if $DEBUG > 1;
2502
2503         if ( $cust_bill_pkg->setup != 0 ) {
2504           push @b, {
2505             'description' => $desc,
2506             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
2507           };
2508         }
2509         if ( $cust_bill_pkg->recur != 0 ) {
2510           push @b, {
2511             'description' => "$desc (".
2512                              time2str($date_format, $cust_bill_pkg->sdate). ' - '.
2513                              time2str($date_format, $cust_bill_pkg->edate). ')',
2514             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
2515           };
2516         }
2517
2518       }
2519
2520     }
2521
2522     $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
2523                                 && $conf->exists('discount-show-always'));
2524
2525   }
2526
2527   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2528     if ( $_  ) {
2529       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
2530       $_->{amount}      =~ s/^\-0\.00$/0.00/;
2531       $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2532       push @b, { %$_ }
2533         if $_->{amount} != 0
2534         || $discount_show_always
2535         || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2536         || (   $_->{_is_setup} && $_->{setup_show_zero} )
2537     }
2538   }
2539
2540   warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
2541     if $DEBUG > 1;
2542
2543   @b;
2544
2545 }
2546
2547 =item _items_discounts_avail
2548
2549 Returns an array of line item hashrefs representing available term discounts
2550 for this invoice.  This makes the same assumptions that apply to term 
2551 discounts in general: that the package is billed monthly, at a flat rate, 
2552 with no usage charges.  A prorated first month will be handled, as will 
2553 a setup fee if the discount is allowed to apply to setup fees.
2554
2555 =cut
2556
2557 sub _items_discounts_avail {
2558   my $self = shift;
2559
2560   #maybe move this method from cust_bill when quotations support discount_plans 
2561   return () unless $self->can('discount_plans');
2562   my %plans = $self->discount_plans;
2563
2564   my $list_pkgnums = 0; # if any packages are not eligible for all discounts
2565   $list_pkgnums = grep { $_->list_pkgnums } values %plans;
2566
2567   map {
2568     my $months = $_;
2569     my $plan = $plans{$months};
2570
2571     my $term_total = sprintf('%.2f', $plan->discounted_total);
2572     my $percent = sprintf('%.0f', 
2573                           100 * (1 - $term_total / $plan->base_total) );
2574     my $permonth = sprintf('%.2f', $term_total / $months);
2575     my $detail = $self->mt('discount on item'). ' '.
2576                  join(', ', map { "#$_" } $plan->pkgnums)
2577       if $list_pkgnums;
2578
2579     # discounts for non-integer months don't work anyway
2580     $months = sprintf("%d", $months);
2581
2582     +{
2583       description => $self->mt('Save [_1]% by paying for [_2] months',
2584                                 $percent, $months),
2585       amount      => $self->mt('[_1] ([_2] per month)', 
2586                                 $term_total, $money_char.$permonth),
2587       ext_description => ($detail || ''),
2588     }
2589   } #map
2590   sort { $b <=> $a } keys %plans;
2591
2592 }
2593
2594 1;