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