RT# 81961 Repair broken links in POD documentation
[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
6              $date_format
7            );
8              # but NOT $conf
9 use vars qw( $invoice_lines @buf ); #yuck
10 use List::Util qw(sum); #can't import first, it conflicts with cust_main.first
11 use Date::Format;
12 use Date::Language;
13 use Time::Local qw( timelocal );
14 use Text::Template 1.20;
15 use File::Temp 0.14;
16 use Archive::Zip qw( :ERROR_CODES :CONSTANTS );
17 use IO::Scalar;
18 use HTML::Entities;
19 use Cwd;
20 use FS::UID;
21 use FS::Misc qw( send_email );
22 use FS::Record qw( qsearch qsearchs dbh );
23 use FS::Conf;
24 use FS::Misc qw( generate_ps generate_pdf );
25 use FS::pkg_category;
26 use FS::pkg_class;
27 use FS::invoice_mode;
28 use FS::L10N;
29 use FS::Log;
30
31 $DEBUG = 0;
32 $me = '[FS::Template_Mixin]';
33 FS::UID->install_callback( sub { 
34   my $conf = new FS::Conf; #global
35   $money_char  = $conf->config('money_char')  || '$';  
36   $date_format = $conf->config('date_format') || '%x'; #/YY
37 } );
38
39 =item conf [ MODE ]
40
41 Returns a configuration handle (L<FS::Conf>) set to the customer's locale.
42
43 If the "mode" pseudo-field is set on the object, the configuration handle
44 will be an L<FS::invoice_conf> for that invoice mode (and the customer's
45 locale).
46
47 =cut
48
49 sub conf {
50   my $self = shift;
51   my $mode = $self->get('mode');
52   if ($self->{_conf} and !defined($mode)) {
53     return $self->{_conf};
54   }
55
56   my $cust_main = $self->cust_main;
57   my $locale = $cust_main ? $cust_main->locale : '';
58   my $conf;
59   if ( $mode ) {
60     if ( ref $mode and $mode->isa('FS::invoice_mode') ) {
61       $mode = $mode->modenum;
62     } elsif ( $mode =~ /\D/ ) {
63       die "invalid invoice mode $mode";
64     }
65     $conf = qsearchs('invoice_conf', { modenum => $mode, locale => $locale });
66     if (!$conf) {
67       $conf = qsearchs('invoice_conf', { modenum => $mode, locale => '' });
68       # it doesn't have a locale, but system conf still might
69       $conf->set('locale' => $locale) if $conf;
70     }
71   }
72   # if $mode is unspecified, or if there is no invoice_conf matching this mode
73   # and locale, then use the system config only (but with the locale)
74   $conf ||= FS::Conf->new({ 'locale' => $locale });
75   # cache it
76   return $self->{_conf} = $conf;
77 }
78
79 =item print_text OPTIONS
80
81 Returns an text invoice, as a list of lines.
82
83 Options can be passed as a hash.
84
85 I<time>, if specified, is used to control the printing of overdue messages.  The
86 default is now.  It isn't the date of the invoice; that's the `_date' field.
87 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
88 L<Time::Local> and L<Date::Parse> for conversion functions.
89
90 I<template>, if specified, is the name of a suffix for alternate invoices.
91
92 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
93
94 =cut
95
96 sub print_text {
97   my $self = shift;
98   my %params;
99   if ( ref($_[0]) ) {
100     %params = %{ shift() };
101   } else {
102     %params = @_;
103   }
104
105   $params{'format'} = 'template'; # for some reason
106
107   $self->print_generic( %params );
108 }
109
110 =item print_latex HASHREF
111
112 Internal method - returns a filename of a filled-in LaTeX template for this
113 invoice (Note: add ".tex" to get the actual filename), and a filename of
114 an associated logo (with the .eps extension included).
115
116 See print_ps and print_pdf for methods that return PostScript and PDF output.
117
118 Options can be passed as a hash.
119
120 I<time>, if specified, is used to control the printing of overdue messages.  The
121 default is now.  It isn't the date of the invoice; that's the `_date' field.
122 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
123 L<Time::Local> and L<Date::Parse> for conversion functions.
124
125 I<template>, if specified, is the name of a suffix for alternate invoices.  
126 This is strongly deprecated; see L<FS::invoice_conf> for the right way to
127 customize invoice templates for different purposes.
128
129 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
130
131 =cut
132
133 sub print_latex {
134   my $self = shift;
135   my %params;
136
137   if ( ref($_[0]) ) {
138     %params = %{ shift() };
139   } else {
140     %params = @_;
141   }
142
143   $params{'format'} = 'latex';
144   my $conf = $self->conf;
145
146   # this needs to go away
147   my $template = $params{'template'};
148   # and this especially
149   $template ||= $self->_agent_template
150     if $self->can('_agent_template');
151
152   #the new way
153   $self->set('mode', $params{mode})
154     if $params{mode};
155
156   my $pkey = $self->primary_key;
157   my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
158
159   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
160   my $lh = new File::Temp(
161     TEMPLATE => $tmp_template,
162     DIR      => $dir,
163     SUFFIX   => '.eps',
164     UNLINK   => 0,
165   ) or die "can't open temp file: $!\n";
166
167   my $agentnum = $self->agentnum;
168
169   if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
170     print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
171       or die "can't write temp file: $!\n";
172   } else {
173     print $lh $conf->config_binary('logo.eps', $agentnum)
174       or die "can't write temp file: $!\n";
175   }
176   close $lh;
177   $params{'logo_file'} = $lh->filename;
178
179   if( $conf->exists('invoice-barcode') 
180         && $self->can('invoice_barcode')
181         && $self->invnum ) { # don't try to barcode statements
182       my $png_file = $self->invoice_barcode($dir);
183       my $eps_file = $png_file;
184       $eps_file =~ s/\.png$/.eps/g;
185       $png_file =~ /(barcode.*png)/;
186       $png_file = $1;
187       $eps_file =~ /(barcode.*eps)/;
188       $eps_file = $1;
189
190       my $curr_dir = cwd();
191       chdir($dir); 
192       # after painfuly long experimentation, it was determined that sam2p won't
193       # accept : and other chars in the path, no matter how hard I tried to
194       # escape them, hence the chdir (and chdir back, just to be safe)
195       system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0
196         or die "sam2p failed: $!\n";
197       unlink($png_file);
198       chdir($curr_dir);
199
200       $params{'barcode_file'} = $eps_file;
201   }
202
203   my @filled_in = $self->print_generic( %params );
204   
205   my $fh = new File::Temp( TEMPLATE => $tmp_template,
206                            DIR      => $dir,
207                            SUFFIX   => '.tex',
208                            UNLINK   => 0,
209                          ) or die "can't open temp file: $!\n";
210   binmode($fh, ':utf8'); # language support
211   print $fh join('', @filled_in );
212   close $fh;
213
214   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
215   return ($1, $params{'logo_file'}, $params{'barcode_file'});
216
217 }
218
219 sub agentnum {
220   my $self = shift;
221   my $cust_main = $self->cust_main;
222   $cust_main ? $cust_main->agentnum : $self->prospect_main->agentnum;
223 }
224
225 =item print_generic OPTION => VALUE ...
226
227 Internal method - returns a filled-in template for this invoice as a scalar.
228
229 See print_ps and print_pdf for methods that return PostScript and PDF output.
230
231 Required options
232
233 =over 4
234
235 =item format
236
237 The B<format> option is required and should be set to html, latex (print and PDF) or template (plaintext).
238
239 =back
240
241 Additional options
242
243 =over 4
244
245 =item notice_name
246
247 Overrides "Invoice" as the name of the sent document.
248
249 =item today
250
251 Used to control the printing of overdue messages.  The
252 default is now.  It isn't the date of the invoice; that's the `_date' field.
253 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
254 L<Time::Local> and L<Date::Parse> for conversion functions.
255
256 =item logo_file
257
258 Logo file (path to temporary EPS file on the local filesystem)
259
260 =item cid
261
262 CID for inline (emailed) images (logo)
263
264 =item locale
265
266 Override customer's locale
267
268 =item unsquelch_cdr
269
270 Overrides any per customer cdr squelching when true
271
272 =item no_number
273
274 Supress the (invoice, quotation, statement, etc.) number
275
276 =item no_date
277
278 Supress the date
279
280 =item no_coupon
281
282 Supress the payment coupon
283
284 =item barcode_file
285
286 Barcode file (path to temporary EPS file on the local filesystem)
287
288 =item barcode_img
289
290 Flag indicating the barcode image should be a link (normal HTML dipaly)
291
292 =item barcode_cid
293
294 Barcode CID for inline (emailed) images
295
296 =item preref_callback
297
298 Coderef run for each line item, code should return HTML to be displayed
299 before that line item (quotations only)
300
301 =item template
302
303 Deprecated.  Used as a suffix for a configuration template.  Please 
304 don't use this, it deprecated in favor of more flexible alternatives.
305
306 =back
307
308 =cut
309
310 #what's with all the sprintf('%10.2f')'s in here?  will it cause any
311 # (alignment in text invoice?) problems to change them all to '%.2f' ?
312 # yes: fixed width/plain text printing will be borked
313 sub print_generic {
314   my( $self, %params ) = @_;
315   my $conf = $self->conf;
316
317   my $today = $params{today} ? $params{today} : time;
318   warn "$me print_generic called on $self with suffix $params{template}\n"
319     if $DEBUG;
320
321   my $format = $params{format};
322   die "Unknown format: $format"
323     unless $format =~ /^(latex|html|template)$/;
324
325   my $cust_main = $self->cust_main || $self->prospect_main;
326
327   my $locale = $params{'locale'} || $cust_main->locale;
328
329   my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
330                      'html'     => [ '<%=', '%>' ],
331                      'template' => [ '{', '}' ],
332                    );
333
334   warn "$me print_generic creating template\n"
335     if $DEBUG > 1;
336
337   # set the notice name here, and nowhere else.
338   my $notice_name =  $params{notice_name}
339                   || $conf->config('notice_name')
340                   || $self->notice_name;
341
342   #create the template
343   my $template = $params{template} ? $params{template} : $self->_agent_template;
344   my $templatefile = $self->template_conf. $format;
345   $templatefile .= "_$template"
346     if length($template) && $conf->exists($templatefile."_$template");
347
348   $self->set('_template',$template);
349
350   # the base template
351   my @invoice_template = map "$_\n", $conf->config($templatefile)
352     or die "cannot load config data $templatefile";
353
354   if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
355     #change this to a die when the old code is removed
356     # it's been almost ten years, changing it to a die
357     die "old-style invoice template $templatefile; ".
358          "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
359          #$old_latex = 'true';
360          #@invoice_template = _translate_old_latex_format(@invoice_template);
361   } 
362
363   warn "$me print_generic creating T:T object\n"
364     if $DEBUG > 1;
365
366   my $text_template = new Text::Template(
367     TYPE => 'ARRAY',
368     SOURCE => \@invoice_template,
369     DELIMITERS => $delimiters{$format},
370   );
371
372   warn "$me print_generic compiling T:T object\n"
373     if $DEBUG > 1;
374
375   $text_template->compile()
376     or die "Can't compile $templatefile: $Text::Template::ERROR\n";
377
378
379   # additional substitution could possibly cause breakage in existing templates
380   my %convert_maps = ( 
381     'latex' => {
382                  'notes'         => sub { map "$_", @_ },
383                  'footer'        => sub { map "$_", @_ },
384                  'smallfooter'   => sub { map "$_", @_ },
385                  'returnaddress' => sub { map "$_", @_ },
386                  'coupon'        => sub { map "$_", @_ },
387                  'summary'       => sub { map "$_", @_ },
388                },
389     'html'  => {
390                  'notes' =>
391                    sub {
392                      map { 
393                        s/%%(.*)$/<!-- $1 -->/g;
394                        s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
395                        s/\\begin\{enumerate\}/<ol>/g;
396                        s/\\item /  <li>/g;
397                        s/\\end\{enumerate\}/<\/ol>/g;
398                        s/\\textbf\{(.*)\}/<b>$1<\/b>/g;
399                        s/\\\\\*/<br>/g;
400                        s/\\dollar ?/\$/g;
401                        s/\\#/#/g;
402                        s/~/&nbsp;/g;
403                        $_;
404                      }  @_
405                    },
406                  'footer' =>
407                    sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
408                  'smallfooter' =>
409                    sub { map { s/~/&nbsp;/g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
410                  'returnaddress' =>
411                    sub {
412                      map { 
413                        s/~/&nbsp;/g;
414                        s/\\\\\*?\s*$/<BR>/;
415                        s/\\hyphenation\{[\w\s\-]+}//;
416                        s/\\([&])/$1/g;
417                        $_;
418                      }  @_
419                    },
420                  'coupon'        => sub { "" },
421                  'summary'       => sub { "" },
422                },
423     'template' => {
424                  'notes' =>
425                    sub {
426                      map { 
427                        s/%%.*$//g;
428                        s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
429                        s/\\begin\{enumerate\}//g;
430                        s/\\item /  * /g;
431                        s/\\end\{enumerate\}//g;
432                        s/\\textbf\{(.*)\}/$1/g;
433                        s/\\\\\*/ /;
434                        s/\\dollar ?/\$/g;
435                        $_;
436                      }  @_
437                    },
438                  'footer' =>
439                    sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
440                  'smallfooter' =>
441                    sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
442                  'returnaddress' =>
443                    sub {
444                      map { 
445                        s/~/ /g;
446                        s/\\\\\*?\s*$/\n/;             # dubious
447                        s/\\hyphenation\{[\w\s\-]+}//;
448                        $_;
449                      }  @_
450                    },
451                  'coupon'        => sub { "" },
452                  'summary'       => sub { "" },
453                },
454   );
455
456
457   # hashes for differing output formats
458   my %nbsps = ( 'latex'    => '~',
459                 'html'     => '',    # '&nbps;' would be nice
460                 'template' => '',    # not used
461               );
462   my $nbsp = $nbsps{$format};
463
464   my %escape_functions = ( 'latex'    => \&_latex_escape,
465                            'html'     => \&_html_escape_nbsp,#\&encode_entities,
466                            'template' => sub { shift },
467                          );
468   my $escape_function = $escape_functions{$format};
469   my $escape_function_nonbsp = ($format eq 'html')
470                                  ? \&_html_escape : $escape_function;
471
472   my %newline_tokens = (  'latex'     => '\\\\',
473                           'html'      => '<br>',
474                           'template'  => "\n",
475                         );
476   my $newline_token = $newline_tokens{$format};
477
478   warn "$me generating template variables\n"
479     if $DEBUG > 1;
480
481   # generate template variables
482   my $returnaddress;
483
484   if (
485          defined( $conf->config_orbase( "invoice_${format}returnaddress",
486                                         $template
487                                       )
488                 )
489        && length( $conf->config_orbase( "invoice_${format}returnaddress",
490                                         $template
491                                       )
492                 )
493   ) {
494
495     $returnaddress = join("\n",
496       $conf->config_orbase("invoice_${format}returnaddress", $template)
497     );
498
499   } elsif ( grep /\S/,
500             $conf->config_orbase('invoice_latexreturnaddress', $template) ) {
501
502     my $convert_map = $convert_maps{$format}{'returnaddress'};
503     $returnaddress =
504       join( "\n",
505             &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress",
506                                                  $template
507                                                )
508                          )
509           );
510   } elsif ( grep /\S/, $conf->config('company_address', $cust_main->agentnum) ) {
511
512     my $convert_map = $convert_maps{$format}{'returnaddress'};
513     $returnaddress = join( "\n", &$convert_map(
514                                    map { s/( {2,})/'~' x length($1)/eg;
515                                          s/$/\\\\\*/;
516                                          $_
517                                        }
518                                      ( $conf->config('company_name', $cust_main->agentnum),
519                                        $conf->config('company_address', $cust_main->agentnum),
520                                      )
521                                  )
522                      );
523
524   } else {
525
526     my $warning = "Couldn't find a return address; ".
527                   "do you need to set the company_address configuration value?";
528     warn "$warning\n";
529     $returnaddress = $nbsp;
530     #$returnaddress = $warning;
531
532   }
533
534   warn "$me generating invoice data\n"
535     if $DEBUG > 1;
536
537   my $agentnum = $cust_main->agentnum;
538
539   my %invoice_data = (
540
541     #invoice from info
542     'company_name'    => scalar( $conf->config('company_name', $agentnum) ),
543     'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
544     'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
545     'returnaddress'   => $returnaddress,
546     'agent'           => &$escape_function($cust_main->agent->agent),
547
548     #invoice/quotation info
549     'no_number'       => $params{'no_number'},
550     'invnum'          => ( $params{'no_number'} ? '' : $self->invnum ),
551     'quotationnum'    => $self->quotationnum,
552     'no_date'         => $params{'no_date'},
553     '_date'           => ( $params{'no_date'} ? '' : $self->_date ),
554       # workaround for inconsistent behavior in the early plain text 
555       # templates; see RT#28271
556     'date'            => ( $params{'no_date'}
557                              ? ''
558                              : ($format eq 'template'
559                                ? $self->_date
560                                : $self->time2str_local('long', $self->_date, $format)
561                                )
562                          ),
563     'today'           => $self->time2str_local('long', $today, $format),
564     'terms'           => $self->terms,
565     'template'        => $template, #params{'template'},
566     'notice_name'     => $notice_name, # escape?
567     'current_charges' => sprintf("%.2f", $self->charged),
568     'duedate'         => $self->due_date2str('rdate'), #date_format?
569     'duedate_long'    => $self->due_date2str('long'),
570
571     #customer info
572     'custnum'         => $cust_main->display_custnum,
573     'prospectnum'     => $cust_main->prospectnum,
574     'agent_custid'    => &$escape_function($cust_main->agent_custid),
575     ( map { $_ => &$escape_function($cust_main->$_()) }
576         qw( company address1 address2 city state zip fax )
577     ),
578     'payname'         => &$escape_function( $cust_main->invoice_attn
579                                              || $cust_main->contact_firstlast ),
580
581     #global config
582     'ship_enable'     => $cust_main->invoice_ship_address || $conf->exists('invoice-ship_address'),
583     'unitprices'      => $conf->exists('invoice-unitprice'),
584     'smallernotes'    => $conf->exists('invoice-smallernotes'),
585     'smallerfooter'   => $conf->exists('invoice-smallerfooter'),
586     'balance_due_below_line' => $conf->exists('balance_due_below_line'),
587    
588     #layout info -- would be fancy to calc some of this and bury the template
589     #               here in the code
590     'topmargin'             => scalar($conf->config('invoice_latextopmargin', $agentnum)),
591     'headsep'               => scalar($conf->config('invoice_latexheadsep', $agentnum)),
592     'textheight'            => scalar($conf->config('invoice_latextextheight', $agentnum)),
593     'extracouponspace'      => scalar($conf->config('invoice_latexextracouponspace', $agentnum)),
594     'couponfootsep'         => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)),
595     'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum),
596     'addresssep'            => scalar($conf->config('invoice_latexaddresssep', $agentnum)),
597     'amountenclosedsep'     => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
598     'coupontoaddresssep'    => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
599     'addcompanytoaddress'   => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
600     'couponlocation'        => (scalar($conf->config('invoice_latexcouponlocation', $agentnum)) eq "top") ? 'top' : 'bottom',
601
602     # better hang on to conf_dir for a while (for old templates)
603     'conf_dir'        => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
604
605     #these are only used when doing paged plaintext
606     'page'            => 1,
607     'total_pages'     => 1,
608
609   );
610
611   #quotations have $name
612   $invoice_data{'name'} = $invoice_data{'payname'};
613  
614   #localization
615   $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
616   # prototype here to silence warnings
617   $invoice_data{'time2str'} = sub ($;$$) { $self->time2str_local(@_, $format) };
618
619   my $min_sdate = 999999999999;
620   my $max_edate = 0;
621   foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
622     next unless $cust_bill_pkg->pkgnum > 0;
623     $min_sdate = $cust_bill_pkg->sdate
624       if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate;
625     $max_edate = $cust_bill_pkg->edate
626       if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate;
627   }
628
629   $invoice_data{'bill_period'} = '';
630   $invoice_data{'bill_period'} =
631       $self->time2str_local('%e %h', $min_sdate, $format) 
632       . " to " .
633       $self->time2str_local('%e %h', $max_edate, $format)
634     if ($max_edate != 0 && $min_sdate != 999999999999);
635
636   $invoice_data{finance_section} = '';
637   if ( $conf->config('finance_pkgclass') ) {
638     my $pkg_class =
639       qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
640     $invoice_data{finance_section} = $pkg_class->categoryname;
641   } 
642   $invoice_data{finance_amount} = '0.00';
643   $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
644
645   my $countrydefault = $conf->config('countrydefault') || 'US';
646   foreach ( qw( address1 address2 city state zip country fax) ){
647     my $method = 'ship_'.$_;
648     $invoice_data{"ship_$_"} = $escape_function->($cust_main->$method);
649   }
650   if ( length($cust_main->ship_company) ) {
651     $invoice_data{'ship_company'} = $escape_function->($cust_main->ship_company);
652   } else {
653     $invoice_data{'ship_company'} = $escape_function->($cust_main->company);
654   }
655   $invoice_data{'ship_contact'} = $escape_function->($cust_main->contact);
656   $invoice_data{'ship_country'} = ''
657     if ( $invoice_data{'ship_country'} eq $countrydefault );
658   
659   $invoice_data{'cid'} = $params{'cid'}
660     if $params{'cid'};
661
662   if ( $cust_main->bill_locationnum
663        && $cust_main->bill_location->country ne $countrydefault ) {
664     $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full);
665   } else {
666     $invoice_data{'country'} = '';
667   }
668
669   my @address = ();
670   $invoice_data{'address'} = \@address;
671   push @address,
672     $invoice_data{'payname'}.
673       ( $cust_main->po_number
674           ? " (P.O. #". $cust_main->po_number. ")"
675           : ''
676       )
677   ;
678   push @address, $cust_main->company
679     if $cust_main->company;
680   push @address, $cust_main->address1;
681   push @address, $cust_main->address2
682     if $cust_main->address2;
683   push @address,
684     $cust_main->city. ", ". $cust_main->state. "  ".  $cust_main->zip;
685   push @address, $invoice_data{'country'}
686     if $invoice_data{'country'};
687   push @address, ''
688     while (scalar(@address) < 5);
689
690   $invoice_data{'logo_file'} = $params{'logo_file'}
691     if $params{'logo_file'};
692   $invoice_data{'barcode_file'} = $params{'barcode_file'}
693     if $params{'barcode_file'};
694   $invoice_data{'barcode_img'} = $params{'barcode_img'}
695     if $params{'barcode_img'};
696   $invoice_data{'barcode_cid'} = $params{'barcode_cid'}
697     if $params{'barcode_cid'};
698
699
700   # re: rt:78190
701   #   using owed_on_invoice() instead of owed() here for $balance_due
702   #   using _items_previous_total() instead of ->previous() for $pr_total
703   #
704   #   owed_on_invoice() is aware of configuration flags that affect how an
705   #     invoice is rendered.  May not return actual current balance. Will
706   #     return balance appropriate for the invoice being rendered, based
707   #     on which past due items, current charges, and future payments are
708   #     displayed.
709   #
710   #   Going forward, usage of owed(), or bypassing cust_bill helper methods
711   #     when generating invoice lines may lead to incorrect or misleading
712   #     math on invoices.
713   #
714   #   Helper methods that are aware of invoicing conf flags:
715   #   - owed_on_invoice          # use instead of owed()
716   #   - _items_previous()        # use instead of previous()
717   #   - _items_credits()         # use instead of cust_credit()
718   #   - _items_payments()
719   #   - _items_total()
720   #   - _items_previous_total()  # use instead of previous()
721   #   - _items_payments_total()
722   #   - _items_credits_total()   # use instead of cust_credit()
723
724   my $pr_total    = $self->_items_previous_total();
725
726   my $balance_due = $self->owed_on_invoice();
727   $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
728
729   # flag telling this invoice to have a first-page summary
730   my $summarypage = '';
731
732   if ( $self->custnum && $self->invnum ) {
733     # XXX should be an FS::cust_bill method to set the defaults, instead
734     # of checking the type here
735
736     # info from customer's last invoice before this one, for some 
737     # summary formats
738     $invoice_data{'last_bill'} = {};
739  
740     #    my $last_bill = $self->previous_bill;
741     # if ( $last_bill ) {
742
743     # Populate template stash for previous balance and payments
744     if ($pr_total) {
745       # Used on summary page as "Previous Balance"
746       $invoice_data{'true_previous_balance'} = sprintf("%.2f", $pr_total);
747
748       # Used on summary page as "Payments"
749       $invoice_data{'balance_adjustments'} = sprintf("%.2f",
750         $self->_items_payments_total() + $self->_items_credits_total()
751         );
752
753       # Used in invoice template as "Previous Balance"
754       $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
755
756       # $invoice_data{last_bill}{_date}:
757       # Not used in default templates, but may be in use by someone
758       #
759       # ! May be a problem field if they are using it... this field
760       #   stores the date of the previous invoice... it is possible to
761       #   carry a balance, but have the immediately previous invoice paid off.
762       #   In this case, this field might be presenting bad data?  Not
763       #   altering the problematic behavior, because someone might be
764       #   expecting this bad behavior in their templates for some other
765       #   purpose, such as a "your last bill was dated %_date%"
766       my $last_bill = $self->previous_bill;
767       $invoice_data{'last_bill'}{'_date'}
768         = ref $last_bill
769         ? $last_bill->_date()
770         : undef;
771
772       # $invoice_data{previous_payments}
773       # Not used in default templates, but may be in use by someone
774       #
775       # Returns an array of hrefs representing payments, each with keys:
776       #  - _date:       epoch timestamp
777       #  - date:        text formatted date
778       #  - amount:      money formatted amount string
779       #  - payinfo:     string from payby_payinfo_pretty()
780       #  - paynum:      id for cust_pay
781       #  - description: Text description for bill line item
782       #
783       my @payments = $self->_items_payments();
784       $invoice_data{previous_payments} = \@payments;
785
786       # $invoice_data{previous_credits}
787       # Not used in default templates, but may be in use by someone
788       #
789       # Returns an array of hrefs representing credits, each with keys:
790       #  - _date:        epoch timestamp
791       #  - date:         text formatted date
792       #  - amount:       money formatted amount string
793       #  - crednum:      id for cust_credit
794       #  - description:  Text description for bill line item
795       #  - creditreason: reason() from cust_credit
796       #
797       my @credits = $self->_items_credits();
798       $invoice_data{previous_credits} = \@credits;
799
800       # Populate formatted date field
801       for my $pmt_href (@payments, @credits) {
802         $pmt_href->{date} = $self->time2str_local(
803           'long',
804           $pmt_href->{_date},
805           $format
806         );
807       }
808
809     } else {
810       # There are no outstanding invoices    = YAPH
811       $invoice_data{'true_previous_balance'} =
812       $invoice_data{'balance_adjustments'}   =
813       $invoice_data{'previous_balance'}      = '0.00';
814       $invoice_data{'previous_payments'}     =
815       $invoice_data{'previous_credits'} = [];
816     }
817
818     # Condencing a lot of debug staements here
819     if ($DEBUG) {
820       warn "\$invoice_data{$_}: $invoice_data{$_}"
821         for qw(
822           true_previous_balance
823           balance_adjustments
824           previous_balance
825           previous_payments
826           previous_credits
827         );
828     }
829
830     if ( $conf->exists('invoice_usesummary', $agentnum) ) {
831       $invoice_data{'summarypage'} = $summarypage = 1;
832     }
833
834   } # if this is an invoice
835
836   warn "$me substituting variables in notes, footer, smallfooter\n"
837     if $DEBUG > 1;
838
839   my $tc = $self->template_conf;
840   my @include = ( [ $tc,        'notes' ],
841                   [ 'invoice_', 'footer' ],
842                   [ 'invoice_', 'smallfooter', ],
843                   [ 'invoice_', 'watermark' ],
844                 );
845   push @include, [ $tc,        'coupon', ]
846     unless $params{'no_coupon'};
847
848   foreach my $i (@include) {
849
850     # load the configuration for this sub-template
851
852     my($base, $include) = @$i;
853
854     my $inc_file = $conf->key_orbase("$base$format$include", $template);
855
856     my @inc_src = $conf->config($inc_file, $agentnum);
857     if (!@inc_src) {
858       my $converter = $convert_maps{$format}{$include};
859       if ( $converter ) {
860         # then attempt to convert LaTeX to the requested format
861         $inc_file = $conf->key_orbase($base.'latex'.$include, $template);
862         @inc_src = &$converter( $conf->config($inc_file, $agentnum) );
863         foreach (@inc_src) {
864           # this isn't included in the convert_maps
865           my ($open, $close) = @{ $delimiters{$format} };
866           s/\[\@--/$open/g;
867           s/--\@\]/$close/g;
868         }
869       }
870     } # else @inc_src is empty and that's fine
871
872     # make a Text::Template out of it
873
874     my $inc_tt = new Text::Template (
875       TYPE       => 'ARRAY',
876       SOURCE     => [ map "$_\n", @inc_src ],
877       DELIMITERS => $delimiters{$format},
878     ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
879
880     unless ( $inc_tt->compile() ) {
881       my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
882       warn $error. "Template:\n". join('', map "$_\n", @inc_src);
883       die $error;
884     }
885
886     # fill in variables
887
888     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
889
890     $invoice_data{$include} =~ s/\n+$//
891       if ($format eq 'latex');
892   }
893
894 # if (well, probably when) we still need PO numbers in the brave new world of
895 # 4.x, then we'll have to add them back as their own customer fields
896 #  # let invoices use either of these as needed
897 #  $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') 
898 #    ? $cust_main->payinfo : '';
899 #  $invoice_data{'po_line'} = 
900 #    (  $cust_main->payby eq 'BILL' && $cust_main->payinfo )
901 #      ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
902 #      : $nbsp;
903
904   my %money_chars = ( 'latex'    => '',
905                       'html'     => $conf->config('money_char') || '$',
906                       'template' => '',
907                     );
908   my $money_char = $money_chars{$format};
909
910   # extremely dubious
911   my %other_money_chars = ( 'latex'    => '\dollar ',#XXX should be a config too
912                             'html'     => $conf->config('money_char') || '$',
913                             'template' => '',
914                           );
915   my $other_money_char = $other_money_chars{$format};
916   $invoice_data{'dollar'} = $other_money_char;
917
918   my %minus_signs = ( 'latex'    => '$-$',
919                       'html'     => '&minus;',
920                       'template' => '- ' );
921   my $minus = $minus_signs{$format};
922
923   my @detail_items = ();
924   my @total_items = ();
925   my @buf = ();
926   my @sections = ();
927
928   $invoice_data{'detail_items'} = \@detail_items;
929   $invoice_data{'total_items'} = \@total_items;
930   $invoice_data{'buf'} = \@buf;
931   $invoice_data{'sections'} = \@sections;
932
933   warn "$me generating sections\n"
934     if $DEBUG > 1;
935
936   my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
937   my $multisection = $self->has_sections;
938   if ( $multisection ) {
939     $invoice_data{multisection} = $conf->config($tc.'sections_method') || 1;
940   }
941   my $late_sections;
942   my $extra_sections = [];
943   my $extra_lines = ();
944
945   # default section ('Charges')
946   my $default_section = { 'description' => '',
947                           'subtotal'    => '', 
948                           'no_subtotal' => 1,
949                         };
950
951   # Previous Charges section
952   # subtotal is the first return value from $self->previous
953   my $previous_section;
954   # if the invoice has major sections, or if we're summarizing previous 
955   # charges with a single line, or if we've been specifically told to put them
956   # in a section, create a section for previous charges:
957   if ( $multisection or
958        $conf->exists('previous_balance-summary_only') or
959        $conf->exists('previous_balance-section') ) {
960     
961     $previous_section =  { 'description' => $self->mt('Previous Charges'),
962                            'subtotal'    => $other_money_char.
963                                             sprintf('%.2f', $pr_total),
964                            'summarized'  => '', #why? $summarypage ? 'Y' : '',
965                          };
966
967     # Include balance aging line and template variables
968     my @aged_balances = $self->_items_aging_balances();
969     ( $invoice_data{aged_balance_current},
970       $invoice_data{aged_balance_30d},
971       $invoice_data{aged_balance_60d},
972       $invoice_data{aged_balance_90d}
973     ) = @aged_balances;
974
975     if ($conf->exists('invoice_include_aging')) {
976       $previous_section->{posttotal} = sprintf(
977         '0 / 30 / 60 / 90 days overdue %.2f / %.2f / %.2f / %.2f',
978         @aged_balances,
979       );
980     }
981
982   } else {
983     # otherwise put them in the main section
984     $previous_section = $default_section;
985   }
986
987   my $adjust_section = {
988     'description'    => $self->mt('Credits, Payments, and Adjustments'),
989     'adjust_section' => 1,
990     'subtotal'       => 0,   # adjusted below
991   };
992   my $adjust_weight = _pkg_category($adjust_section->{description})
993                         ? _pkg_category($adjust_section->{description})->weight
994                         : 0;
995   $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
996   # Note: 'sort_weight' here is actually a flag telling whether there is an
997   # explicit package category for the adjust section. If so, certain behavior
998   # happens.
999   $adjust_section->{'sort_weight'} = $adjust_weight;
1000
1001
1002   if ( $multisection ) {
1003     ($extra_sections, $extra_lines) =
1004       $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
1005       if $conf->exists('usage_class_as_a_section', $cust_main->agentnum)
1006       && $self->can('_items_extra_usage_sections');
1007
1008     push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
1009
1010     push @detail_items, @$extra_lines if $extra_lines;
1011
1012     # the code is written so that both methods can be used together, but
1013     # we haven't yet changed the template to take advantage of that, so for 
1014     # now, treat them as mutually exclusive.
1015     my %section_method = ( by_category => 1 );
1016     if ( $conf->config($tc.'sections_method') eq 'location' ) {
1017       %section_method = ( by_location => 1 );
1018     }
1019     my ($early, $late) =
1020       $self->_items_sections( 'summary' => $summarypage,
1021                               'escape'  => $escape_function_nonbsp,
1022                               'extra_sections' => $extra_sections,
1023                               'format'  => $format,
1024                               %section_method
1025                             );
1026     push @sections, @$early;
1027     $late_sections = $late;
1028
1029     if (    $conf->exists('svc_phone_sections')
1030          && $self->can('_items_svc_phone_sections')
1031        )
1032     {
1033       my ($phone_sections, $phone_lines) =
1034         $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
1035       push @{$late_sections}, @$phone_sections;
1036       push @detail_items, @$phone_lines;
1037     }
1038     if ( $conf->exists('voip-cust_accountcode_cdr')
1039          && $cust_main->accountcode_cdr
1040          && $self->can('_items_accountcode_cdr')
1041        )
1042     {
1043       my ($accountcode_section, $accountcode_lines) =
1044         $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
1045       if ( scalar(@$accountcode_lines) ) {
1046           push @{$late_sections}, $accountcode_section;
1047           push @detail_items, @$accountcode_lines;
1048       }
1049     }
1050   } else {# not multisection
1051     # make a default section
1052     push @sections, $default_section;
1053     # and calculate the finance charge total, since it won't get done otherwise.
1054     # and the default section total
1055     # XXX possibly finance_pkgclass should not be used in this manner?
1056     my @finance_charges;
1057     my @charges;
1058     foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
1059       if ( $invoice_data{finance_section} and 
1060         grep { $_->section eq $invoice_data{finance_section} }
1061            $cust_bill_pkg->cust_bill_pkg_display ) {
1062         # I think these are always setup fees, but just to be sure...
1063         push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
1064       } else {
1065         push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
1066       }
1067     }
1068     $invoice_data{finance_amount} = 
1069       sprintf('%.2f', sum( @finance_charges ) || 0);
1070     $default_section->{subtotal} = $other_money_char.
1071                                     sprintf('%.2f', sum( @charges ) || 0);
1072   }
1073
1074   # start setting up summary subtotals
1075   my @summary_subtotals;
1076   my $method = $conf->config('summary_subtotals_method');
1077   if ( $method and $method ne $conf->config($tc.'sections_method') ) {
1078     # then re-section them by the correct method
1079     my %section_method = ( by_category => 1 );
1080     if ( $conf->config('summary_subtotals_method') eq 'location' ) {
1081       %section_method = ( by_location => 1 );
1082     }
1083     my ($early, $late) =
1084       $self->_items_sections( 'summary' => $summarypage,
1085                               'escape'  => $escape_function_nonbsp,
1086                               'extra_sections' => $extra_sections,
1087                               'format'  => $format,
1088                               %section_method
1089                             );
1090     foreach ( @$early ) {
1091       next if $_->{subtotal} == 0;
1092       $_->{subtotal} = $other_money_char.sprintf('%.2f', $_->{subtotal});
1093       push @summary_subtotals, $_;
1094     }
1095   } else {
1096     # subtotal sectioning is the same as for the actual invoice sections
1097     @summary_subtotals = @sections;
1098   }
1099
1100   # Hereafter, push sections to both @sections and @summary_subtotals
1101   # if they belong in both places (e.g. tax section).  Late sections are
1102   # never in @summary_subtotals.
1103
1104   # previous invoice balances in the Previous Charges section if there
1105   # is one, otherwise in the main detail section
1106   # (except if summary_only is enabled, don't show them at all)
1107   if ( $self->can('_items_previous') &&
1108        $self->enable_previous &&
1109        ! $conf->exists('previous_balance-summary_only') ) {
1110
1111     warn "$me adding previous balances\n"
1112       if $DEBUG > 1;
1113
1114     foreach my $line_item ( $self->_items_previous ) {
1115
1116       my $detail = {
1117         ref             => $line_item->{'pkgnum'},
1118         pkgpart         => $line_item->{'pkgpart'},
1119         #quantity        => 1, # not really correct
1120         section         => $previous_section, # which might be $default_section
1121         description     => &$escape_function($line_item->{'description'}),
1122         ext_description => [ map { &$escape_function($_) } 
1123                              @{ $line_item->{'ext_description'} || [] }
1124                            ],
1125         amount          => $money_char . $line_item->{'amount'},
1126         product_code    => $line_item->{'pkgpart'} || 'N/A',
1127       };
1128
1129       push @detail_items, $detail;
1130       push @buf, [ $detail->{'description'},
1131                    $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1132                  ];
1133     }
1134
1135   }
1136
1137   if ( $pr_total && $self->enable_previous ) {
1138     push @buf, ['','-----------'];
1139     push @buf, [ $self->mt('Total Previous Balance'),
1140                  $money_char. sprintf("%10.2f", $pr_total) ];
1141     push @buf, ['',''];
1142   }
1143  
1144   if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) {
1145       warn "$me adding DID summary\n"
1146         if $DEBUG > 1;
1147
1148       my ($didsummary,$minutes) = $self->_did_summary;
1149       my $didsummary_desc = 'DID Activity Summary (since last invoice)';
1150       push @detail_items, 
1151        { 'description' => $didsummary_desc,
1152            'ext_description' => [ $didsummary, $minutes ],
1153        };
1154   }
1155
1156   foreach my $section (@sections, @$late_sections) {
1157
1158     # begin some normalization
1159     $section->{'subtotal'} = $section->{'amount'}
1160       if $multisection
1161          && !exists($section->{subtotal})
1162          && exists($section->{amount});
1163
1164     $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
1165       if ( $invoice_data{finance_section} &&
1166            $section->{'description'} eq $invoice_data{finance_section} );
1167
1168     if ( $multisection ) {
1169
1170       if ( ref($section->{'subtotal'}) ) {
1171
1172         $section->{'subtotal'} =
1173           sprintf("$other_money_char%.2f to $other_money_char%.2f",
1174                     $section->{'subtotal'}[0],
1175                     $section->{'subtotal'}[1]
1176                  );
1177
1178       } else {
1179
1180         $section->{'subtotal'} = $other_money_char.
1181                                  sprintf('%.2f', $section->{'subtotal'})
1182
1183       }
1184
1185       # continue some normalization
1186       $section->{'amount'}   = $section->{'subtotal'}
1187
1188     }
1189
1190     if ( $section->{'description'} ) {
1191       push @buf, ( [ &$escape_function($section->{'description'}), '' ],
1192                    [ '', '' ],
1193                  );
1194     }
1195
1196     warn "$me   setting options\n"
1197       if $DEBUG > 1;
1198
1199     my %options = ();
1200     $options{'section'} = $section if $multisection;
1201     $options{'section_with_taxes'} = 1
1202       if $multisection
1203       && $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum);
1204     $options{'format'} = $format;
1205     $options{'escape_function'} = $escape_function;
1206     $options{'no_usage'} = 1 unless $unsquelched;
1207     $options{'unsquelched'} = $unsquelched;
1208     $options{'summary_page'} = $summarypage;
1209     $options{'skip_usage'} =
1210       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
1211     $options{'preref_callback'} = $params{'preref_callback'};
1212     $options{'disable_line_item_date_ranges'} =
1213       $conf->exists('disable_line_item_date_ranges');
1214
1215     warn "$me   searching for line items\n"
1216       if $DEBUG > 1;
1217
1218     my %section_tax_lines;
1219     my %seen_tax_lines;
1220
1221     foreach my $line_item ( $self->_items_pkg(%options),
1222                             $self->_items_fee(%options) ) {
1223
1224       # When bill is sectioned by location, fees may be displayed within the
1225       # appropriate location section.  Suppress this fee from the taxes/fees
1226       # end section, so it doesn't appear to be charged twice and make the
1227       # subtotals seem incorrect
1228       next
1229         if $line_item->{locationnum}
1230         && ref $options{section}
1231         && !exists $options{section}->{locationnum}
1232         && $self->has_sections
1233         && $conf->config($tc.'sections_method') eq 'location';
1234
1235       warn "$me     adding line item ".
1236            join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
1237         if $DEBUG > 1;
1238
1239       push @buf, ( [ $line_item->{'description'},
1240                      $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1241                    ],
1242                    map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
1243                  );
1244
1245       $line_item->{'ref'} = $line_item->{'pkgnum'};
1246       $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
1247       $line_item->{'section'} = $section;
1248       $line_item->{'description'} = &$escape_function($line_item->{'description'});
1249       $line_item->{'amount'} = $money_char.$line_item->{'amount'};
1250
1251       if ( length($line_item->{'unit_amount'}) ) {
1252         $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
1253       }
1254       $line_item->{'ext_description'} ||= [];
1255
1256       if ( $options{section_with_taxes} && ref $line_item->{pkg_tax} ) {
1257         for my $line_tax ( @{$ line_item->{pkg_tax} } ) {
1258
1259           # It is rarely possible for the same tax record to be presented here
1260           # multiple times.  See cust_bill_pkg::_pkg_tax_list for more info
1261           next if $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} };
1262           $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} } = 1;
1263
1264           $section_tax_lines{ $line_tax->{taxname} } += $line_tax->{amount};
1265         }
1266       }
1267
1268       push @detail_items, $line_item;
1269     }
1270
1271     # If conf flag invoice_sections_with_taxes:
1272     # - Add @detail_items for taxes into each section
1273     # - Update section subtotal to include taxes
1274     if ( $options{section_with_taxes} && %section_tax_lines ) {
1275       for my $taxname ( keys %section_tax_lines ) {
1276
1277         push @detail_items, {
1278           section => $section,
1279           amount  => sprintf($money_char."%.2f",$section_tax_lines{$taxname}),
1280           description => &$escape_function($taxname),
1281         };
1282
1283         # Append taxes to total.  If line format resembles "$5.00 to $12.00"
1284         # append to the second value.
1285
1286         if ($section->{subtotal} =~ /to/) {
1287           my @subtotal = split /\s/, $section->{subtotal};
1288           $subtotal[2] =~ s/[^\d\.]//g;
1289           $subtotal[2] = sprintf(
1290             $money_char."%.2f",
1291             ( $subtotal[2] + $section_tax_lines{$taxname} )
1292           );
1293           $section->{subtotal} = join ' ', @subtotal;
1294         } else {
1295           $section->{subtotal} =~ s/[^\d\.]//g;
1296           $section->{subtotal} = sprintf(
1297             $money_char . "%.2f",
1298             ( $section->{subtotal} + $section_tax_lines{$taxname} )
1299           );
1300         }
1301
1302       }
1303     }
1304
1305     if ( $section->{'description'} ) {
1306       push @buf, ( ['','-----------'],
1307                    [ $section->{'description'}. ' sub-total',
1308                       $section->{'subtotal'} # already formatted this 
1309                    ],
1310                    [ '', '' ],
1311                    [ '', '' ],
1312                  );
1313     }
1314   
1315   }
1316
1317   $invoice_data{current_less_finance} =
1318     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
1319
1320   # if there's anything in the Previous Charges section, prepend it to the list
1321   if ( $pr_total and $previous_section ne $default_section ) {
1322     unshift @sections, $previous_section;
1323     # but not @summary_subtotals
1324   }
1325
1326   warn "$me adding taxes\n"
1327     if $DEBUG > 1;
1328
1329   # create a tax section if we don't yet have one
1330   my @items_tax = $self->_items_tax;
1331   my $tax_description = 'Taxes, Surcharges, and Fees';
1332   my $tax_section =
1333     List::Util::first { $_->{description} eq $tax_description } @sections;
1334   if (!$tax_section) {
1335     $tax_section = { 'description' => $tax_description };
1336     push @sections, $tax_section if $multisection and @items_tax > 0;
1337   }
1338   $tax_section->{tax_section} = 1; # mark this section as containing taxes
1339   # if this is an existing tax section, we're merging the tax items into it.
1340   # grab the taxtotal that's already there, strip the money symbol if any
1341   my $taxtotal = $tax_section->{'subtotal'} || 0;
1342   $taxtotal =~ s/^\Q$other_money_char\E//;
1343
1344   # this does nothing
1345   #my $tax_weight = _pkg_category($tax_section->{description})
1346   #                      ? _pkg_category($tax_section->{description})->weight
1347   #                      : 0;
1348   #$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
1349   #$tax_section->{'sort_weight'} = $tax_weight;
1350
1351   my $invoice_sections_with_taxes = $conf->config_bool(
1352     'invoice_sections_with_taxes', $cust_main->agentnum
1353   );
1354
1355   foreach my $tax ( @items_tax ) {
1356
1357
1358     my $description = &$escape_function( $tax->{'description'} );
1359     my $amount      = sprintf( '%.2f', $tax->{'amount'} );
1360
1361     if ( $multisection ) {
1362       if ( !$invoice_sections_with_taxes ) {
1363
1364         $taxtotal += $tax->{'amount'};
1365
1366         push @detail_items, {
1367           ext_description => [],
1368           ref          => '',
1369           quantity     => '',
1370           description  => $description,
1371           amount       => $money_char. $amount,
1372           product_code => '',
1373           section      => $tax_section,
1374         };
1375
1376       }
1377     } else {
1378
1379       $taxtotal += $tax->{'amount'};
1380
1381       push @total_items, {
1382         'total_item'   => $description,
1383         'total_amount' => $other_money_char. $amount,
1384       };
1385
1386     }
1387
1388     push @buf,[ $description,
1389                 $money_char. $amount,
1390               ];
1391
1392   }
1393  
1394   if ( @items_tax ) {
1395     my $total = {};
1396     $total->{'total_item'} = $self->mt('Sub-total');
1397     $total->{'total_amount'} =
1398       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
1399
1400     if ( $multisection ) {
1401
1402       if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) {
1403         # If all tax items are displayed in location/category sections,
1404         # remove the empty tax section
1405         @sections = grep{ $_ ne $tax_section } @sections
1406           unless grep{ $_->{section} eq $tax_section } @detail_items;
1407       }
1408
1409       if ( $taxtotal > 0 ) {
1410         # there are taxes, so prepare the section to be displayed.
1411         # $taxtotal already includes any line items that were already in the
1412         # section (fees, taxes that are charged as packages for some reason).
1413         # also set 'summarized' to false so that this isn't a summary-only
1414         # section.
1415         $tax_section->{'subtotal'} = $other_money_char.
1416                                      sprintf('%.2f', $taxtotal);
1417         $tax_section->{'pretotal'} = 'New charges sub-total '.
1418                                      $total->{'total_amount'};
1419         $tax_section->{'description'} = $self->mt($tax_description);
1420         $tax_section->{'summarized'} = '';
1421
1422         # append tax section unless it's already there
1423         push @sections, $tax_section
1424           unless grep {$_ eq $tax_section} @sections;
1425
1426         push @summary_subtotals, $tax_section
1427           unless grep {$_ eq $tax_section} @summary_subtotals;
1428
1429       }
1430
1431     } else {
1432       unshift @total_items, $total;
1433     }
1434   }
1435   $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
1436
1437   ###
1438   # Totals
1439   ###
1440
1441   my %embolden_functions = (
1442     'latex'    => sub { return '\textbf{'. shift(). '}' },
1443     'html'     => sub { return '<b>'. shift(). '</b>' },
1444     'template' => sub { shift },
1445   );
1446   my $embolden_function = $embolden_functions{$format};
1447
1448   if ( $multisection ) {
1449
1450     if ( $adjust_section->{'sort_weight'} ) {
1451       $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
1452         $other_money_char.  sprintf("%.2f", ($self->billing_balance || 0) );
1453     } else{
1454       $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
1455         $other_money_char.  sprintf('%.2f', $self->charged );
1456     }
1457
1458   }
1459   
1460   if ( $self->can('_items_total') ) { # should always be true now
1461
1462     # even for multisection, need plain text version
1463
1464     my @new_total_items = $self->_items_total;
1465
1466     push @buf,['','-----------'];
1467
1468     foreach ( @new_total_items ) {
1469       my ($item, $amount) = ($_->{'total_item'}, $_->{'total_amount'});
1470       $_->{'total_item'}   = &$embolden_function( $item );
1471
1472       if ( ref($amount) ) {
1473         $_->{'total_amount'} = &$embolden_function(
1474                                  $other_money_char.$amount->[0]. ' to '.
1475                                  $other_money_char.$amount->[1]
1476                                );
1477       } else {
1478       $_->{'total_amount'} = &$embolden_function( $other_money_char.$amount );
1479       }
1480
1481       # but if it's multisection, don't append to @total_items. the adjust
1482       # section has all this stuff
1483       push @total_items, $_ if !$multisection;
1484       push @buf, [ $item, $money_char.sprintf('%10.2f',$amount) ];
1485     }
1486
1487     push @buf, [ '', '' ];
1488
1489     # if we're showing previous invoices, also show previous
1490     # credits and payments 
1491     if ( $self->enable_previous 
1492           and $self->can('_items_credits')
1493           and $self->can('_items_payments') )
1494       {
1495     
1496       # credits
1497       my $credittotal = 0;
1498       foreach my $credit (
1499         $self->_items_credits( 'template' => $template, 'trim_len' => 40 )
1500       ) {
1501
1502         my $total;
1503         $total->{'total_item'} = &$escape_function($credit->{'description'});
1504         $credittotal += $credit->{'amount'};
1505         $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
1506         if ( $multisection ) {
1507           push @detail_items, {
1508             ext_description => [],
1509             ref          => '',
1510             quantity     => '',
1511             description  => &$escape_function($credit->{'description'}),
1512             amount       => $money_char . $credit->{'amount'},
1513             product_code => '',
1514             section      => $adjust_section,
1515           };
1516         } else {
1517           push @total_items, $total;
1518         }
1519
1520       }
1521       $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
1522
1523       #credits (again)
1524       foreach my $credit (
1525         $self->_items_credits( 'template' => $template, 'trim_len'=>32 )
1526       ) {
1527         push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
1528       }
1529
1530       # payments
1531       my $paymenttotal = 0;
1532       foreach my $payment (
1533         $self->_items_payments( 'template' => $template )
1534       ) {
1535         my $total = {};
1536         $total->{'total_item'} = &$escape_function($payment->{'description'});
1537         $paymenttotal += $payment->{'amount'};
1538         $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
1539         if ( $multisection ) {
1540           push @detail_items, {
1541             ext_description => [],
1542             ref          => '',
1543             quantity     => '',
1544             description  => &$escape_function($payment->{'description'}),
1545             amount       => $money_char . $payment->{'amount'},
1546             product_code => '',
1547             section      => $adjust_section,
1548           };
1549         }else{
1550           push @total_items, $total;
1551         }
1552         push @buf, [ $payment->{'description'},
1553                      $money_char. sprintf("%10.2f", $payment->{'amount'}),
1554                    ];
1555       }
1556       $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
1557     
1558       if ( $multisection ) {
1559         $adjust_section->{'subtotal'} = $other_money_char.
1560                                         sprintf('%.2f', $credittotal + $paymenttotal);
1561
1562         #why this? because {sort_weight} forces the adjust_section to appear
1563         #in @extra_sections instead of @sections. obviously.
1564         push @sections, $adjust_section
1565           unless $adjust_section->{sort_weight};
1566         # do not summarize; adjustments there are shown according to 
1567         # different rules
1568       }
1569
1570       # create Balance Due message
1571       { 
1572         my $total;
1573         $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1574         $total->{'total_amount'} =
1575           &$embolden_function(
1576             $other_money_char. sprintf('%.2f', #why? $summarypage 
1577                                                #  ? $self->charged +
1578                                                #    $self->billing_balance
1579                                                #  :
1580                                                    $balance_due
1581                                       )
1582           );
1583         if ( $multisection && !$adjust_section->{sort_weight} ) {
1584           $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
1585                                            $total->{'total_amount'};
1586         } else {
1587           push @total_items, $total;
1588         }
1589         push @buf,['','-----------'];
1590         push @buf,[$self->balance_due_msg, $money_char. 
1591           sprintf("%10.2f", $balance_due ) ];
1592       }
1593
1594       if ( $conf->exists('previous_balance-show_credit')
1595           and $cust_main->balance < 0 ) {
1596         my $credit_total = {
1597           'total_item'    => &$embolden_function($self->credit_balance_msg),
1598           'total_amount'  => &$embolden_function(
1599             $other_money_char. sprintf('%.2f', -$cust_main->balance)
1600           ),
1601         };
1602         if ( $multisection ) {
1603           $adjust_section->{'posttotal'} .= $newline_token .
1604             $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
1605         }
1606         else {
1607           push @total_items, $credit_total;
1608         }
1609         push @buf,['','-----------'];
1610         push @buf,[$self->credit_balance_msg, $money_char. 
1611           sprintf("%10.2f", -$cust_main->balance ) ];
1612       }
1613     }
1614
1615   } #end of default total adding ! can('_items_total')
1616
1617   if ( $multisection ) {
1618     if (    $conf->exists('svc_phone_sections')
1619          && $self->can('_items_svc_phone_sections')
1620        )
1621     {
1622       my $total;
1623       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1624       $total->{'total_amount'} =
1625         &$embolden_function(
1626           $other_money_char. sprintf('%.2f', $balance_due)
1627         );
1628       my $last_section = pop @sections;
1629       $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
1630                                      $total->{'total_amount'};
1631       push @sections, $last_section;
1632     }
1633     push @sections, @$late_sections
1634       if $unsquelched;
1635   }
1636
1637   # make a discounts-available section, even without multisection
1638   if ( $conf->exists('discount-show_available') 
1639        and my @discounts_avail = $self->_items_discounts_avail ) {
1640     my $discount_section = {
1641       'description' => $self->mt('Discounts Available'),
1642       'subtotal'    => '',
1643       'no_subtotal' => 1,
1644     };
1645
1646     push @sections, $discount_section; # do not summarize
1647     push @detail_items, map { +{
1648         'ref'         => '', #should this be something else?
1649         'section'     => $discount_section,
1650         'description' => &$escape_function( $_->{description} ),
1651         'amount'      => $money_char . &$escape_function( $_->{amount} ),
1652         'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
1653     } } @discounts_avail;
1654   }
1655
1656   # not adding any more sections after this
1657   $invoice_data{summary_subtotals} = \@summary_subtotals;
1658
1659   # usage subtotals
1660   if ( $conf->exists('usage_class_summary')
1661        and $self->can('_items_usage_class_summary') ) {
1662     my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function, 'money_char' => $other_money_char);
1663     if ( @usage_subtotals ) {
1664       unshift @sections, $usage_subtotals[0]->{section}; # do not summarize
1665       unshift @detail_items, @usage_subtotals;
1666     }
1667   }
1668
1669   # invoice history "section" (not really a section)
1670   # not to be included in any subtotals, completely independent of 
1671   # everything...
1672   if ( $conf->exists('previous_invoice_history') and $cust_main->isa('FS::cust_main') ) {
1673     my %history;
1674     my %monthorder;
1675     foreach my $cust_bill ( $cust_main->cust_bill ) {
1676       # XXX hardcoded format, and currently only 'charged'; add other fields
1677       # if they become necessary
1678       my $date = $self->time2str_local('%b %Y', $cust_bill->_date);
1679       $history{$date} ||= 0;
1680       $history{$date} += $cust_bill->charged;
1681       # just so we have a numeric sort key
1682       $monthorder{$date} ||= $cust_bill->_date;
1683     }
1684     my @sorted_months = sort { $monthorder{$a} <=> $monthorder{$b} }
1685                         keys %history;
1686     my @sorted_amounts = map { sprintf('%.2f', $history{$_}) } @sorted_months;
1687     $invoice_data{monthly_history} = [ \@sorted_months, \@sorted_amounts ];
1688   }
1689
1690   # service locations: another option for template customization
1691   my %location_info;
1692   foreach my $item (@detail_items) {
1693     if ( $item->{locationnum} ) {
1694       $location_info{ $item->{locationnum} } ||= {
1695         FS::cust_location->by_key( $item->{locationnum} )->location_hash
1696       };
1697     }
1698   }
1699   $invoice_data{location_info} = \%location_info;
1700
1701   # debugging hook: call this with 'diag' => 1 to just get a hash of 
1702   # the invoice variables
1703   return \%invoice_data if ( $params{'diag'} );
1704
1705   # All sections and items are built; now fill in templates.
1706   my @includelist = ();
1707   push @includelist, 'summary' if $summarypage;
1708   foreach my $include ( @includelist ) {
1709
1710     my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
1711     my @inc_src;
1712
1713     if ( length( $conf->config($inc_file, $agentnum) ) ) {
1714
1715       @inc_src = $conf->config($inc_file, $agentnum);
1716
1717     } else {
1718
1719       $inc_file = $conf->key_orbase("invoice_latex$include", $template);
1720
1721       my $convert_map = $convert_maps{$format}{$include};
1722
1723       @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
1724                        s/--\@\]/$delimiters{$format}[1]/g;
1725                        $_;
1726                      } 
1727                  &$convert_map( $conf->config($inc_file, $agentnum) );
1728
1729     }
1730
1731     my $inc_tt = new Text::Template (
1732       TYPE       => 'ARRAY',
1733       SOURCE     => [ map "$_\n", @inc_src ],
1734       DELIMITERS => $delimiters{$format},
1735     ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
1736
1737     unless ( $inc_tt->compile() ) {
1738       my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
1739       warn $error. "Template:\n". join('', map "$_\n", @inc_src);
1740       die $error;
1741     }
1742
1743     $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
1744
1745     $invoice_data{$include} =~ s/\n+$//
1746       if ($format eq 'latex');
1747   }
1748
1749   $invoice_lines = 0;
1750   my $wasfunc = 0;
1751   foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
1752     /invoice_lines\((\d*)\)/;
1753     $invoice_lines += $1 || scalar(@buf);
1754     $wasfunc=1;
1755   }
1756   die "no invoice_lines() functions in template?"
1757     if ( $format eq 'template' && !$wasfunc );
1758
1759   if ( $invoice_lines ) {
1760     $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
1761     $invoice_data{'total_pages'}++
1762       if scalar(@buf) % $invoice_lines;
1763   }
1764
1765   #setup subroutine for the template
1766   $invoice_data{invoice_lines} = sub {
1767     my $lines = shift || scalar(@buf);
1768     map { 
1769       scalar(@buf)
1770         ? shift @buf
1771         : [ '', '' ];
1772     }
1773     ( 1 .. $lines );
1774   };
1775
1776   if ($format eq 'template') {
1777
1778     my $lines;
1779     my @collect;
1780     while (@buf) {
1781       push @collect, split("\n",
1782         $text_template->fill_in( HASH => \%invoice_data )
1783       );
1784       $invoice_data{'page'}++;
1785     }
1786     map "$_\n", @collect;
1787
1788   } else { # this is where we actually create the invoice
1789
1790     if ( $params{no_addresses} ) {
1791       delete $invoice_data{$_} foreach qw(
1792         payname company address1 address2 city state zip country
1793       );
1794       $invoice_data{returnaddress} = '~';
1795     }
1796
1797     warn "filling in template for invoice ". $self->invnum. "\n"
1798       if $DEBUG;
1799     warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
1800       if $DEBUG > 1;
1801
1802     $text_template->fill_in(HASH => \%invoice_data);
1803   }
1804 }
1805
1806 sub notice_name { '('.shift->table.')'; }
1807
1808 # this is not supposed to happen
1809 sub template_conf { warn "bare FS::Template_Mixin::template_conf";
1810   'invoice_';
1811 }
1812
1813 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
1814
1815 Returns an postscript invoice, as a scalar.
1816
1817 Options can be passed as a hashref (recommended) or as a list of time, template
1818 and then any key/value pairs for any other options.
1819
1820 I<time> an optional value used to control the printing of overdue messages.  The
1821 default is now.  It isn't the date of the invoice; that's the `_date' field.
1822 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1823 L<Time::Local> and L<Date::Parse> for conversion functions.
1824
1825 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1826
1827 =cut
1828
1829 sub print_ps {
1830   my $self = shift;
1831
1832   my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1833   my $ps = generate_ps($file);
1834   unlink($logofile);
1835   unlink($barcodefile) if $barcodefile;
1836
1837   $ps;
1838 }
1839
1840 =item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
1841
1842 Returns an PDF invoice, as a scalar.
1843
1844 Options can be passed as a hashref (recommended) or as a list of time, template
1845 and then any key/value pairs for any other options.
1846
1847 I<time> an optional value used to control the printing of overdue messages.  The
1848 default is now.  It isn't the date of the invoice; that's the `_date' field.
1849 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1850 L<Time::Local> and L<Date::Parse> for conversion functions.
1851
1852 I<template>, if specified, is the name of a suffix for alternate invoices.
1853
1854 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1855
1856 =cut
1857
1858 sub print_pdf {
1859   my $self = shift;
1860
1861   my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1862   my $pdf = generate_pdf($file);
1863   unlink($logofile);
1864   unlink($barcodefile) if $barcodefile;
1865
1866   $pdf;
1867 }
1868
1869 =item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
1870
1871 Returns an HTML invoice, as a scalar.
1872
1873 I<time> an optional value used to control the printing of overdue messages.  The
1874 default is now.  It isn't the date of the invoice; that's the `_date' field.
1875 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1876 L<Time::Local> and L<Date::Parse> for conversion functions.
1877
1878 I<template>, if specified, is the name of a suffix for alternate invoices.
1879
1880 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1881
1882 I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
1883 when emailing the invoice as part of a multipart/related MIME email.
1884
1885 =cut
1886
1887 sub print_html {
1888   my $self = shift;
1889   my %params;
1890   if ( ref($_[0]) ) {
1891     %params = %{ shift() }; 
1892   } else {
1893     %params = @_;
1894   }
1895   $params{'format'} = 'html';
1896   
1897   $self->print_generic( %params );
1898 }
1899
1900 # quick subroutine for print_latex
1901 #
1902 # There are ten characters that LaTeX treats as special characters, which
1903 # means that they do not simply typeset themselves: 
1904 #      # $ % & ~ _ ^ \ { }
1905 #
1906 # TeX ignores blanks following an escaped character; if you want a blank (as
1907 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). 
1908
1909 sub _latex_escape {
1910   my $value = shift;
1911   $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
1912   $value =~ s/([<>])/\$$1\$/g;
1913   $value;
1914 }
1915
1916 sub _html_escape {
1917   my $value = shift;
1918   encode_entities($value);
1919   $value;
1920 }
1921
1922 sub _html_escape_nbsp {
1923   my $value = _html_escape(shift);
1924   $value =~ s/ +/&nbsp;/g;
1925   $value;
1926 }
1927
1928 #utility methods for print_*
1929
1930 sub _translate_old_latex_format {
1931   warn "_translate_old_latex_format called\n"
1932     if $DEBUG; 
1933
1934   my @template = ();
1935   while ( @_ ) {
1936     my $line = shift;
1937   
1938     if ( $line =~ /^%%Detail\s*$/ ) {
1939   
1940       push @template, q![@--!,
1941                       q!  foreach my $_tr_line (@detail_items) {!,
1942                       q!    if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
1943                       q!      $_tr_line->{'description'} .= !, 
1944                       q!        "\\tabularnewline\n~~".!,
1945                       q!        join( "\\tabularnewline\n~~",!,
1946                       q!          @{$_tr_line->{'ext_description'}}!,
1947                       q!        );!,
1948                       q!    }!;
1949
1950       while ( ( my $line_item_line = shift )
1951               !~ /^%%EndDetail\s*$/                            ) {
1952         $line_item_line =~ s/'/\\'/g;    # nice LTS
1953         $line_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
1954         $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1955         push @template, "    \$OUT .= '$line_item_line';";
1956       }
1957
1958       push @template, '}',
1959                       '--@]';
1960       #' doh, gvim
1961     } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1962
1963       push @template, '[@--',
1964                       '  foreach my $_tr_line (@total_items) {';
1965
1966       while ( ( my $total_item_line = shift )
1967               !~ /^%%EndTotalDetails\s*$/                      ) {
1968         $total_item_line =~ s/'/\\'/g;    # nice LTS
1969         $total_item_line =~ s/\\/\\\\/g;  # escape quotes and backslashes
1970         $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1971         push @template, "    \$OUT .= '$total_item_line';";
1972       }
1973
1974       push @template, '}',
1975                       '--@]';
1976
1977     } else {
1978       $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
1979       push @template, $line;  
1980     }
1981   
1982   }
1983
1984   if ($DEBUG) {
1985     warn "$_\n" foreach @template;
1986   }
1987
1988   (@template);
1989 }
1990
1991 =item terms
1992
1993 =cut
1994
1995 sub terms {
1996   my $self = shift;
1997   my $conf = $self->conf;
1998
1999   #check for an invoice-specific override
2000   return $self->invoice_terms if $self->invoice_terms;
2001   
2002   #check for a customer- specific override
2003   my $cust_main = $self->cust_main;
2004   return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
2005
2006   my $agentnum = '';
2007   if ( $cust_main ) {
2008     $agentnum = $cust_main->agentnum;
2009   } elsif ( my $prospect_main = $self->prospect_main ) {
2010     $agentnum = $prospect_main->agentnum;
2011   }
2012
2013   #use configured default
2014   $conf->config('invoice_default_terms', $agentnum) || '';
2015 }
2016
2017 =item due_date
2018
2019 =cut
2020
2021 sub due_date {
2022   my $self = shift;
2023   my $duedate = '';
2024   if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
2025     $duedate = $self->_date() + ( $1 * 86400 );
2026   } elsif ( $self->terms =~ /^End of Month$/ ) {
2027     my ($mon,$year) = (localtime($self->_date) )[4,5];
2028     $mon++;
2029     until ( $mon < 12 ) { $mon -= 12; $year++; }
2030     my $nextmonth_first = timelocal(0,0,0,1,$mon,$year);
2031     $duedate = $nextmonth_first - 86400;
2032   }
2033   $duedate;
2034 }
2035
2036 =item due_date2str
2037
2038 =cut
2039
2040 sub due_date2str {
2041   my $self = shift;
2042   $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
2043 }
2044
2045 =item invoice_pay_by_msg
2046
2047   displays the invoice_pay_by_msg or default Please pay by [_1] if empty.
2048
2049 =cut
2050
2051 sub invoice_pay_by_msg {
2052   my $self = shift;
2053   my $msg = '';
2054   my $please_pay_by =
2055         $self->conf->config('invoice_pay_by_msg', $self->agentnum)
2056         || 'Please pay by [_1]';
2057   $msg .= ' - ' . $self->mt($please_pay_by, $self->due_date2str('short')) . ' ';
2058
2059   $msg;
2060 }
2061
2062 =item balance_due_msg
2063
2064 =cut
2065
2066 sub balance_due_msg {
2067   my $self = shift;
2068   my $msg = $self->mt('Balance Due');
2069   return $msg unless $self->terms; # huh?
2070   if ( !$self->conf->exists('invoice_show_prior_due_date')
2071        || $self->has_sections ) {
2072     # if enabled, the due date is shown with Total New Charges (see 
2073     # _items_total) and not here
2074     # (yes, or if invoice_sections is enabled; this is just for compatibility)
2075     if ( $self->due_date ) {
2076       $msg .= $self->invoice_pay_by_msg
2077        unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum);
2078     } elsif ( $self->terms ) {
2079       $msg .= ' - '. $self->mt($self->terms);
2080     }
2081   }
2082   $msg;
2083 }
2084
2085 =item balance_due_date
2086
2087 =cut
2088
2089 sub balance_due_date {
2090   my $self = shift;
2091   my $conf = $self->conf;
2092   my $duedate = '';
2093   my $terms = $self->terms;
2094   if ( $terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
2095     $duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
2096   }
2097   $duedate;
2098 }
2099
2100 sub credit_balance_msg { 
2101   my $self = shift;
2102   $self->mt('Credit Balance Remaining')
2103 }
2104
2105 =item _date_pretty
2106
2107 Returns a string with the date, for example: "3/20/2008", localized for the
2108 customer.  Use _date_pretty_unlocalized for non-end-customer display use.
2109
2110 =cut
2111
2112 sub _date_pretty {
2113   my $self = shift;
2114   $self->time2str_local('short', $self->_date);
2115 }
2116
2117 =item _date_pretty_unlocalized
2118
2119 Returns a string with the date, for example: "3/20/2008", in the format
2120 configured for the back-office.  Use _date_pretty for end-customer display use.
2121
2122 =cut
2123
2124 sub _date_pretty_unlocalized {
2125   my $self = shift;
2126   time2str($date_format, $self->_date);
2127 }
2128
2129 =item email HASHREF
2130
2131 Emails this template.
2132
2133 Options are passed as a hashref.  Available options:
2134
2135 =over 4
2136
2137 =item from
2138
2139 If specified, overrides the default From: address.
2140
2141 =item notice_name
2142
2143 If specified, overrides the name of the sent document ("Invoice" or "Quotation")
2144
2145 =item template
2146
2147 (Deprecated) If specified, is the name of a suffix for alternate template files.
2148
2149 =back
2150
2151 Options accepted by generate_email can also be used.
2152
2153 =cut
2154
2155 sub email {
2156   my $self = shift;
2157   my $opt = shift || {};
2158   if ($opt and !ref($opt)) {
2159     die ref($self). '->email called with positional parameters';
2160   }
2161
2162   return if $self->hide;
2163
2164   my $error = send_email(
2165     $self->generate_email(
2166       'subject'     => $self->email_subject($opt->{template}),
2167       %$opt, # template, etc.
2168     )
2169   );
2170
2171   die "can't email: $error\n" if $error;
2172 }
2173
2174 =item generate_email OPTION => VALUE ...
2175
2176 Options:
2177
2178 =over 4
2179
2180 =item from
2181
2182 sender address, required
2183
2184 =item template
2185
2186 alternate template name, optional
2187
2188 =item subject
2189
2190 email subject, optional
2191
2192 =item notice_name
2193
2194 notice name instead of "Invoice", optional
2195
2196 =back
2197
2198 Returns an argument list to be passed to L<FS::Misc/send_email>.
2199
2200 =cut
2201
2202 use MIME::Entity;
2203 use Encode;
2204
2205 sub generate_email {
2206
2207   my $self = shift;
2208   my %args = @_;
2209   my $conf = $self->conf;
2210
2211   my $me = '[FS::Template_Mixin::generate_email]';
2212
2213   my %return = (
2214     'from'      => $args{'from'},
2215     'subject'   => ($args{'subject'} || $self->email_subject),
2216     'custnum'   => $self->custnum,
2217     'msgtype'   => 'invoice',
2218   );
2219
2220   $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
2221
2222   my $cust_main = $self->cust_main;
2223
2224   if (ref($args{'to'}) eq 'ARRAY') {
2225     $return{'to'} = $args{'to'};
2226   } elsif ( $cust_main ) {
2227     $return{'to'} = [ $cust_main->invoicing_list_emailonly ];
2228   }
2229
2230   my $tc = $self->template_conf;
2231
2232   my @text; # array of lines
2233   my $html; # a big string
2234   my @related_parts; # will contain the text/HTML alternative, and images
2235   my $related; # will contain the multipart/related object
2236
2237   if ( $conf->exists($tc. 'email_pdf') ) {
2238     if ( my $msgnum = $conf->config($tc.'email_pdf_msgnum') ) {
2239
2240       warn "$me using '${tc}email_pdf_msgnum' in multipart message"
2241         if $DEBUG;
2242
2243       my $msg_template = FS::msg_template->by_key($msgnum)
2244         or die "${tc}email_pdf_msgnum $msgnum not found\n";
2245       my $cust_msg = $msg_template->prepare(
2246         cust_main => $self->cust_main,
2247         object    => $self,
2248         msgtype   => 'invoice',
2249       );
2250
2251       # XXX hack to make this work in the new cust_msg era; consider replacing
2252       # with cust_bill_send_with_notice events.
2253       my @parts = $cust_msg->parts;
2254       foreach my $part (@parts) { # will only have two parts, normally
2255         if ( $part->mime_type eq 'text/plain' ) {
2256           @text = @{ $part->body };
2257         } elsif ( $part->mime_type eq 'text/html' ) {
2258           $html = $part->bodyhandle->as_string;
2259         }
2260       }
2261
2262     } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) {
2263
2264       warn "$me using '${tc}email_pdf_note' in multipart message"
2265         if $DEBUG;
2266       @text = $conf->config($tc.'email_pdf_note');
2267       $html = join('<BR>', @text);
2268   
2269     } # else use the plain text invoice
2270   }
2271
2272   if (!@text) {
2273
2274     if ( $conf->exists($tc.'template') ) {
2275
2276       warn "$me generating plain text invoice"
2277         if $DEBUG;
2278
2279       @text = $self->print_text(\%args);
2280
2281     } else {
2282
2283       warn "$me no plain text version exists; sending empty message body"
2284         if $DEBUG;
2285
2286     }
2287
2288   }
2289
2290   my $text_part = build MIME::Entity (
2291     'Type'        => 'text/plain',
2292     'Encoding'    => 'quoted-printable',
2293     'Charset'     => 'UTF-8',
2294     #'Encoding'    => '7bit',
2295     'Data'        => [
2296       map
2297         { Encode::encode('UTF-8', $_, Encode::FB_WARN | Encode::LEAVE_SRC ) }
2298         @text
2299     ],
2300     'Disposition' => 'inline',
2301   );
2302
2303   if (!$html) {
2304
2305     if ( $conf->exists($tc.'html') ) {
2306       warn "$me generating HTML invoice"
2307         if $DEBUG;
2308
2309       $args{'from'} =~ /\@([\w\.\-]+)/;
2310       my $from = $1 || 'example.com';
2311       my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
2312
2313       my $logo;
2314       my $agentnum = $cust_main ? $cust_main->agentnum
2315                                 : $self->prospect_main->agentnum;
2316       if ( defined($args{'template'}) && length($args{'template'})
2317            && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
2318          )
2319       {
2320         $logo = 'logo_'. $args{'template'}. '.png';
2321       } else {
2322         $logo = "logo.png";
2323       }
2324       my $image_data = $conf->config_binary( $logo, $agentnum);
2325
2326       push @related_parts, build MIME::Entity
2327         'Type'       => 'image/png',
2328         'Encoding'   => 'base64',
2329         'Data'       => $image_data,
2330         'Filename'   => 'logo.png',
2331         'Content-ID' => "<$content_id>",
2332       ;
2333    
2334       if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) {
2335         my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
2336         push @related_parts, build MIME::Entity
2337           'Type'       => 'image/png',
2338           'Encoding'   => 'base64',
2339           'Data'       => $self->invoice_barcode(0),
2340           'Filename'   => 'barcode.png',
2341           'Content-ID' => "<$barcode_content_id>",
2342         ;
2343         $args{'barcode_cid'} = $barcode_content_id;
2344       }
2345
2346       $html = $self->print_html({ 'cid'=>$content_id, %args });
2347     }
2348
2349   }
2350
2351   if ( $html ) {
2352
2353     warn "$me creating HTML/text multipart message"
2354       if $DEBUG;
2355
2356     $return{'nobody'} = 1;
2357
2358     my $alternative = build MIME::Entity
2359       'Type'        => 'multipart/alternative',
2360       #'Encoding'    => '7bit',
2361       'Disposition' => 'inline'
2362     ;
2363
2364     if ( @text ) {
2365       $alternative->add_part($text_part);
2366     }
2367
2368     $alternative->attach(
2369       'Type'        => 'text/html',
2370       'Encoding'    => 'quoted-printable',
2371       'Data'        => [ '<html>',
2372                          '  <head>',
2373                          '    <title>',
2374                          '      '. encode_entities($return{'subject'}), 
2375                          '    </title>',
2376                          '  </head>',
2377                          '  <body bgcolor="#e8e8e8">',
2378                          Encode::encode(
2379                            'UTF-8',
2380                            $html,
2381                            Encode::FB_WARN | Encode::LEAVE_SRC
2382                          ),
2383                          '  </body>',
2384                          '</html>',
2385                        ],
2386       'Disposition' => 'inline',
2387       #'Filename'    => 'invoice.pdf',
2388     );
2389
2390     unshift @related_parts, $alternative;
2391
2392     $related = build MIME::Entity 'Type'     => 'multipart/related',
2393                                   'Encoding' => '7bit';
2394
2395     #false laziness w/Misc::send_email
2396     $related->head->replace('Content-type',
2397       $related->mime_type.
2398       '; boundary="'. $related->head->multipart_boundary. '"'.
2399       '; type=multipart/alternative'
2400     );
2401
2402     $related->add_part($_) foreach @related_parts;
2403
2404   }
2405
2406   my @otherparts = ();
2407   if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
2408
2409     if ( $conf->config('voip-cdr_email_attach') eq 'zip' ) {
2410
2411       my $data = join('', map "$_\n",
2412                    $self->call_details(prepend_billed_number=>1)
2413                  );
2414
2415       my $zip = new Archive::Zip;
2416       my $file = $zip->addString( $data, 'usage-'.$self->invnum.'.csv' );
2417       $file->desiredCompressionMethod( COMPRESSION_DEFLATED );
2418
2419       my $zipdata = '';
2420       my $SH = IO::Scalar->new(\$zipdata);
2421       my $status = $zip->writeToFileHandle($SH);
2422       die "Error zipping CDR attachment: $!" unless $status == AZ_OK;
2423
2424       push @otherparts, build MIME::Entity
2425         'Type'        => 'application/zip',
2426         'Encoding'    => 'base64',
2427         'Data'        => $zipdata,
2428         'Disposition' => 'attachment',
2429         'Filename'    => 'usage-'. $self->invnum. '.zip',
2430       ;
2431
2432     } else { # } elsif ( $conf->config('voip-cdr_email_attach') eq 'csv' ) {
2433  
2434       push @otherparts, build MIME::Entity
2435         'Type'        => 'text/csv',
2436         'Encoding'    => '7bit',
2437         'Data'        => [ map { "$_\n" }
2438                              $self->call_details('prepend_billed_number' => 1)
2439                          ],
2440         'Disposition' => 'attachment',
2441         'Filename'    => 'usage-'. $self->invnum. '.csv',
2442       ;
2443
2444     }
2445
2446   }
2447
2448   if ( $conf->exists($tc.'email_pdf') ) {
2449
2450     #attaching pdf too:
2451     # multipart/mixed
2452     #   multipart/related
2453     #     multipart/alternative
2454     #       text/plain
2455     #       text/html
2456     #     image/png
2457     #   application/pdf
2458
2459     my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
2460     push @otherparts, $pdf;
2461   }
2462
2463   if (@otherparts) {
2464     $return{'content-type'} = 'multipart/mixed'; # of the outer container
2465     if ( $html ) {
2466       $return{'mimeparts'} = [ $related, @otherparts ];
2467       $return{'type'} = 'multipart/related'; # of the first part
2468     } else {
2469       $return{'mimeparts'} = [ $text_part, @otherparts ];
2470       $return{'type'} = 'text/plain';
2471     }
2472   } elsif ( $html ) { # no PDF or CSV, strip the outer container
2473     $return{'mimeparts'} = \@related_parts;
2474     $return{'content-type'} = 'multipart/related';
2475     $return{'type'} = 'multipart/alternative';
2476   } else { # no HTML either
2477     $return{'body'} = \@text;
2478     $return{'content-type'} = 'text/plain';
2479   }
2480
2481   %return;
2482
2483 }
2484
2485 =item mimebuild_pdf
2486
2487 Returns a list suitable for passing to MIME::Entity->build(), representing
2488 this quotation or invoice as PDF attachment.
2489
2490 =cut
2491
2492 sub mimebuild_pdf {
2493   my $self = shift;
2494   (
2495     'Type'        => 'application/pdf',
2496     'Encoding'    => 'base64',
2497     'Data'        => [ $self->print_pdf(@_) ],
2498     'Disposition' => 'attachment',
2499     'Filename'    => $self->pdf_filename,
2500   );
2501 }
2502
2503 =item postal_mail_fsinc
2504
2505 Sends this invoice to the Freeside Internet Services, Inc. print and mail
2506 service.
2507
2508 =cut
2509
2510 use CAM::PDF;
2511 use IO::Socket::SSL;
2512 use LWP::UserAgent;
2513 use HTTP::Request::Common qw( POST );
2514 use Cpanel::JSON::XS;
2515 use MIME::Base64;
2516 sub postal_mail_fsinc {
2517   my ( $self, %opt ) = @_;
2518
2519   if ( $FS::Misc::DISABLE_PRINT ) {
2520     warn 'postal_mail_fsinc() disabled by $FS::Misc::DISABLE_PRINT' if $DEBUG;
2521     return;
2522   }
2523
2524   my $url = 'https://ws.freeside.biz/print';
2525
2526   my $cust_main = $self->cust_main;
2527   my $agentnum = $cust_main->agentnum;
2528   my $bill_location = $cust_main->bill_location;
2529
2530   die "Extra charges for international mailing; contact support\@freeside.biz to enable\n"
2531     if $bill_location->country ne 'US';
2532
2533   my $conf = new FS::Conf;
2534
2535   my @company_address = $conf->config('company_address', $agentnum);
2536   my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip );
2537   if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
2538     $company_address1 = $company_address[0];
2539     $company_address2 = $company_address[1];
2540     $company_city  = $1;
2541     $company_state = $2;
2542     $company_zip   = $3;
2543   } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
2544     $company_address1 = $company_address[0];
2545     $company_address2 = '';
2546     $company_city  = $1;
2547     $company_state = $2;
2548     $company_zip   = $3;
2549   } else {
2550     die "Unparsable company_address; contact support\@freeside.biz\n";
2551   }
2552   $company_city =~ s/,$//;
2553
2554   my $file = $self->print_pdf(%opt, 'no_addresses' => 1);
2555   my $pages = CAM::PDF->new($file)->numPages;
2556
2557   my $ua = LWP::UserAgent->new(
2558     'ssl_opts' => { 
2559       verify_hostname => 0,
2560       SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
2561       SSL_version     => 'SSLv3',
2562     }
2563   );
2564   my $response = $ua->request( POST $url, [
2565     'support-key'      => scalar($conf->config('support-key')),
2566     'file'             => encode_base64($file),
2567     'pages'            => $pages,
2568
2569     #from:
2570     'company_name'     => scalar( $conf->config('company_name', $agentnum) ),
2571     'company_address1' => $company_address1,
2572     'company_address2' => $company_address2,
2573     'company_city'     => $company_city,
2574     'company_state'    => $company_state,
2575     'company_zip'      => $company_zip,
2576     'company_country'  => 'US',
2577     'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)),
2578     'company_email'    => scalar($conf->config('invoice_from', $agentnum)),
2579
2580     #to:
2581     'name'             => $cust_main->invoice_attn
2582                             || $cust_main->contact_firstlast,
2583     'company'          => $cust_main->company,
2584     'address1'         => $bill_location->address1,
2585     'address2'         => $bill_location->address2,
2586     'city'             => $bill_location->city,
2587     'state'            => $bill_location->state,
2588     'zip'              => $bill_location->zip,
2589     'country'          => $bill_location->country,
2590   ]);
2591
2592   die "Print connection error: ". $response->message.
2593       ' ('. $response->as_string. ")\n"
2594     unless $response->is_success;
2595
2596   local $@;
2597   my $content = eval { decode_json($response->content) };
2598   die "Print JSON error : $@\n" if $@;
2599
2600   die $content->{error}."\n"
2601     if $content->{error};
2602
2603   #TODO: store this so we can query for a status later
2604   warn "Invoice printed, ID ". $content->{id}. "\n";
2605
2606   $content->{id};
2607
2608 }
2609
2610 =item _items_sections OPTIONS
2611
2612 Generate section information for all items appearing on this invoice.
2613 This will only be called for multi-section invoices.
2614
2615 For each line item (L<FS::cust_bill_pkg> record), this will fetch all 
2616 related display records (L<FS::cust_bill_pkg_display>) and organize 
2617 them into two groups ("early" and "late" according to whether they come 
2618 before or after the total), then into sections.  A subtotal is calculated 
2619 for each section.
2620
2621 Section descriptions are returned in sort weight order.  Each consists 
2622 of a hash containing:
2623
2624 description: the package category name, escaped
2625 subtotal: the total charges in that section
2626 tax_section: a flag indicating that the section contains only tax charges
2627 summarized: same as tax_section, for some reason
2628 sort_weight: the package category's sort weight
2629
2630 If 'condense' is set on the display record, it also contains everything 
2631 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
2632 coderefs to generate parts of the invoice.  This is not advised.
2633
2634 The method returns two arrayrefs, one of "early" sections and one of "late"
2635 sections.
2636
2637 OPTIONS may include:
2638
2639 by_location: a flag to divide the invoice into sections by location.  
2640 Each section hash will have a 'location' element containing a hashref of 
2641 the location fields (see L<FS::cust_location>).  The section description
2642 will be the location label, but the template can use any of the location 
2643 fields to create a suitable label.
2644
2645 by_category: a flag to divide the invoice into sections using display 
2646 records (see L<FS::cust_bill_pkg_display>).  This is the "traditional" 
2647 behavior.  Each section hash will have a 'category' element containing
2648 the section name from the display record (which probably equals the 
2649 category name of the package, but may not in some cases).
2650
2651 summary: a flag indicating that this is a summary-format invoice.
2652 Turning this on has the following effects:
2653 - Ignores display items with the 'summary' flag.
2654 - Places all sections in the "early" group even if they have post_total.
2655 - Creates sections for all non-disabled package categories, even if they 
2656 have no charges on this invoice, as well as a section with no name.
2657
2658 escape: an escape function to use for section titles.
2659
2660 extra_sections: an arrayref of additional sections to return after the 
2661 sorted list.  If there are any of these, section subtotals exclude 
2662 usage charges.
2663
2664 format: 'latex', 'html', or 'template' (i.e. text).  Not used, but 
2665 passed through to C<_condense_section()>.
2666
2667 =cut
2668
2669 use vars qw(%pkg_category_cache);
2670 sub _items_sections {
2671   my $self = shift;
2672   my %opt = @_;
2673   
2674   my $escape = $opt{escape};
2675   my @extra_sections = @{ $opt{extra_sections} || [] };
2676
2677   # $subtotal{$locationnum}{$categoryname} = amount.
2678   # if we're not using by_location, $locationnum is undef.
2679   # if we're not using by_category, you guessed it, $categoryname is undef.
2680   # if we're not using either one, we shouldn't be here in the first place...
2681   my %subtotal = ();
2682   my %late_subtotal = ();
2683   my %not_tax = ();
2684
2685   # About tax items + multisection invoices:
2686   # If either invoice_*summary option is enabled, AND there is a 
2687   # package category with the name of the tax, then there will be 
2688   # a display record assigning the tax item to that category.
2689   #
2690   # However, the taxes are always placed in the "Taxes, Surcharges,
2691   # and Fees" section regardless of that.  The only effect of the 
2692   # display record is to create a subtotal for the summary page.
2693
2694   # cache these
2695   my $pkg_hash = $self->cust_pkg_hash;
2696
2697   foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
2698   {
2699
2700       my $usage = $cust_bill_pkg->usage;
2701
2702       my $locationnum;
2703       if ( $opt{by_location} ) {
2704         if ( $cust_bill_pkg->pkgnum ) {
2705           $locationnum = $pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum;
2706         } else {
2707           $locationnum = '';
2708         }
2709       } else {
2710         $locationnum = undef;
2711       }
2712
2713       # as in _items_cust_pkg, if a line item has no display records,
2714       # cust_bill_pkg_display() returns a default record for it
2715
2716       foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
2717         next if ( $display->summary && $opt{summary} );
2718
2719         #my $section = $display->section;
2720         #false laziness with the method, but for efficiency inside this loop
2721         my $section = $display->get('section');
2722         if ( !$section && !$cust_bill_pkg->hidden ) {
2723           $section = $cust_bill_pkg->get('categoryname'); #cust_bill->cust_bill_pkg added it (XXX quotations / quotation_section)
2724         }
2725
2726         my $type    = $display->type;
2727         # Set $section = undef if we're sectioning by location and this
2728         # line item _has_ a location (i.e. isn't a fee).
2729         $section = undef if $locationnum;
2730
2731         # set this flag if the section is not tax-only
2732         $not_tax{$locationnum}{$section} = 1
2733           if $cust_bill_pkg->pkgnum  or $cust_bill_pkg->feepart;
2734
2735         # there's actually a very important piece of logic buried in here:
2736         # incrementing $late_subtotal{$section} CREATES 
2737         # $late_subtotal{$section}.  keys(%late_subtotal) is later used 
2738         # to define the list of late sections, and likewise keys(%subtotal).
2739         # When _items_cust_bill_pkg is called to generate line items for 
2740         # real, it will be called with 'section' => $section for each 
2741         # of these.
2742         if ( $display->post_total && !$opt{summary} ) {
2743           if (! $type || $type eq 'S') {
2744             $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
2745               if $cust_bill_pkg->setup != 0
2746               || $cust_bill_pkg->setup_show_zero;
2747           }
2748
2749           if (! $type) {
2750             $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
2751               if $cust_bill_pkg->recur != 0
2752               || $cust_bill_pkg->recur_show_zero;
2753           }
2754
2755           if ($type && $type eq 'R') {
2756             $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
2757               if $cust_bill_pkg->recur != 0
2758               || $cust_bill_pkg->recur_show_zero;
2759           }
2760           
2761           if ($type && $type eq 'U') {
2762             $late_subtotal{$locationnum}{$section} += $usage
2763               unless scalar(@extra_sections);
2764           }
2765
2766         } else { # it's a pre-total (normal) section
2767
2768           # skip tax items unless they're explicitly included in a section
2769           next if $cust_bill_pkg->pkgnum == 0 and
2770                   ! $cust_bill_pkg->feepart   and
2771                   ! $section;
2772
2773           if ( $type eq 'S' ) {
2774             $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
2775               if $cust_bill_pkg->setup != 0
2776               || $cust_bill_pkg->setup_show_zero;
2777           } elsif ( $type eq 'R' ) {
2778             $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
2779               if $cust_bill_pkg->recur != 0
2780               || $cust_bill_pkg->recur_show_zero;
2781           } elsif ( $type eq 'U' ) {
2782             $subtotal{$locationnum}{$section} += $usage
2783               unless scalar(@extra_sections);
2784           } elsif ( !$type ) {
2785             $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
2786                                                + $cust_bill_pkg->recur;
2787           }
2788
2789         }
2790
2791       }
2792
2793   }
2794
2795   %pkg_category_cache = ();
2796
2797   # summary invoices need subtotals for all non-disabled package categories,
2798   # even if they're zero
2799   # but currently assume that there are no location sections, or at least
2800   # that the summary page doesn't care about them
2801   if ( $opt{summary} ) {
2802     foreach my $category (qsearch('pkg_category', {disabled => ''})) {
2803       $subtotal{''}{$category->categoryname} ||= 0;
2804     }
2805     $subtotal{''}{''} ||= 0;
2806   }
2807
2808   my @sections;
2809   foreach my $post_total (0,1) {
2810     my @these;
2811     my $s = $post_total ? \%late_subtotal : \%subtotal;
2812     foreach my $locationnum (keys %$s) {
2813       foreach my $sectionname (keys %{ $s->{$locationnum} }) {
2814         my $section = {
2815                         'subtotal'    => $s->{$locationnum}{$sectionname},
2816                         'sort_weight' => 0,
2817                       };
2818         if ( $locationnum ) {
2819           $section->{'locationnum'} = $locationnum;
2820           my $location = FS::cust_location->by_key($locationnum);
2821           $section->{'description'} = &{ $escape }($location->location_label);
2822           # Better ideas? This will roughly group them by proximity, 
2823           # which alpha sorting on any of the address fields won't.
2824           # Sorting by locationnum is meaningless.
2825           # We have to sort on _something_ or the order may change 
2826           # randomly from one invoice to the next, which will confuse
2827           # people.
2828           $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
2829                                       $locationnum;
2830           $section->{'location'} = {
2831             label_prefix => &{ $escape }($location->label_prefix),
2832             map { $_ => &{ $escape }($location->get($_)) }
2833               $location->fields
2834           };
2835         } else {
2836           $section->{'category'} = $sectionname;
2837           $section->{'description'} = &{ $escape }($sectionname);
2838           if ( _pkg_category($sectionname) ) {
2839             $section->{'sort_weight'} = _pkg_category($sectionname)->weight;
2840             if ( _pkg_category($sectionname)->condense ) {
2841               $section = { %$section, $self->_condense_section($opt{format}) };
2842             }
2843           }
2844         }
2845         if ( !$post_total and !$not_tax{$locationnum}{$sectionname} ) {
2846           # then it's a tax-only section
2847           $section->{'summarized'} = 'Y';
2848           $section->{'tax_section'} = 'Y';
2849         }
2850         push @these, $section;
2851       } # foreach $sectionname
2852     } #foreach $locationnum
2853     push @these, @extra_sections if $post_total == 0;
2854     # need an alpha sort for location sections, because postal codes can 
2855     # be non-numeric
2856     $sections[ $post_total ] = [ sort {
2857       $opt{'by_location'} ? 
2858         ($a->{sort_weight} cmp $b->{sort_weight}) :
2859         ($a->{sort_weight} <=> $b->{sort_weight})
2860       } @these ];
2861   } #foreach $post_total
2862
2863   return @sections; # early, late
2864 }
2865
2866 #helper subs for above
2867
2868 sub cust_pkg_hash {
2869   my $self = shift;
2870   $self->{cust_pkg} ||= { map { $_->pkgnum => $_ } $self->cust_pkg };
2871 }
2872
2873 sub _pkg_category {
2874   my $categoryname = shift;
2875   $pkg_category_cache{$categoryname} ||=
2876     qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
2877 }
2878
2879 my %condensed_format = (
2880   'label' => [ qw( Description Qty Amount ) ],
2881   'fields' => [
2882                 sub { shift->{description} },
2883                 sub { shift->{quantity} },
2884                 sub { my($href, %opt) = @_;
2885                       ($opt{dollar} || ''). $href->{amount};
2886                     },
2887               ],
2888   'align'  => [ qw( l r r ) ],
2889   'span'   => [ qw( 5 1 1 ) ],            # unitprices?
2890   'width'  => [ qw( 10.7cm 1.4cm 1.6cm ) ],   # don't like this
2891 );
2892
2893 sub _condense_section {
2894   my ( $self, $format ) = ( shift, shift );
2895   ( 'condensed' => 1,
2896     map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
2897       qw( description_generator
2898           header_generator
2899           total_generator
2900           total_line_generator
2901         )
2902   );
2903 }
2904
2905 sub _condensed_generator_defaults {
2906   my ( $self, $format ) = ( shift, shift );
2907   return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
2908 }
2909
2910 my %html_align = (
2911   'c' => 'center',
2912   'l' => 'left',
2913   'r' => 'right',
2914 );
2915
2916 sub _condensed_header_generator {
2917   my ( $self, $format ) = ( shift, shift );
2918
2919   my ( $f, $prefix, $suffix, $separator, $column ) =
2920     _condensed_generator_defaults($format);
2921
2922   if ($format eq 'latex') {
2923     $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
2924     $suffix = "\\\\\n\\hline";
2925     $separator = "&\n";
2926     $column =
2927       sub { my ($d,$a,$s,$w) = @_;
2928             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2929           };
2930   } elsif ( $format eq 'html' ) {
2931     $prefix = '<th></th>';
2932     $suffix = '';
2933     $separator = '';
2934     $column =
2935       sub { my ($d,$a,$s,$w) = @_;
2936             return qq!<th align="$html_align{$a}">$d</th>!;
2937       };
2938   }
2939
2940   sub {
2941     my @args = @_;
2942     my @result = ();
2943
2944     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
2945       push @result,
2946         &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
2947     }
2948
2949     $prefix. join($separator, @result). $suffix;
2950   };
2951
2952 }
2953
2954 sub _condensed_description_generator {
2955   my ( $self, $format ) = ( shift, shift );
2956
2957   my ( $f, $prefix, $suffix, $separator, $column ) =
2958     _condensed_generator_defaults($format);
2959
2960   my $money_char = '$';
2961   if ($format eq 'latex') {
2962     $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
2963     $suffix = '\\\\';
2964     $separator = " & \n";
2965     $column =
2966       sub { my ($d,$a,$s,$w) = @_;
2967             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2968           };
2969     $money_char = '\\dollar';
2970   }elsif ( $format eq 'html' ) {
2971     $prefix = '"><td align="center"></td>';
2972     $suffix = '';
2973     $separator = '';
2974     $column =
2975       sub { my ($d,$a,$s,$w) = @_;
2976             return qq!<td align="$html_align{$a}">$d</td>!;
2977       };
2978     #$money_char = $conf->config('money_char') || '$';
2979     $money_char = '';  # this is madness
2980   }
2981
2982   sub {
2983     #my @args = @_;
2984     my $href = shift;
2985     my @result = ();
2986
2987     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
2988       my $dollar = '';
2989       $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
2990       push @result,
2991         &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
2992                     map { $f->{$_}->[$i] } qw(align span width)
2993                   );
2994     }
2995
2996     $prefix. join( $separator, @result ). $suffix;
2997   };
2998
2999 }
3000
3001 sub _condensed_total_generator {
3002   my ( $self, $format ) = ( shift, shift );
3003
3004   my ( $f, $prefix, $suffix, $separator, $column ) =
3005     _condensed_generator_defaults($format);
3006   my $style = '';
3007
3008   if ($format eq 'latex') {
3009     $prefix = "& ";
3010     $suffix = "\\\\\n";
3011     $separator = " & \n";
3012     $column =
3013       sub { my ($d,$a,$s,$w) = @_;
3014             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
3015           };
3016   }elsif ( $format eq 'html' ) {
3017     $prefix = '';
3018     $suffix = '';
3019     $separator = '';
3020     $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
3021     $column =
3022       sub { my ($d,$a,$s,$w) = @_;
3023             return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
3024       };
3025   }
3026
3027
3028   sub {
3029     my @args = @_;
3030     my @result = ();
3031
3032     #  my $r = &{$f->{fields}->[$i]}(@args);
3033     #  $r .= ' Total' unless $i;
3034
3035     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
3036       push @result,
3037         &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
3038                     map { $f->{$_}->[$i] } qw(align span width)
3039                   );
3040     }
3041
3042     $prefix. join( $separator, @result ). $suffix;
3043   };
3044
3045 }
3046
3047 =item total_line_generator FORMAT
3048
3049 Returns a coderef used for generation of invoice total line items for this
3050 usage_class.  FORMAT is either html or latex
3051
3052 =cut
3053
3054 # should not be used: will have issues with hash element names (description vs
3055 # total_item and amount vs total_amount -- another array of functions?
3056
3057 sub _condensed_total_line_generator {
3058   my ( $self, $format ) = ( shift, shift );
3059
3060   my ( $f, $prefix, $suffix, $separator, $column ) =
3061     _condensed_generator_defaults($format);
3062   my $style = '';
3063
3064   if ($format eq 'latex') {
3065     $prefix = "& ";
3066     $suffix = "\\\\\n";
3067     $separator = " & \n";
3068     $column =
3069       sub { my ($d,$a,$s,$w) = @_;
3070             return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
3071           };
3072   }elsif ( $format eq 'html' ) {
3073     $prefix = '';
3074     $suffix = '';
3075     $separator = '';
3076     $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
3077     $column =
3078       sub { my ($d,$a,$s,$w) = @_;
3079             return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
3080       };
3081   }
3082
3083
3084   sub {
3085     my @args = @_;
3086     my @result = ();
3087
3088     foreach  (my $i = 0; $f->{label}->[$i]; $i++) {
3089       push @result,
3090         &{$column}( &{$f->{fields}->[$i]}(@args),
3091                     map { $f->{$_}->[$i] } qw(align span width)
3092                   );
3093     }
3094
3095     $prefix. join( $separator, @result ). $suffix;
3096   };
3097
3098 }
3099
3100 =item _items_pkg [ OPTIONS ]
3101
3102 Return line item hashes for each package item on this invoice. Nearly 
3103 equivalent to 
3104
3105 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
3106
3107 OPTIONS are passed through to _items_cust_bill_pkg, and should include
3108 'format' and 'escape_function' at minimum.
3109
3110 To produce items for a specific invoice section, OPTIONS should include
3111 'section', a hashref containing 'category' and/or 'locationnum' keys.
3112
3113 'section' may also contain a key named 'condensed'. If this is present
3114 and has a true value, _items_pkg will try to merge identical items into items
3115 with 'quantity' equal to the number of items (not the sum of their separate
3116 quantities, for some reason).
3117
3118 =cut
3119
3120 sub _items_nontax {
3121   my $self = shift;
3122   # The order of these is important.  Bundled line items will be merged into
3123   # the most recent non-hidden item, so it needs to be the one with:
3124   # - the same pkgnum
3125   # - the same start date
3126   # - no pkgpart_override
3127   #
3128   # So: sort by pkgnum,
3129   # then by sdate
3130   # then sort the base line item before any overrides
3131   # then sort hidden before non-hidden add-ons
3132   # then sort by override pkgpart (for consistency)
3133   sort { $a->pkgnum <=> $b->pkgnum        or
3134          $a->sdate  <=> $b->sdate         or
3135          ($a->pkgpart_override ? 0 : -1)  or
3136          ($b->pkgpart_override ? 0 : 1)   or
3137          $b->hidden cmp $a->hidden        or
3138          $a->pkgpart_override <=> $b->pkgpart_override
3139        }
3140   # and of course exclude taxes and fees
3141   grep { $_->pkgnum > 0 } $self->cust_bill_pkg;
3142 }
3143
3144 sub _items_fee {
3145   my $self = shift;
3146   my %options = @_;
3147   my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
3148   my $escape_function = $options{escape_function};
3149
3150   my $locale = $self->cust_main
3151              ? $self->cust_main->locale
3152              : $self->prospect_main->locale;
3153
3154   my @items;
3155   foreach my $cust_bill_pkg (@cust_bill_pkg) {
3156     # cache this, so we don't look it up again in every section
3157     my $part_fee = $cust_bill_pkg->get('part_fee')
3158        || $cust_bill_pkg->part_fee;
3159     $cust_bill_pkg->set('part_fee', $part_fee);
3160     if (!$part_fee) {
3161       #die "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; # might make more sense
3162       warn "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n";
3163       next;
3164     }
3165
3166     # If _items_fee is called while building a sectioned invoice,
3167     #   - invoice_sections_method: category
3168     #     Skip fee records that do not match the section category.
3169     #   - invoice_sections_method: location
3170     #     Skip fee records always for location sections.
3171     #     The fee records will be presented in the tax/fee section instead.
3172     if (
3173       exists( $options{section} )
3174       and
3175       (
3176         (
3177           exists( $options{section}{category} )
3178           and
3179           $part_fee->categoryname ne $options{section}{category}
3180         )
3181         or
3182         exists( $options{section}{location})
3183       )
3184     ) {
3185       warn "skipping fee '".$part_fee->itemdesc.
3186            "'--not in section $options{section}{category}\n" if $DEBUG;
3187       next;
3188     }
3189
3190     my @ext_desc;
3191     my %base_invnums; # invnum => invoice date
3192     foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
3193       if ($_->base_invnum) {
3194         my $base_bill = FS::cust_bill->by_key($_->base_invnum);
3195         my $base_date = $self->time2str_local('short', $base_bill->_date)
3196           if $base_bill;
3197         $base_invnums{$_->base_invnum} = $base_date || '';
3198       }
3199     }
3200     foreach (sort keys(%base_invnums)) {
3201       next if $_ == $self->invnum;
3202       # per convention, we must escape ext_description lines
3203       push @ext_desc,
3204         &{$escape_function}(
3205           $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
3206         );
3207     }
3208     my $desc = $part_fee->itemdesc_locale($locale);
3209     # but not escape the base description line
3210
3211     my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
3212       if $options{section_with_taxes};
3213
3214     push @items,
3215       { feepart     => $cust_bill_pkg->feepart,
3216         billpkgnum  => $cust_bill_pkg->billpkgnum,
3217         amount      => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
3218         description => $desc,
3219         pkg_tax     => \@pkg_tax,
3220         ext_description => \@ext_desc
3221         # sdate/edate?
3222       };
3223   }
3224   @items;
3225 }
3226
3227 sub _items_pkg {
3228   my $self = shift;
3229   my %options = @_;
3230
3231   warn "$me _items_pkg searching for all package line items\n"
3232     if $DEBUG > 1;
3233
3234   my @cust_bill_pkg = $self->_items_nontax;
3235
3236   warn "$me _items_pkg filtering line items\n"
3237     if $DEBUG > 1;
3238   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
3239
3240   if ($options{section} && $options{section}->{condensed}) {
3241
3242     warn "$me _items_pkg condensing section\n"
3243       if $DEBUG > 1;
3244
3245     my %itemshash = ();
3246     local $Storable::canonical = 1;
3247     foreach ( @items ) {
3248       my $item = { %$_ };
3249       delete $item->{ref};
3250       delete $item->{ext_description};
3251       my $key = freeze($item);
3252       $itemshash{$key} ||= 0;
3253       $itemshash{$key} ++; # += $item->{quantity};
3254     }
3255     @items = sort { $a->{description} cmp $b->{description} }
3256              map { my $i = thaw($_);
3257                    $i->{quantity} = $itemshash{$_};
3258                    $i->{amount} =
3259                      sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
3260                    $i;
3261                  }
3262              keys %itemshash;
3263   }
3264
3265   warn "$me _items_pkg returning ". scalar(@items). " items\n"
3266     if $DEBUG > 1;
3267
3268   @items;
3269 }
3270
3271 sub _taxsort {
3272   return 0 unless $a->itemdesc cmp $b->itemdesc;
3273   return -1 if $b->itemdesc eq 'Tax';
3274   return 1 if $a->itemdesc eq 'Tax';
3275   return -1 if $b->itemdesc eq 'Other surcharges';
3276   return 1 if $a->itemdesc eq 'Other surcharges';
3277   $a->itemdesc cmp $b->itemdesc;
3278 }
3279
3280 sub _items_tax {
3281   my $self = shift;
3282   my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart } 
3283     $self->cust_bill_pkg;
3284   my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
3285
3286   if ( $self->conf->exists('always_show_tax') ) {
3287     my $itemdesc = $self->conf->config('always_show_tax') || 'Tax';
3288     if (0 == grep { $_->{description} eq $itemdesc } @items) {
3289       push @items,
3290         { 'description' => $itemdesc,
3291           'amount'      => 0.00 };
3292     }
3293   }
3294   @items;
3295 }
3296
3297 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
3298
3299 Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
3300 list of hashrefs describing the line items they generate on the invoice.
3301
3302 OPTIONS may include:
3303
3304 format: the invoice format.
3305
3306 escape_function: the function used to escape strings.
3307
3308 DEPRECATED? (expensive, mostly unused?)
3309 format_function: the function used to format CDRs.
3310
3311 section: a hashref containing 'category' and/or 'locationnum'; if this 
3312 is present, only returns line items that belong to that category and/or
3313 location (whichever is defined).
3314
3315 multisection: a flag indicating that this is a multisection invoice,
3316 which does something complicated.
3317
3318 section_with_taxes:  Look up and include applied taxes for each record
3319
3320 Returns a list of hashrefs, each of which may contain:
3321
3322 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and 
3323 ext_description, which is an arrayref of detail lines to show below 
3324 the package line.
3325
3326 =cut
3327
3328 sub _items_cust_bill_pkg {
3329   my $self = shift;
3330   my $conf = $self->conf;
3331   my $cust_bill_pkgs = shift;
3332   my %opt = @_;
3333
3334   my $format = $opt{format} || '';
3335   my $escape_function = $opt{escape_function} || sub { shift };
3336   my $format_function = $opt{format_function} || '';
3337   my $no_usage = $opt{no_usage} || '';
3338   my $unsquelched = $opt{unsquelched} || ''; #unused
3339   my ($section, $locationnum, $category);
3340   if ( $opt{section} ) {
3341     $category = $opt{section}->{category};
3342     $locationnum = $opt{section}->{locationnum};
3343   }
3344   my $summary_page = $opt{summary_page} || ''; #unused
3345   my $multisection = defined($category) || defined($locationnum);
3346   # this variable is the value of the config setting, not whether it applies
3347   # to this particular line item.
3348   my $discount_show_always = $conf->exists('discount-show-always');
3349
3350   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
3351
3352   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
3353
3354   my $agentnum = $self->agentnum;
3355
3356   # for location labels: use default location on the invoice date
3357   my $default_locationnum;
3358   if ( $conf->exists('invoice-all_pkg_addresses') ) {
3359     $default_locationnum = 0; # treat them all as non-default
3360   } elsif ( $self->custnum ) {
3361     my $h_cust_main;
3362     my @h_search = FS::h_cust_main->sql_h_search($self->_date);
3363     $h_cust_main = qsearchs({
3364         'table'     => 'h_cust_main',
3365         'hashref'   => { custnum => $self->custnum },
3366         'extra_sql' => $h_search[1],
3367         'addl_from' => $h_search[3],
3368     }) || $cust_main;
3369     $default_locationnum = $h_cust_main->ship_locationnum;
3370   } elsif ( $self->prospectnum ) {
3371     my $cust_location = qsearchs('cust_location',
3372       { prospectnum => $self->prospectnum,
3373         disabled => '' });
3374     $default_locationnum = $cust_location->locationnum if $cust_location;
3375   }
3376
3377   my @b = (); # accumulator for the line item hashes that we'll return
3378   my ($s, $r, $u, $d) = ( undef, undef, undef, undef );
3379             # the 'current' line item hashes for setup, recur, usage, discount
3380   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
3381   {
3382     # if the current line item is waiting to go out, and the one we're about
3383     # to start is not bundled, then push out the current one and start a new
3384     # one.
3385     if ( $d ) {
3386       $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
3387     }
3388     foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
3389       if ( $_ && !$cust_bill_pkg->hidden ) {
3390         $_->{amount}      = sprintf( "%.2f", $_->{amount} );
3391         $_->{amount}      =~ s/^\-0\.00$/0.00/;
3392         if (exists($_->{unit_amount})) {
3393           $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
3394         }
3395         push @b, { %$_ };
3396         # we already decided to create this display line; don't reconsider it
3397         # now.
3398         #  if $_->{amount} != 0
3399         #  || $discount_show_always
3400         #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
3401         #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
3402         ;
3403         $_ = undef;
3404       }
3405     }
3406
3407     if ( $locationnum ) {
3408       # this is a location section; skip packages that aren't at this
3409       # service location.
3410       next if $cust_bill_pkg->pkgnum == 0; # skips fees...
3411       next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum 
3412               != $locationnum;
3413     }
3414
3415     # Consider display records for this item to determine if it belongs
3416     # in this section.  Note that if there are no display records, there
3417     # will be a default pseudo-record that includes all charge types 
3418     # and has no section name.
3419     my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
3420                                   ? $cust_bill_pkg->cust_bill_pkg_display
3421                                   : ( $cust_bill_pkg );
3422
3423     warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
3424          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
3425       if $DEBUG > 1;
3426
3427     if ( defined($category) ) {
3428       # then this is a package category section; process all display records
3429       # that belong to this section.
3430       @cust_bill_pkg_display = grep { $_->section eq $category }
3431                                 @cust_bill_pkg_display;
3432     } else {
3433       # otherwise, process all display records that aren't usage summaries
3434       # (I don't think there should be usage summaries if you aren't using 
3435       # category sections, but this is the historical behavior)
3436       @cust_bill_pkg_display = grep { !$_->summary }
3437                                 @cust_bill_pkg_display;
3438     }
3439
3440     my $classname = ''; # package class name, will fill in later
3441
3442     foreach my $display (@cust_bill_pkg_display) {
3443
3444       warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
3445            $display->billpkgdisplaynum. "\n"
3446         if $DEBUG > 1;
3447
3448       my $type = $display->type;
3449
3450       my $desc = $cust_bill_pkg->desc( $cust_main ? $cust_main->locale : '' );
3451       $desc = substr($desc, 0, $maxlength). '...'
3452         if $format eq 'latex' && length($desc) > $maxlength;
3453
3454       my %details_opt = ( 'format'          => $format,
3455                           'escape_function' => $escape_function,
3456                           'format_function' => $format_function,
3457                           'no_usage'        => $opt{'no_usage'},
3458                         );
3459
3460       if ( $cust_bill_pkg->pkgnum > 0 ) {
3461         # a "normal" package line item (not a quotation, not a fee, not a tax)
3462
3463         warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
3464           if $DEBUG > 1;
3465  
3466         my $cust_pkg = $cust_bill_pkg->cust_pkg;
3467
3468         unless ( $cust_pkg ) {
3469           # There is no related row in cust_pkg for this cust_bill_pkg.pkgnum.
3470           # This invoice may have been broken by an unusual combination
3471           # of manually editing package dates, and aborted package changes
3472           # when the manually edited dates used are nonsensical.
3473
3474           my $error = sprintf
3475             'cust_bill_pkg(billpkgnum:%s) '.
3476             'is missing related row in cust_pkg(pkgnum:%s)! '.
3477             'cust_bill(invnum:%s) is corrupted by bad database data, '.
3478             'and should be investigated',
3479               $cust_bill_pkg->billpkgnum,
3480               $cust_bill_pkg->pkgnum,
3481               $cust_bill_pkg->invnum;
3482
3483           FS::Log->new('FS::cust_bill_pkg')->critical( $error );
3484           warn $error;
3485           next;
3486         }
3487
3488         my $part_pkg = $cust_pkg->part_pkg;
3489
3490         # which pkgpart to show for display purposes?
3491         my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
3492
3493         # start/end dates for invoice formats that do nonstandard 
3494         # things with them
3495         my %item_dates = ();
3496         %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
3497           unless $part_pkg->option('disable_line_item_date_ranges',1);
3498
3499         # not normally used, but pass this to the template anyway
3500         $classname = $part_pkg->classname;
3501
3502         my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
3503           if $self->conf->exists('invoice_sections_with_taxes');
3504
3505         if (    (!$type || $type eq 'S')
3506              && (    $cust_bill_pkg->setup != 0
3507                   || $cust_bill_pkg->setup_show_zero
3508                   || ($discount_show_always and $cust_bill_pkg->unitsetup > 0)
3509                 )
3510            )
3511          {
3512
3513           warn "$me _items_cust_bill_pkg adding setup\n"
3514             if $DEBUG > 1;
3515
3516           # append the word 'Setup' to the setup line if there's going to be
3517           # a recur line for the same package (i.e. not a one-time charge) 
3518           # XXX localization
3519           my $description = $desc;
3520           $description .= ' Setup'
3521             if $cust_bill_pkg->recur != 0
3522             || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
3523             || $cust_bill_pkg->recur_show_zero;
3524
3525           my $disable_date_ranges =
3526                $opt{disable_line_item_date_ranges}
3527             || $part_pkg->option('disable_line_item_date_ranges', 1);
3528
3529           $description .= $cust_bill_pkg->time_period_pretty(
3530                             $part_pkg,
3531                             $agentnum,
3532                             disable_date_ranges => $disable_date_ranges,
3533                           )
3534             if $part_pkg->is_prepaid #for prepaid, "display the validity period
3535                                      # triggered by the recurring charge freq
3536                                      # (RT#26274)
3537             && $cust_bill_pkg->recur == 0
3538             && ! $cust_bill_pkg->recur_show_zero;
3539
3540           my @d = ();
3541           my $svc_label;
3542
3543           # always pass the svc_label through to the template, even if 
3544           # not displaying it as an ext_description
3545           my @svc_labels = map &{$escape_function}($_),
3546             $cust_pkg->h_labels_short($self->_date,
3547                                       undef,
3548                                       'I',
3549                                       $self->conf->{locale},
3550                                      );
3551           $svc_label = $svc_labels[0];
3552
3553           unless ( $cust_pkg->part_pkg->hide_svc_detail
3554                 || $cust_bill_pkg->hidden )
3555           {
3556
3557             push @d, @svc_labels
3558               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
3559             # show the location label if it's not the customer's default
3560             # location, and we're not grouping items by location already
3561             if ( $cust_pkg->locationnum != $default_locationnum
3562                   and !defined($locationnum) ) {
3563               my $loc = $cust_pkg->location_label;
3564               $loc = substr($loc, 0, $maxlength). '...'
3565                 if $format eq 'latex' && length($loc) > $maxlength;
3566               push @d, &{$escape_function}($loc);
3567             }
3568
3569           } #unless hiding service details
3570
3571           push @d, $cust_bill_pkg->details(%details_opt)
3572             if $cust_bill_pkg->recur == 0;
3573
3574           if ( $cust_bill_pkg->hidden ) {
3575             $s->{amount}      += $cust_bill_pkg->setup;
3576             $s->{unit_amount} += $cust_bill_pkg->unitsetup;
3577             push @{ $s->{ext_description} }, @d;
3578           } else {
3579             $s = {
3580               billpkgnum      => $cust_bill_pkg->billpkgnum,
3581               _is_setup       => 1,
3582               description     => $description,
3583               pkgpart         => $pkgpart,
3584               pkgnum          => $cust_bill_pkg->pkgnum,
3585               amount          => $cust_bill_pkg->setup,
3586               setup_show_zero => $cust_bill_pkg->setup_show_zero,
3587               unit_amount     => $cust_bill_pkg->unitsetup,
3588               quantity        => $cust_bill_pkg->quantity,
3589               ext_description => \@d,
3590               svc_label       => ($svc_label || ''),
3591               locationnum     => $cust_pkg->locationnum, # sure, why not?
3592               pkg_tax         => \@pkg_tax,
3593             };
3594           };
3595
3596         }
3597
3598         # should we show a recur line?
3599         # if type eq 'S', then NO, because we've been told not to.
3600         # otherwise, show the recur line if:
3601         # - there's a recurring charge
3602         # - or recur_show_zero is on
3603         # - or there's a positive unitrecur (so it's been discounted to zero)
3604         #   and discount-show-always is on
3605         if (    ( !$type || $type eq 'R' || $type eq 'U' )
3606              && (
3607                      $cust_bill_pkg->recur != 0
3608                   || !defined($s)
3609                   || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
3610                   || $cust_bill_pkg->recur_show_zero
3611                 )
3612            )
3613         {
3614
3615           warn "$me _items_cust_bill_pkg adding recur/usage\n"
3616             if $DEBUG > 1;
3617
3618           my $is_summary = $display->summary;
3619           my $description = $desc;
3620           if ( $type eq 'U' and defined($r) ) {
3621             # don't just show the same description as the recur line
3622             $description = $self->mt('Usage charges');
3623           }
3624
3625           my $disable_date_ranges =
3626                $opt{disable_line_item_date_ranges}
3627             || $part_pkg->option('disable_line_item_date_ranges', 1);
3628
3629           $description .= $cust_bill_pkg->time_period_pretty(
3630                                     $part_pkg,
3631                                     $agentnum,
3632                                     disable_date_ranges => $disable_date_ranges,
3633                           );
3634
3635           my @d = ();
3636           my @seconds = (); # for display of usage info
3637           my $svc_label = '';
3638
3639           #at least until cust_bill_pkg has "past" ranges in addition to
3640           #the "future" sdate/edate ones... see #3032
3641           my @dates = ( $self->_date );
3642           my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
3643           push @dates, $prev->sdate if $prev;
3644           push @dates, undef if !$prev;
3645
3646           my @svc_labels = map &{$escape_function}($_),
3647             $cust_pkg->h_labels_short(@dates,
3648                                       'I',
3649                                       $self->conf->{locale});
3650           $svc_label = $svc_labels[0];
3651
3652           # show service labels, unless...
3653                     # the package is set not to display them
3654           unless ( $part_pkg->hide_svc_detail
3655                     # or this is a tax-like line item
3656                 || $cust_bill_pkg->itemdesc
3657                     # or this is a hidden (bundled) line item
3658                 || $cust_bill_pkg->hidden
3659                     # or this is a usage summary line
3660                 || $is_summary && $type && $type eq 'U'
3661                     # or this is a usage line and there's a recurring line
3662                     # for the package in the same section (which will 
3663                     # have service labels already)
3664                 || ($type eq 'U' and defined($r))
3665               )
3666           {
3667
3668             warn "$me _items_cust_bill_pkg adding service details\n"
3669               if $DEBUG > 1;
3670
3671             push @d, @svc_labels
3672               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
3673             warn "$me _items_cust_bill_pkg done adding service details\n"
3674               if $DEBUG > 1;
3675
3676             # show the location label if it's not the customer's default
3677             # location, and we're not grouping items by location already
3678             if ( $cust_pkg->locationnum != $default_locationnum
3679                   and !defined($locationnum) ) {
3680               my $loc = $cust_pkg->location_label;
3681               $loc = substr($loc, 0, $maxlength). '...'
3682                 if $format eq 'latex' && length($loc) > $maxlength;
3683               push @d, &{$escape_function}($loc);
3684             }
3685
3686             # Display of seconds_since_sqlradacct:
3687             # On the invoice, when processing @detail_items, look for a field
3688             # named 'seconds'.  This will contain total seconds for each 
3689             # service, in the same order as @ext_description.  For services 
3690             # that don't support this it will show undef.
3691             if ( $conf->exists('svc_acct-usage_seconds') 
3692                  and ! $cust_bill_pkg->pkgpart_override ) {
3693               foreach my $cust_svc ( 
3694                   $cust_pkg->h_cust_svc(@dates, 'I') 
3695                 ) {
3696
3697                 # eval because not having any part_export_usage exports 
3698                 # is a fatal error, last_bill/_date because that's how 
3699                 # sqlradius_hour billing does it
3700                 my $sec = eval {
3701                   $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
3702                 };
3703                 push @seconds, $sec;
3704               }
3705             } #if svc_acct-usage_seconds
3706
3707           } # if we are showing service labels
3708
3709           unless ( $is_summary ) {
3710             warn "$me _items_cust_bill_pkg adding details\n"
3711               if $DEBUG > 1;
3712
3713             #instead of omitting details entirely in this case (unwanted side
3714             # effects), just omit CDRs
3715             $details_opt{'no_usage'} = 1
3716               if $type && $type eq 'R';
3717
3718             push @d, $cust_bill_pkg->details(%details_opt);
3719           }
3720
3721           warn "$me _items_cust_bill_pkg calculating amount\n"
3722             if $DEBUG > 1;
3723   
3724           my $amount = 0;
3725           if (!$type) {
3726             $amount = $cust_bill_pkg->recur;
3727           } elsif ($type eq 'R') {
3728             $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
3729           } elsif ($type eq 'U') {
3730             $amount = $cust_bill_pkg->usage;
3731           }
3732   
3733           if ( !$type || $type eq 'R' ) {
3734
3735             warn "$me _items_cust_bill_pkg adding recur\n"
3736               if $DEBUG > 1;
3737
3738             my $unit_amount =
3739               ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
3740                                                 : $amount;
3741
3742             if ( $cust_bill_pkg->hidden ) {
3743               $r->{amount}      += $amount;
3744               $r->{unit_amount} += $unit_amount;
3745               push @{ $r->{ext_description} }, @d;
3746             } else {
3747               $r = {
3748                 billpkgnum      => $cust_bill_pkg->billpkgnum,
3749                 description     => $description,
3750                 pkgpart         => $pkgpart,
3751                 pkgnum          => $cust_bill_pkg->pkgnum,
3752                 amount          => $amount,
3753                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
3754                 unit_amount     => $unit_amount,
3755                 quantity        => $cust_bill_pkg->quantity,
3756                 %item_dates,
3757                 ext_description => \@d,
3758                 svc_label       => ($svc_label || ''),
3759                 locationnum     => $cust_pkg->locationnum,
3760                 pkg_tax         => \@pkg_tax,
3761               };
3762               $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
3763             }
3764
3765           } else {  # $type eq 'U'
3766
3767             warn "$me _items_cust_bill_pkg adding usage\n"
3768               if $DEBUG > 1;
3769
3770             if ( $cust_bill_pkg->hidden and defined($u) ) {
3771               # if this is a hidden package and there's already a usage
3772               # line for the bundle, add this package's total amount and
3773               # usage details to it
3774               $u->{amount}      += $amount;
3775               push @{ $u->{ext_description} }, @d;
3776             } elsif ( $amount ) {
3777               # create a new usage line
3778               $u = {
3779                 billpkgnum      => $cust_bill_pkg->billpkgnum,
3780                 description     => $description,
3781                 pkgpart         => $pkgpart,
3782                 pkgnum          => $cust_bill_pkg->pkgnum,
3783                 amount          => $amount,
3784                 usage_item      => 1,
3785                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
3786                 %item_dates,
3787                 ext_description => \@d,
3788                 locationnum     => $cust_pkg->locationnum,
3789                 pkg_tax         => \@pkg_tax,
3790               };
3791             } # else this has no usage, so don't create a usage section
3792           }
3793
3794         } # recurring or usage with recurring charge
3795
3796       } else { # taxes and fees
3797
3798         warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
3799           if $DEBUG > 1;
3800
3801         # items of this kind should normally not have sdate/edate.
3802         push @b, {
3803           'description' => $desc,
3804           'amount'      => sprintf('%.2f', $cust_bill_pkg->setup 
3805                                            + $cust_bill_pkg->recur)
3806         };
3807
3808       } # if package line item / other line item
3809
3810       # decide whether to show active discounts here
3811       if (
3812           # case 1: we are showing a single line for the package
3813           ( !$type )
3814           # case 2: we are showing a setup line for a package that has
3815           # no base recurring fee
3816           or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
3817           # case 3: we are showing a recur line for a package that has 
3818           # a base recurring fee
3819           or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
3820       ) {
3821
3822         my $item_discount = $cust_bill_pkg->_item_discount;
3823         if ( $item_discount ) {
3824           # $item_discount->{amount} is negative
3825
3826           if ( $d and $cust_bill_pkg->hidden ) {
3827             $d->{setup_amount} += $item_discount->{setup_amount};
3828             $d->{recur_amount} += $item_discount->{recur_amount};
3829           } else {
3830             $d = $item_discount;
3831             $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} };
3832           }
3833
3834           # update the active line (before the discount) to show the 
3835           # original price (whether this is a hidden line or not)
3836
3837           $s->{amount} -= $item_discount->{setup_amount} if $s;
3838           $r->{amount} -= $item_discount->{recur_amount} if $r;
3839
3840         } # if there are any discounts
3841       } # if this is an appropriate place to show discounts
3842
3843     } # foreach $display
3844
3845   }
3846
3847   # discount amount is internally split up
3848   if ( $d ) {
3849     $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
3850   }
3851
3852   foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
3853     if ( $_  ) {
3854       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
3855         if exists($_->{amount});
3856       $_->{amount}      =~ s/^\-0\.00$/0.00/;
3857       if (exists($_->{unit_amount})) {
3858         $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
3859       }
3860
3861       push @b, { %$_ };
3862       #if $_->{amount} != 0
3863       #  || $discount_show_always
3864       #  || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
3865       #  || (   $_->{_is_setup} && $_->{setup_show_zero} )
3866     }
3867   }
3868
3869   warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
3870     if $DEBUG > 1;
3871
3872   @b;
3873
3874 }
3875
3876 =item _items_discounts_avail
3877
3878 Returns an array of line item hashrefs representing available term discounts
3879 for this invoice.  This makes the same assumptions that apply to term 
3880 discounts in general: that the package is billed monthly, at a flat rate, 
3881 with no usage charges.  A prorated first month will be handled, as will 
3882 a setup fee if the discount is allowed to apply to setup fees.
3883
3884 =cut
3885
3886 sub _items_discounts_avail {
3887   my $self = shift;
3888
3889   #maybe move this method from cust_bill when quotations support discount_plans 
3890   return () unless $self->can('discount_plans');
3891   my %plans = $self->discount_plans;
3892
3893   my $list_pkgnums = 0; # if any packages are not eligible for all discounts
3894   $list_pkgnums = grep { $_->list_pkgnums } values %plans;
3895
3896   map {
3897     my $months = $_;
3898     my $plan = $plans{$months};
3899
3900     my $term_total = sprintf('%.2f', $plan->discounted_total);
3901     my $percent = sprintf('%.0f', 
3902                           100 * (1 - $term_total / $plan->base_total) );
3903     my $permonth = sprintf('%.2f', $term_total / $months);
3904     my $detail = $self->mt('discount on item'). ' '.
3905                  join(', ', map { "#$_" } $plan->pkgnums)
3906       if $list_pkgnums;
3907
3908     # discounts for non-integer months don't work anyway
3909     $months = sprintf("%d", $months);
3910
3911     +{
3912       description => $self->mt('Save [_1]% by paying for [_2] months',
3913                                 $percent, $months),
3914       amount      => $self->mt('[_1] ([_2] per month)', 
3915                                 $term_total, $money_char.$permonth),
3916       ext_description => ($detail || ''),
3917     }
3918   } #map
3919   sort { $b <=> $a } keys %plans;
3920
3921 }
3922
3923 =item has_sections AGENTNUM
3924
3925 Return true if invoice_sections should be enabled for this bill.
3926  (Inherited by both cust_bill and cust_bill_void)
3927
3928 Determination:
3929 * False if not an invoice
3930 * True always if conf invoice_sections is enabled
3931 * True always if sections_by_location is enabled
3932 * True if conf invoice_sections_multilocation > 1,
3933   and location_count >= invoice_sections_multilocation
3934 * Else, False
3935
3936 =cut
3937
3938 sub has_sections {
3939   my ($self, $agentnum) = @_;
3940
3941   return 0 unless $self->invnum > 0;
3942
3943   $agentnum ||= $self->agentnum;
3944   return 1 if $self->conf->config_bool('invoice_sections', $agentnum);
3945   return 1 if $self->conf->exists('sections_by_location', $agentnum);
3946
3947   my $location_min = $self->conf->config(
3948     'invoice_sections_multilocation', $agentnum,
3949   );
3950
3951   return 1
3952     if $location_min
3953     && $self->location_count >= $location_min;
3954
3955   0;
3956 }
3957
3958
3959 =item location_count
3960
3961 Return the number of locations billed on this invoice
3962
3963 =cut
3964
3965 sub location_count {
3966   my ($self) = @_;
3967   return 0 unless $self->invnum;
3968
3969   # SELECT COUNT( DISTINCT cust_pkg.locationnum )
3970   # FROM cust_bill_pkg
3971   # LEFT JOIN cust_pkg USING (pkgnum)
3972   # WHERE invnum = 278
3973   #   AND cust_bill_pkg.pkgnum > 0
3974
3975   my $result = qsearchs({
3976     select    => 'COUNT(DISTINCT cust_pkg.locationnum) as location_count',
3977     table     => 'cust_bill_pkg',
3978     addl_from => 'LEFT JOIN cust_pkg USING (pkgnum)',
3979     extra_sql => 'WHERE invnum = '.dbh->quote( $self->invnum )
3980                . '  AND cust_bill_pkg.pkgnum > 0'
3981   });
3982   ref $result ? $result->location_count : 0;
3983 }
3984
3985 1;