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