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