use part_pkg_msgcat on invoice line items, RT#19906
[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   my $locale = $cust_main->locale;
2185
2186   my @b = ();
2187   my ($s, $r, $u) = ( undef, undef, undef );
2188   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
2189   {
2190
2191     foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2192       if ( $_ && !$cust_bill_pkg->hidden ) {
2193         $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
2194         $_->{amount}      =~ s/^\-0\.00$/0.00/;
2195         $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2196         push @b, { %$_ }
2197           if $_->{amount} != 0
2198           || $discount_show_always
2199           || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2200           || (   $_->{_is_setup} && $_->{setup_show_zero} )
2201         ;
2202         $_ = undef;
2203       }
2204     }
2205
2206     my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
2207                                   ? $cust_bill_pkg->cust_bill_pkg_display
2208                                   : ( $cust_bill_pkg );
2209
2210     warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
2211          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
2212       if $DEBUG > 1;
2213
2214     foreach my $display ( grep { defined($section)
2215                             ? $_->section eq $section
2216                             : 1
2217                           }
2218                           grep { !$_->summary || $multisection }
2219                           @cust_bill_pkg_display
2220                         )
2221       {
2222
2223       warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
2224            $display->billpkgdisplaynum. "\n"
2225         if $DEBUG > 1;
2226
2227       my $type = $display->type;
2228
2229       my $desc = $cust_bill_pkg->desc( $cust_main->locale );
2230       $desc = substr($desc, 0, $maxlength). '...'
2231         if $format eq 'latex' && length($desc) > $maxlength;
2232
2233       my %details_opt = ( 'format'          => $format,
2234                           'escape_function' => $escape_function,
2235                           'format_function' => $format_function,
2236                           'no_usage'        => $opt{'no_usage'},
2237                         );
2238
2239       if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
2240
2241         warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
2242           if $DEBUG > 1;
2243
2244         if ( $cust_bill_pkg->setup != 0 ) {
2245           my $description = $desc;
2246           $description .= ' Setup'
2247             if $cust_bill_pkg->recur != 0
2248             || $discount_show_always
2249             || $cust_bill_pkg->recur_show_zero;
2250           push @b, {
2251             'description' => $description,
2252             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
2253           };
2254         }
2255         if ( $cust_bill_pkg->recur != 0 ) {
2256           push @b, {
2257             'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
2258             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
2259           };
2260         }
2261
2262       } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
2263
2264         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
2265           if $DEBUG > 1;
2266  
2267         my $cust_pkg = $cust_bill_pkg->cust_pkg;
2268
2269         # which pkgpart to show for display purposes?
2270         my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
2271
2272         # start/end dates for invoice formats that do nonstandard 
2273         # things with them
2274         my %item_dates = ();
2275         %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
2276           unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
2277
2278         if (    (!$type || $type eq 'S')
2279              && (    $cust_bill_pkg->setup != 0
2280                   || $cust_bill_pkg->setup_show_zero
2281                 )
2282            )
2283          {
2284
2285           warn "$me _items_cust_bill_pkg adding setup\n"
2286             if $DEBUG > 1;
2287
2288           my $description = $desc;
2289           $description .= ' Setup'
2290             if $cust_bill_pkg->recur != 0
2291             || $discount_show_always
2292             || $cust_bill_pkg->recur_show_zero;
2293
2294           my @d = ();
2295           my $svc_label;
2296           unless ( $cust_pkg->part_pkg->hide_svc_detail
2297                 || $cust_bill_pkg->hidden )
2298           {
2299
2300             my @svc_labels = map &{$escape_function}($_),
2301                         $cust_pkg->h_labels_short($self->_date, undef, 'I');
2302             push @d, @svc_labels
2303               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2304             $svc_label = $svc_labels[0];
2305
2306             if ( ! $cust_pkg->locationnum or
2307                    $cust_pkg->locationnum != $cust_main->ship_locationnum  ) {
2308               my $loc = $cust_pkg->location_label;
2309               $loc = substr($loc, 0, $maxlength). '...'
2310                 if $format eq 'latex' && length($loc) > $maxlength;
2311               push @d, &{$escape_function}($loc);
2312             }
2313
2314           } #unless hiding service details
2315
2316           push @d, $cust_bill_pkg->details(%details_opt)
2317             if $cust_bill_pkg->recur == 0;
2318
2319           if ( $cust_bill_pkg->hidden ) {
2320             $s->{amount}      += $cust_bill_pkg->setup;
2321             $s->{unit_amount} += $cust_bill_pkg->unitsetup;
2322             push @{ $s->{ext_description} }, @d;
2323           } else {
2324             $s = {
2325               _is_setup       => 1,
2326               description     => $description,
2327               pkgpart         => $pkgpart,
2328               pkgnum          => $cust_bill_pkg->pkgnum,
2329               amount          => $cust_bill_pkg->setup,
2330               setup_show_zero => $cust_bill_pkg->setup_show_zero,
2331               unit_amount     => $cust_bill_pkg->unitsetup,
2332               quantity        => $cust_bill_pkg->quantity,
2333               ext_description => \@d,
2334               svc_label       => ($svc_label || ''),
2335             };
2336           };
2337
2338         }
2339
2340         if (    ( !$type || $type eq 'R' || $type eq 'U' )
2341              && (
2342                      $cust_bill_pkg->recur != 0
2343                   || $cust_bill_pkg->setup == 0
2344                   || $discount_show_always
2345                   || $cust_bill_pkg->recur_show_zero
2346                 )
2347            )
2348         {
2349
2350           warn "$me _items_cust_bill_pkg adding recur/usage\n"
2351             if $DEBUG > 1;
2352
2353           my $is_summary = $display->summary;
2354           my $description = ($is_summary && $type && $type eq 'U')
2355                             ? "Usage charges" : $desc;
2356
2357           my $part_pkg = $cust_pkg->part_pkg;
2358
2359           #pry be a bit more efficient to look some of this conf stuff up
2360           # outside the loop
2361           unless (
2362             $conf->exists('disable_line_item_date_ranges')
2363               || $part_pkg->option('disable_line_item_date_ranges',1)
2364               || ! $cust_bill_pkg->sdate
2365               || ! $cust_bill_pkg->edate
2366           ) {
2367             my $time_period;
2368             my $date_style = '';
2369             $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monhtly',
2370                                          $cust_main->agentnum
2371                                        )
2372               if $part_pkg && $part_pkg->freq !~ /^1m?$/;
2373             $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
2374                                             $cust_main->agentnum
2375                                          );
2376             if ( defined($date_style) && $date_style eq 'month_of' ) {
2377               $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
2378             } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
2379               my $desc = $conf->config( 'cust_bill-line_item-date_description',
2380                                          $cust_main->agentnum
2381                                       );
2382               $desc .= ' ' unless $desc =~ /\s$/;
2383               $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
2384             } else {
2385               $time_period =      time2str($date_format, $cust_bill_pkg->sdate).
2386                            " - ". time2str($date_format, $cust_bill_pkg->edate);
2387             }
2388             $description .= " ($time_period)";
2389           }
2390
2391           my @d = ();
2392           my @seconds = (); # for display of usage info
2393           my $svc_label = '';
2394
2395           #at least until cust_bill_pkg has "past" ranges in addition to
2396           #the "future" sdate/edate ones... see #3032
2397           my @dates = ( $self->_date );
2398           my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
2399           push @dates, $prev->sdate if $prev;
2400           push @dates, undef if !$prev;
2401
2402           unless ( $part_pkg->hide_svc_detail
2403                 || $cust_bill_pkg->itemdesc
2404                 || $cust_bill_pkg->hidden
2405                 || $is_summary && $type && $type eq 'U'
2406               )
2407           {
2408
2409             warn "$me _items_cust_bill_pkg adding service details\n"
2410               if $DEBUG > 1;
2411
2412             my @svc_labels = map &{$escape_function}($_),
2413                         $cust_pkg->h_labels_short($self->_date, undef, 'I');
2414             push @d, @svc_labels
2415               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2416             $svc_label = $svc_labels[0];
2417
2418             warn "$me _items_cust_bill_pkg done adding service details\n"
2419               if $DEBUG > 1;
2420
2421             if ( $cust_pkg->locationnum != $cust_main->ship_locationnum ) {
2422               my $loc = $cust_pkg->location_label;
2423               $loc = substr($loc, 0, $maxlength). '...'
2424                 if $format eq 'latex' && length($loc) > $maxlength;
2425               push @d, &{$escape_function}($loc);
2426             }
2427
2428             # Display of seconds_since_sqlradacct:
2429             # On the invoice, when processing @detail_items, look for a field
2430             # named 'seconds'.  This will contain total seconds for each 
2431             # service, in the same order as @ext_description.  For services 
2432             # that don't support this it will show undef.
2433             if ( $conf->exists('svc_acct-usage_seconds') 
2434                  and ! $cust_bill_pkg->pkgpart_override ) {
2435               foreach my $cust_svc ( 
2436                   $cust_pkg->h_cust_svc(@dates, 'I') 
2437                 ) {
2438
2439                 # eval because not having any part_export_usage exports 
2440                 # is a fatal error, last_bill/_date because that's how 
2441                 # sqlradius_hour billing does it
2442                 my $sec = eval {
2443                   $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
2444                 };
2445                 push @seconds, $sec;
2446               }
2447             } #if svc_acct-usage_seconds
2448
2449           }
2450
2451           unless ( $is_summary ) {
2452             warn "$me _items_cust_bill_pkg adding details\n"
2453               if $DEBUG > 1;
2454
2455             #instead of omitting details entirely in this case (unwanted side
2456             # effects), just omit CDRs
2457             $details_opt{'no_usage'} = 1
2458               if $type && $type eq 'R';
2459
2460             push @d, $cust_bill_pkg->details(%details_opt);
2461           }
2462
2463           warn "$me _items_cust_bill_pkg calculating amount\n"
2464             if $DEBUG > 1;
2465   
2466           my $amount = 0;
2467           if (!$type) {
2468             $amount = $cust_bill_pkg->recur;
2469           } elsif ($type eq 'R') {
2470             $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
2471           } elsif ($type eq 'U') {
2472             $amount = $cust_bill_pkg->usage;
2473           }
2474   
2475           my $unit_amount =
2476             ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
2477                                               : $amount;
2478
2479           if ( !$type || $type eq 'R' ) {
2480
2481             warn "$me _items_cust_bill_pkg adding recur\n"
2482               if $DEBUG > 1;
2483
2484             if ( $cust_bill_pkg->hidden ) {
2485               $r->{amount}      += $amount;
2486               $r->{unit_amount} += $unit_amount;
2487               push @{ $r->{ext_description} }, @d;
2488             } else {
2489               $r = {
2490                 description     => $description,
2491                 pkgpart         => $pkgpart,
2492                 pkgnum          => $cust_bill_pkg->pkgnum,
2493                 amount          => $amount,
2494                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2495                 unit_amount     => $unit_amount,
2496                 quantity        => $cust_bill_pkg->quantity,
2497                 %item_dates,
2498                 ext_description => \@d,
2499                 svc_label       => ($svc_label || ''),
2500               };
2501               $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
2502             }
2503
2504           } else {  # $type eq 'U'
2505
2506             warn "$me _items_cust_bill_pkg adding usage\n"
2507               if $DEBUG > 1;
2508
2509             if ( $cust_bill_pkg->hidden ) {
2510               $u->{amount}      += $amount;
2511               $u->{unit_amount} += $unit_amount,
2512               push @{ $u->{ext_description} }, @d;
2513             } else {
2514               $u = {
2515                 description     => $description,
2516                 pkgpart         => $pkgpart,
2517                 pkgnum          => $cust_bill_pkg->pkgnum,
2518                 amount          => $amount,
2519                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2520                 unit_amount     => $unit_amount,
2521                 quantity        => $cust_bill_pkg->quantity,
2522                 %item_dates,
2523                 ext_description => \@d,
2524               };
2525             }
2526           }
2527
2528         } # recurring or usage with recurring charge
2529
2530       } else { #pkgnum tax or one-shot line item (??)
2531
2532         warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
2533           if $DEBUG > 1;
2534
2535         if ( $cust_bill_pkg->setup != 0 ) {
2536           push @b, {
2537             'description' => $desc,
2538             'amount'      => sprintf("%.2f", $cust_bill_pkg->setup),
2539           };
2540         }
2541         if ( $cust_bill_pkg->recur != 0 ) {
2542           push @b, {
2543             'description' => "$desc (".
2544                              time2str($date_format, $cust_bill_pkg->sdate). ' - '.
2545                              time2str($date_format, $cust_bill_pkg->edate). ')',
2546             'amount'      => sprintf("%.2f", $cust_bill_pkg->recur),
2547           };
2548         }
2549
2550       }
2551
2552     }
2553
2554     $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
2555                                 && $conf->exists('discount-show-always'));
2556
2557   }
2558
2559   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2560     if ( $_  ) {
2561       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
2562       $_->{amount}      =~ s/^\-0\.00$/0.00/;
2563       $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2564       push @b, { %$_ }
2565         if $_->{amount} != 0
2566         || $discount_show_always
2567         || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2568         || (   $_->{_is_setup} && $_->{setup_show_zero} )
2569     }
2570   }
2571
2572   warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
2573     if $DEBUG > 1;
2574
2575   @b;
2576
2577 }
2578
2579 =item _items_discounts_avail
2580
2581 Returns an array of line item hashrefs representing available term discounts
2582 for this invoice.  This makes the same assumptions that apply to term 
2583 discounts in general: that the package is billed monthly, at a flat rate, 
2584 with no usage charges.  A prorated first month will be handled, as will 
2585 a setup fee if the discount is allowed to apply to setup fees.
2586
2587 =cut
2588
2589 sub _items_discounts_avail {
2590   my $self = shift;
2591
2592   #maybe move this method from cust_bill when quotations support discount_plans 
2593   return () unless $self->can('discount_plans');
2594   my %plans = $self->discount_plans;
2595
2596   my $list_pkgnums = 0; # if any packages are not eligible for all discounts
2597   $list_pkgnums = grep { $_->list_pkgnums } values %plans;
2598
2599   map {
2600     my $months = $_;
2601     my $plan = $plans{$months};
2602
2603     my $term_total = sprintf('%.2f', $plan->discounted_total);
2604     my $percent = sprintf('%.0f', 
2605                           100 * (1 - $term_total / $plan->base_total) );
2606     my $permonth = sprintf('%.2f', $term_total / $months);
2607     my $detail = $self->mt('discount on item'). ' '.
2608                  join(', ', map { "#$_" } $plan->pkgnums)
2609       if $list_pkgnums;
2610
2611     # discounts for non-integer months don't work anyway
2612     $months = sprintf("%d", $months);
2613
2614     +{
2615       description => $self->mt('Save [_1]% by paying for [_2] months',
2616                                 $percent, $months),
2617       amount      => $self->mt('[_1] ([_2] per month)', 
2618                                 $term_total, $money_char.$permonth),
2619       ext_description => ($detail || ''),
2620     }
2621   } #map
2622   sort { $b <=> $a } keys %plans;
2623
2624 }
2625
2626 1;