1 package FS::Template_Mixin;
4 use vars qw( $DEBUG $me
5 $money_char $date_format $rdate_format $date_format_long );
7 use vars qw( $invoice_lines @buf ); #yuck
8 use List::Util qw(sum);
11 use Text::Template 1.20;
17 use FS::Record qw( qsearch qsearchs );
18 use FS::Misc qw( generate_ps generate_pdf );
24 $me = '[FS::Template_Mixin]';
25 FS::UID->install_callback( sub {
26 my $conf = new FS::Conf; #global
27 $money_char = $conf->config('money_char') || '$';
28 $date_format = $conf->config('date_format') || '%x'; #/YY
29 $rdate_format = $conf->config('date_format') || '%m/%d/%Y'; #/YYYY
30 $date_format_long = $conf->config('date_format_long') || '%b %o, %Y';
33 =item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
35 Returns an text invoice, as a list of lines.
37 Options can be passed as a hashref (recommended) or as a list of time, template
38 and then any key/value pairs for any other options.
40 I<time>, if specified, is used to control the printing of overdue messages. The
41 default is now. It isn't the date of the invoice; that's the `_date' field.
42 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
43 L<Time::Local> and L<Date::Parse> for conversion functions.
45 I<template>, if specified, is the name of a suffix for alternate invoices.
47 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
53 my( $today, $template, %opt );
56 $today = delete($opt{'time'}) || '';
57 $template = delete($opt{template}) || '';
59 ( $today, $template, %opt ) = @_;
62 my %params = ( 'format' => 'template' );
63 $params{'time'} = $today if $today;
64 $params{'template'} = $template if $template;
65 $params{$_} = $opt{$_}
66 foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
68 $self->print_generic( %params );
71 =item print_latex HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
73 Internal method - returns a filename of a filled-in LaTeX template for this
74 invoice (Note: add ".tex" to get the actual filename), and a filename of
75 an associated logo (with the .eps extension included).
77 See print_ps and print_pdf for methods that return PostScript and PDF output.
79 Options can be passed as a hashref (recommended) or as a list of time, template
80 and then any key/value pairs for any other options.
82 I<time>, if specified, is used to control the printing of overdue messages. The
83 default is now. It isn't the date of the invoice; that's the `_date' field.
84 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
85 L<Time::Local> and L<Date::Parse> for conversion functions.
87 I<template>, if specified, is the name of a suffix for alternate invoices.
89 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
95 my $conf = $self->conf;
96 my( $today, $template, %opt );
99 $today = delete($opt{'time'}) || '';
100 $template = delete($opt{template}) || '';
102 ( $today, $template, %opt ) = @_;
105 my %params = ( 'format' => 'latex' );
106 $params{'time'} = $today if $today;
107 $params{'template'} = $template if $template;
108 $params{$_} = $opt{$_}
109 foreach grep $opt{$_}, qw( unsquelch_cdr notice_name no_date no_number );
111 $template ||= $self->_agent_template
112 if $self->can('_agent_template');
114 my $pkey = $self->primary_key;
115 my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
117 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
118 my $lh = new File::Temp(
119 TEMPLATE => $tmp_template,
123 ) or die "can't open temp file: $!\n";
125 my $agentnum = $self->agentnum;
127 if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
128 print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
129 or die "can't write temp file: $!\n";
131 print $lh $conf->config_binary('logo.eps', $agentnum)
132 or die "can't write temp file: $!\n";
135 $params{'logo_file'} = $lh->filename;
137 if( $conf->exists('invoice-barcode')
138 && $self->can('invoice_barcode')
139 && $self->invnum ) { # don't try to barcode statements
140 my $png_file = $self->invoice_barcode($dir);
141 my $eps_file = $png_file;
142 $eps_file =~ s/\.png$/.eps/g;
143 $png_file =~ /(barcode.*png)/;
145 $eps_file =~ /(barcode.*eps)/;
148 my $curr_dir = cwd();
150 # after painfuly long experimentation, it was determined that sam2p won't
151 # accept : and other chars in the path, no matter how hard I tried to
152 # escape them, hence the chdir (and chdir back, just to be safe)
153 system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0
154 or die "sam2p failed: $!\n";
158 $params{'barcode_file'} = $eps_file;
161 my @filled_in = $self->print_generic( %params );
163 my $fh = new File::Temp( TEMPLATE => $tmp_template,
167 ) or die "can't open temp file: $!\n";
168 binmode($fh, ':utf8'); # language support
169 print $fh join('', @filled_in );
172 $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
173 return ($1, $params{'logo_file'}, $params{'barcode_file'});
179 my $cust_main = $self->cust_main;
180 $cust_main ? $cust_main->agentnum : $self->prospect_main->agentnum;
183 =item print_generic OPTION => VALUE ...
185 Internal method - returns a filled-in template for this invoice as a scalar.
187 See print_ps and print_pdf for methods that return PostScript and PDF output.
189 Non optional options include
190 format - latex, html, template
192 Optional options include
194 template - a value used as a suffix for a configuration template
196 time - a value used to control the printing of overdue messages. The
197 default is now. It isn't the date of the invoice; that's the `_date' field.
198 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
199 L<Time::Local> and L<Date::Parse> for conversion functions.
203 unsquelch_cdr - overrides any per customer cdr squelching when true
205 notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
207 locale - override customer's locale
211 #what's with all the sprintf('%10.2f')'s in here? will it cause any
212 # (alignment in text invoice?) problems to change them all to '%.2f' ?
213 # yes: fixed width/plain text printing will be borked
215 my( $self, %params ) = @_;
216 my $conf = $self->conf;
217 my $today = $params{today} ? $params{today} : time;
218 warn "$me print_generic called on $self with suffix $params{template}\n"
221 my $format = $params{format};
222 die "Unknown format: $format"
223 unless $format =~ /^(latex|html|template)$/;
225 my $cust_main = $self->cust_main || $self->prospect_main;
226 $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
227 unless $cust_main->payname
228 && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
230 my %delimiters = ( 'latex' => [ '[@--', '--@]' ],
231 'html' => [ '<%=', '%>' ],
232 'template' => [ '{', '}' ],
235 warn "$me print_generic creating template\n"
239 my $template = $params{template} ? $params{template} : $self->_agent_template;
240 my $templatefile = $self->template_conf. $format;
241 $templatefile .= "_$template"
242 if length($template) && $conf->exists($templatefile."_$template");
243 my @invoice_template = map "$_\n", $conf->config($templatefile)
244 or die "cannot load config data $templatefile";
247 if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
248 #change this to a die when the old code is removed
249 warn "old-style invoice template $templatefile; ".
250 "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
252 @invoice_template = _translate_old_latex_format(@invoice_template);
255 warn "$me print_generic creating T:T object\n"
258 my $text_template = new Text::Template(
260 SOURCE => \@invoice_template,
261 DELIMITERS => $delimiters{$format},
264 warn "$me print_generic compiling T:T object\n"
267 $text_template->compile()
268 or die "Can't compile $templatefile: $Text::Template::ERROR\n";
271 # additional substitution could possibly cause breakage in existing templates
274 'notes' => sub { map "$_", @_ },
275 'footer' => sub { map "$_", @_ },
276 'smallfooter' => sub { map "$_", @_ },
277 'returnaddress' => sub { map "$_", @_ },
278 'coupon' => sub { map "$_", @_ },
279 'summary' => sub { map "$_", @_ },
285 s/%%(.*)$/<!-- $1 -->/g;
286 s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
287 s/\\begin\{enumerate\}/<ol>/g;
289 s/\\end\{enumerate\}/<\/ol>/g;
290 s/\\textbf\{(.*)\}/<b>$1<\/b>/g;
299 sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
301 sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
307 s/\\hyphenation\{[\w\s\-]+}//;
312 'coupon' => sub { "" },
313 'summary' => sub { "" },
320 s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
321 s/\\begin\{enumerate\}//g;
323 s/\\end\{enumerate\}//g;
324 s/\\textbf\{(.*)\}/$1/g;
331 sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
333 sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
338 s/\\\\\*?\s*$/\n/; # dubious
339 s/\\hyphenation\{[\w\s\-]+}//;
343 'coupon' => sub { "" },
344 'summary' => sub { "" },
349 # hashes for differing output formats
350 my %nbsps = ( 'latex' => '~',
351 'html' => '', # '&nbps;' would be nice
352 'template' => '', # not used
354 my $nbsp = $nbsps{$format};
356 my %escape_functions = ( 'latex' => \&_latex_escape,
357 'html' => \&_html_escape_nbsp,#\&encode_entities,
358 'template' => sub { shift },
360 my $escape_function = $escape_functions{$format};
361 my $escape_function_nonbsp = ($format eq 'html')
362 ? \&_html_escape : $escape_function;
364 my %date_formats = ( 'latex' => $date_format_long,
365 'html' => $date_format_long,
368 $date_formats{'html'} =~ s/ / /g;
370 my $date_format = $date_formats{$format};
372 my %newline_tokens = ( 'latex' => '\\\\',
376 my $newline_token = $newline_tokens{$format};
378 warn "$me generating template variables\n"
381 # generate template variables
384 defined( $conf->config_orbase( "invoice_${format}returnaddress",
388 && length( $conf->config_orbase( "invoice_${format}returnaddress",
394 $returnaddress = join("\n",
395 $conf->config_orbase("invoice_${format}returnaddress", $template)
399 $conf->config_orbase('invoice_latexreturnaddress', $template) ) {
401 my $convert_map = $convert_maps{$format}{'returnaddress'};
404 &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress",
409 } elsif ( grep /\S/, $conf->config('company_address', $cust_main->agentnum) ) {
411 my $convert_map = $convert_maps{$format}{'returnaddress'};
412 $returnaddress = join( "\n", &$convert_map(
413 map { s/( {2,})/'~' x length($1)/eg;
417 ( $conf->config('company_name', $cust_main->agentnum),
418 $conf->config('company_address', $cust_main->agentnum),
425 my $warning = "Couldn't find a return address; ".
426 "do you need to set the company_address configuration value?";
428 $returnaddress = $nbsp;
429 #$returnaddress = $warning;
433 warn "$me generating invoice data\n"
436 my $agentnum = $cust_main->agentnum;
441 'company_name' => scalar( $conf->config('company_name', $agentnum) ),
442 'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
443 'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
444 'returnaddress' => $returnaddress,
445 'agent' => &$escape_function($cust_main->agent->agent),
447 #invoice/quotation info
448 'no_number' => $params{'no_number'},
449 'invnum' => ( $params{'no_number'} ? '' : $self->invnum ),
450 'quotationnum' => $self->quotationnum,
451 'no_date' => $params{'no_date'},
452 '_date' => ( $params{'no_date'} ? '' : $self->_date ),
453 'date' => ( $params{'no_date'}
455 : time2str($date_format, $self->_date)
457 'today' => time2str($date_format_long, $today),
458 'terms' => $self->terms,
459 'template' => $template, #params{'template'},
460 'notice_name' => ($params{'notice_name'} || $self->notice_name),#escape_function?
461 'current_charges' => sprintf("%.2f", $self->charged),
462 'duedate' => $self->due_date2str($rdate_format), #date_format?
465 'custnum' => $cust_main->display_custnum,
466 'prospectnum' => $cust_main->prospectnum,
467 'agent_custid' => &$escape_function($cust_main->agent_custid),
468 ( map { $_ => &$escape_function($cust_main->$_()) } qw(
469 payname company address1 address2 city state zip fax
473 'ship_enable' => $conf->exists('invoice-ship_address'),
474 'unitprices' => $conf->exists('invoice-unitprice'),
475 'smallernotes' => $conf->exists('invoice-smallernotes'),
476 'smallerfooter' => $conf->exists('invoice-smallerfooter'),
477 'balance_due_below_line' => $conf->exists('balance_due_below_line'),
479 #layout info -- would be fancy to calc some of this and bury the template
481 'topmargin' => scalar($conf->config('invoice_latextopmargin', $agentnum)),
482 'headsep' => scalar($conf->config('invoice_latexheadsep', $agentnum)),
483 'textheight' => scalar($conf->config('invoice_latextextheight', $agentnum)),
484 'extracouponspace' => scalar($conf->config('invoice_latexextracouponspace', $agentnum)),
485 'couponfootsep' => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)),
486 'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum),
487 'addresssep' => scalar($conf->config('invoice_latexaddresssep', $agentnum)),
488 'amountenclosedsep' => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
489 'coupontoaddresssep' => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
490 'addcompanytoaddress' => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
492 # better hang on to conf_dir for a while (for old templates)
493 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
495 #these are only used when doing paged plaintext
502 my $lh = FS::L10N->get_handle( $params{'locale'} || $cust_main->locale );
503 $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
504 my %info = FS::Locales->locale_info($cust_main->locale || 'en_US');
505 # eval to avoid death for unimplemented languages
506 my $dh = eval { Date::Language->new($info{'name'}) } ||
507 Date::Language->new(); # fall back to English
508 # prototype here to silence warnings
509 $invoice_data{'time2str'} = sub ($;$$) { $dh->time2str(@_) };
510 # eventually use this date handle everywhere in here, too
512 my $min_sdate = 999999999999;
514 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
515 next unless $cust_bill_pkg->pkgnum > 0;
516 $min_sdate = $cust_bill_pkg->sdate
517 if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate;
518 $max_edate = $cust_bill_pkg->edate
519 if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate;
522 $invoice_data{'bill_period'} = '';
523 $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate)
524 . " to " . time2str('%e %h', $max_edate)
525 if ($max_edate != 0 && $min_sdate != 999999999999);
527 $invoice_data{finance_section} = '';
528 if ( $conf->config('finance_pkgclass') ) {
530 qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
531 $invoice_data{finance_section} = $pkg_class->categoryname;
533 $invoice_data{finance_amount} = '0.00';
534 $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
536 my $countrydefault = $conf->config('countrydefault') || 'US';
537 foreach ( qw( address1 address2 city state zip country fax) ){
538 my $method = 'ship_'.$_;
539 $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
541 foreach ( qw( contact company ) ) { #compatibility
542 $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
544 $invoice_data{'ship_country'} = ''
545 if ( $invoice_data{'ship_country'} eq $countrydefault );
547 $invoice_data{'cid'} = $params{'cid'}
550 if ( $cust_main->country eq $countrydefault ) {
551 $invoice_data{'country'} = '';
553 $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
557 $invoice_data{'address'} = \@address;
560 ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
561 ? " (P.O. #". $cust_main->payinfo. ")"
565 push @address, $cust_main->company
566 if $cust_main->company;
567 push @address, $cust_main->address1;
568 push @address, $cust_main->address2
569 if $cust_main->address2;
571 $cust_main->city. ", ". $cust_main->state. " ". $cust_main->zip;
572 push @address, $invoice_data{'country'}
573 if $invoice_data{'country'};
575 while (scalar(@address) < 5);
577 $invoice_data{'logo_file'} = $params{'logo_file'}
578 if $params{'logo_file'};
579 $invoice_data{'barcode_file'} = $params{'barcode_file'}
580 if $params{'barcode_file'};
581 $invoice_data{'barcode_img'} = $params{'barcode_img'}
582 if $params{'barcode_img'};
583 $invoice_data{'barcode_cid'} = $params{'barcode_cid'}
584 if $params{'barcode_cid'};
586 my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
587 # my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
588 #my $balance_due = $self->owed + $pr_total - $cr_total;
589 my $balance_due = $self->owed + $pr_total;
591 #these are used on the summary page only
593 # the customer's current balance as shown on the invoice before this one
594 $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
596 # the change in balance from that invoice to this one
597 $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
599 # the sum of amount owed on all previous invoices
600 # ($pr_total is used elsewhere but not as $previous_balance)
601 $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
603 # the sum of amount owed on all invoices
604 # (this is used in the summary & on the payment coupon)
605 $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
607 # info from customer's last invoice before this one, for some
609 $invoice_data{'last_bill'} = {};
611 # returns the last unpaid bill, not the last bill
612 #my $last_bill = $pr_cust_bill[-1];
614 if ( $self->custnum && $self->invnum ) {
616 # THIS returns the customer's last bill before this one
617 my $last_bill = qsearchs({
618 'table' => 'cust_bill',
619 'hashref' => { 'custnum' => $self->custnum,
620 'invnum' => { op => '<', value => $self->invnum },
622 'order_by' => ' ORDER BY invnum DESC LIMIT 1'
625 $invoice_data{'last_bill'} = {
626 '_date' => $last_bill->_date, #unformatted
627 # all we need for now
629 my (@payments, @credits);
630 # for formats that itemize previous payments
631 foreach my $cust_pay ( qsearch('cust_pay', {
632 'custnum' => $self->custnum,
633 '_date' => { op => '>=',
634 value => $last_bill->_date }
637 next if $cust_pay->_date > $self->_date;
639 '_date' => $cust_pay->_date,
640 'date' => time2str($date_format, $cust_pay->_date),
641 'payinfo' => $cust_pay->payby_payinfo_pretty,
642 'amount' => sprintf('%.2f', $cust_pay->paid),
644 # not concerned about applications
646 foreach my $cust_credit ( qsearch('cust_credit', {
647 'custnum' => $self->custnum,
648 '_date' => { op => '>=',
649 value => $last_bill->_date }
652 next if $cust_credit->_date > $self->_date;
654 '_date' => $cust_credit->_date,
655 'date' => time2str($date_format, $cust_credit->_date),
656 'creditreason'=> $cust_credit->reason,
657 'amount' => sprintf('%.2f', $cust_credit->amount),
660 $invoice_data{'previous_payments'} = \@payments;
661 $invoice_data{'previous_credits'} = \@credits;
666 my $summarypage = '';
667 if ( $conf->exists('invoice_usesummary', $agentnum) ) {
670 $invoice_data{'summarypage'} = $summarypage;
672 warn "$me substituting variables in notes, footer, smallfooter\n"
675 my $tc = $self->template_conf;
676 my @include = ( [ $tc, 'notes' ],
677 [ 'invoice_', 'footer' ],
678 [ 'invoice_', 'smallfooter', ],
680 push @include, [ $tc, 'coupon', ]
681 unless $params{'no_coupon'};
683 foreach my $i (@include) {
685 my($base, $include) = @$i;
687 my $inc_file = $conf->key_orbase("$base$format$include", $template);
690 if ( $conf->exists($inc_file, $agentnum)
691 && length( $conf->config($inc_file, $agentnum) ) ) {
693 @inc_src = $conf->config($inc_file, $agentnum);
697 $inc_file = $conf->key_orbase("${base}latex$include", $template);
699 my $convert_map = $convert_maps{$format}{$include};
701 @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
702 s/--\@\]/$delimiters{$format}[1]/g;
705 &$convert_map( $conf->config($inc_file, $agentnum) );
709 my $inc_tt = new Text::Template (
711 SOURCE => [ map "$_\n", @inc_src ],
712 DELIMITERS => $delimiters{$format},
713 ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
715 unless ( $inc_tt->compile() ) {
716 my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
717 warn $error. "Template:\n". join('', map "$_\n", @inc_src);
721 $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
723 $invoice_data{$include} =~ s/\n+$//
724 if ($format eq 'latex');
727 # let invoices use either of these as needed
728 $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL')
729 ? $cust_main->payinfo : '';
730 $invoice_data{'po_line'} =
731 ( $cust_main->payby eq 'BILL' && $cust_main->payinfo )
732 ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
735 my %money_chars = ( 'latex' => '',
736 'html' => $conf->config('money_char') || '$',
739 my $money_char = $money_chars{$format};
741 my %other_money_chars = ( 'latex' => '\dollar ',#XXX should be a config too
742 'html' => $conf->config('money_char') || '$',
745 my $other_money_char = $other_money_chars{$format};
746 $invoice_data{'dollar'} = $other_money_char;
748 my %minus_signs = ( 'latex' => '$-$',
750 'template' => '- ' );
751 my $minus = $minus_signs{$format};
753 my @detail_items = ();
754 my @total_items = ();
758 $invoice_data{'detail_items'} = \@detail_items;
759 $invoice_data{'total_items'} = \@total_items;
760 $invoice_data{'buf'} = \@buf;
761 $invoice_data{'sections'} = \@sections;
763 warn "$me generating sections\n"
767 my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
768 'subtotal' => $taxtotal, # adjusted below
770 my $tax_weight = _pkg_category($tax_section->{description})
771 ? _pkg_category($tax_section->{description})->weight
773 $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
774 $tax_section->{'sort_weight'} = $tax_weight;
777 my $adjust_section = {
778 'description' => $self->mt('Credits, Payments, and Adjustments'),
779 'adjust_section' => 1,
780 'subtotal' => 0, # adjusted below
782 my $adjust_weight = _pkg_category($adjust_section->{description})
783 ? _pkg_category($adjust_section->{description})->weight
785 $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
786 $adjust_section->{'sort_weight'} = $adjust_weight;
788 my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
789 my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
790 $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
791 $invoice_data{'multisection'} = $multisection;
793 my $extra_sections = [];
794 my $extra_lines = ();
796 # default section ('Charges')
797 my $default_section = { 'description' => '',
802 # Previous Charges section
803 # subtotal is the first return value from $self->previous
804 my $previous_section;
805 # if the invoice has major sections, or if we're summarizing previous
806 # charges with a single line, or if we've been specifically told to put them
807 # in a section, create a section for previous charges:
808 if ( $multisection or
809 $conf->exists('previous_balance-summary_only') or
810 $conf->exists('previous_balance-section') ) {
812 $previous_section = { 'description' => $self->mt('Previous Charges'),
813 'subtotal' => $other_money_char.
814 sprintf('%.2f', $pr_total),
815 'summarized' => '', #why? $summarypage ? 'Y' : '',
817 $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '.
818 join(' / ', map { $cust_main->balance_date_range(@$_) }
819 $self->_prior_month30s
821 if $conf->exists('invoice_include_aging');
824 # otherwise put them in the main section
825 $previous_section = $default_section;
828 if ( $multisection ) {
829 ($extra_sections, $extra_lines) =
830 $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
831 if $conf->exists('usage_class_as_a_section', $cust_main->agentnum)
832 && $self->can('_items_extra_usage_sections');
834 push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
836 push @detail_items, @$extra_lines if $extra_lines;
838 # the code is written so that both methods can be used together, but
839 # we haven't yet changed the template to take advantage of that, so for
840 # now, treat them as mutually exclusive.
841 my %section_method = ( by_category => 1 );
842 if ( $conf->exists($tc.'sections_by_location') ) {
843 %section_method = ( by_location => 1 );
846 $self->_items_sections( 'summary' => $summarypage,
847 'escape' => $escape_function_nonbsp,
848 'extra_sections' => $extra_sections,
852 push @sections, @$early;
853 $late_sections = $late;
855 if ( $conf->exists('svc_phone_sections')
856 && $self->can('_items_svc_phone_sections')
859 my ($phone_sections, $phone_lines) =
860 $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
861 push @{$late_sections}, @$phone_sections;
862 push @detail_items, @$phone_lines;
864 if ( $conf->exists('voip-cust_accountcode_cdr')
865 && $cust_main->accountcode_cdr
866 && $self->can('_items_accountcode_cdr')
869 my ($accountcode_section, $accountcode_lines) =
870 $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
871 if ( scalar(@$accountcode_lines) ) {
872 push @{$late_sections}, $accountcode_section;
873 push @detail_items, @$accountcode_lines;
876 } else {# not multisection
877 # make a default section
878 push @sections, $default_section;
879 # and calculate the finance charge total, since it won't get done otherwise.
880 # and the default section total
881 # XXX possibly finance_pkgclass should not be used in this manner?
884 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
885 if ( $invoice_data{finance_section} and
886 grep { $_->section eq $invoice_data{finance_section} }
887 $cust_bill_pkg->cust_bill_pkg_display ) {
888 # I think these are always setup fees, but just to be sure...
889 push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
891 push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
894 $invoice_data{finance_amount} =
895 sprintf('%.2f', sum( @finance_charges ) || 0);
896 $default_section->{subtotal} = $other_money_char.
897 sprintf('%.2f', sum( @charges ) || 0);
900 # previous invoice balances in the Previous Charges section if there
901 # is one, otherwise in the main detail section
902 # (except if summary_only is enabled, don't show them at all)
903 if ( $self->can('_items_previous') &&
904 $self->enable_previous &&
905 ! $conf->exists('previous_balance-summary_only') ) {
907 warn "$me adding previous balances\n"
910 foreach my $line_item ( $self->_items_previous ) {
913 ref => $line_item->{'pkgnum'},
914 pkgpart => $line_item->{'pkgpart'},
916 section => $previous_section, # which might be $default_section
917 description => &$escape_function($line_item->{'description'}),
918 ext_description => [ map { &$escape_function($_) }
919 @{ $line_item->{'ext_description'} || [] }
921 amount => ( $old_latex ? '' : $money_char).
922 $line_item->{'amount'},
923 product_code => $line_item->{'pkgpart'} || 'N/A',
926 push @detail_items, $detail;
927 push @buf, [ $detail->{'description'},
928 $money_char. sprintf("%10.2f", $line_item->{'amount'}),
934 if ( @pr_cust_bill && $self->enable_previous ) {
935 push @buf, ['','-----------'];
936 push @buf, [ $self->mt('Total Previous Balance'),
937 $money_char. sprintf("%10.2f", $pr_total) ];
941 if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) {
942 warn "$me adding DID summary\n"
945 my ($didsummary,$minutes) = $self->_did_summary;
946 my $didsummary_desc = 'DID Activity Summary (since last invoice)';
948 { 'description' => $didsummary_desc,
949 'ext_description' => [ $didsummary, $minutes ],
953 foreach my $section (@sections, @$late_sections) {
955 warn "$me adding section \n". Dumper($section)
958 # begin some normalization
959 $section->{'subtotal'} = $section->{'amount'}
961 && !exists($section->{subtotal})
962 && exists($section->{amount});
964 $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
965 if ( $invoice_data{finance_section} &&
966 $section->{'description'} eq $invoice_data{finance_section} );
968 $section->{'subtotal'} = $other_money_char.
969 sprintf('%.2f', $section->{'subtotal'})
972 # continue some normalization
973 $section->{'amount'} = $section->{'subtotal'}
977 if ( $section->{'description'} ) {
978 push @buf, ( [ &$escape_function($section->{'description'}), '' ],
983 warn "$me setting options\n"
987 $options{'section'} = $section if $multisection;
988 $options{'format'} = $format;
989 $options{'escape_function'} = $escape_function;
990 $options{'no_usage'} = 1 unless $unsquelched;
991 $options{'unsquelched'} = $unsquelched;
992 $options{'summary_page'} = $summarypage;
993 $options{'skip_usage'} =
994 scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
996 warn "$me searching for line items\n"
999 foreach my $line_item ( $self->_items_pkg(%options) ) {
1001 warn "$me adding line item $line_item\n"
1005 ext_description => [],
1007 $detail->{'ref'} = $line_item->{'pkgnum'};
1008 $detail->{'pkgpart'} = $line_item->{'pkgpart'};
1009 $detail->{'quantity'} = $line_item->{'quantity'};
1010 $detail->{'section'} = $section;
1011 $detail->{'description'} = &$escape_function($line_item->{'description'});
1012 if ( exists $line_item->{'ext_description'} ) {
1013 @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
1015 $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
1016 $line_item->{'amount'};
1017 if ( exists $line_item->{'unit_amount'} ) {
1018 $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
1019 $line_item->{'unit_amount'};
1021 $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
1023 $detail->{'sdate'} = $line_item->{'sdate'};
1024 $detail->{'edate'} = $line_item->{'edate'};
1025 $detail->{'seconds'} = $line_item->{'seconds'};
1026 $detail->{'svc_label'} = $line_item->{'svc_label'};
1028 push @detail_items, $detail;
1029 push @buf, ( [ $detail->{'description'},
1030 $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1032 map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
1036 if ( $section->{'description'} ) {
1037 push @buf, ( ['','-----------'],
1038 [ $section->{'description'}. ' sub-total',
1039 $section->{'subtotal'} # already formatted this
1048 $invoice_data{current_less_finance} =
1049 sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
1051 # if there's anything in the Previous Charges section, prepend it to the list
1052 if ( $pr_total and $previous_section ne $default_section ) {
1053 unshift @sections, $previous_section;
1056 warn "$me adding taxes\n"
1059 my @items_tax = $self->_items_tax;
1060 foreach my $tax ( @items_tax ) {
1062 $taxtotal += $tax->{'amount'};
1064 my $description = &$escape_function( $tax->{'description'} );
1065 my $amount = sprintf( '%.2f', $tax->{'amount'} );
1067 if ( $multisection ) {
1069 my $money = $old_latex ? '' : $money_char;
1070 push @detail_items, {
1071 ext_description => [],
1074 description => $description,
1075 amount => $money. $amount,
1077 section => $tax_section,
1082 push @total_items, {
1083 'total_item' => $description,
1084 'total_amount' => $other_money_char. $amount,
1089 push @buf,[ $description,
1090 $money_char. $amount,
1097 $total->{'total_item'} = $self->mt('Sub-total');
1098 $total->{'total_amount'} =
1099 $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
1101 if ( $multisection ) {
1102 $tax_section->{'subtotal'} = $other_money_char.
1103 sprintf('%.2f', $taxtotal);
1104 $tax_section->{'pretotal'} = 'New charges sub-total '.
1105 $total->{'total_amount'};
1106 push @sections, $tax_section if $taxtotal;
1108 unshift @total_items, $total;
1111 $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
1113 push @buf,['','-----------'];
1114 push @buf,[$self->mt(
1115 (!$self->enable_previous)
1117 : 'Total New Charges'
1119 $money_char. sprintf("%10.2f",$self->charged) ];
1127 my %embolden_functions = (
1128 'latex' => sub { return '\textbf{'. shift(). '}' },
1129 'html' => sub { return '<b>'. shift(). '</b>' },
1130 'template' => sub { shift },
1132 my $embolden_function = $embolden_functions{$format};
1134 if ( $self->can('_items_total') ) { # quotations
1136 $self->_items_total(\@total_items);
1138 foreach ( @total_items ) {
1139 $_->{'total_item'} = &$embolden_function( $_->{'total_item'} );
1140 $_->{'total_amount'} = &$embolden_function( $other_money_char.
1141 $_->{'total_amount'}
1145 } else { #normal invoice case
1147 # calculate total, possibly including total owed on previous
1151 $item = $conf->config('previous_balance-exclude_from_total')
1152 || 'Total New Charges'
1153 if $conf->exists('previous_balance-exclude_from_total');
1154 my $amount = $self->charged;
1155 if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) {
1156 $amount += $pr_total;
1159 $total->{'total_item'} = &$embolden_function($self->mt($item));
1160 $total->{'total_amount'} =
1161 &$embolden_function( $other_money_char. sprintf( '%.2f', $amount ) );
1162 if ( $multisection ) {
1163 if ( $adjust_section->{'sort_weight'} ) {
1164 $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
1165 $other_money_char. sprintf("%.2f", ($self->billing_balance || 0) );
1167 $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
1168 $other_money_char. sprintf('%.2f', $self->charged );
1171 push @total_items, $total;
1173 push @buf,['','-----------'];
1176 sprintf( '%10.2f', $amount )
1180 # if we're showing previous invoices, also show previous
1181 # credits and payments
1182 if ( $self->enable_previous
1183 and $self->can('_items_credits')
1184 and $self->can('_items_payments') )
1186 #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
1189 my $credittotal = 0;
1190 foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
1193 $total->{'total_item'} = &$escape_function($credit->{'description'});
1194 $credittotal += $credit->{'amount'};
1195 $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
1196 $adjusttotal += $credit->{'amount'};
1197 if ( $multisection ) {
1198 my $money = $old_latex ? '' : $money_char;
1199 push @detail_items, {
1200 ext_description => [],
1203 description => &$escape_function($credit->{'description'}),
1204 amount => $money. $credit->{'amount'},
1206 section => $adjust_section,
1209 push @total_items, $total;
1213 $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
1216 foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
1217 push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
1221 my $paymenttotal = 0;
1222 foreach my $payment ( $self->_items_payments ) {
1224 $total->{'total_item'} = &$escape_function($payment->{'description'});
1225 $paymenttotal += $payment->{'amount'};
1226 $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
1227 $adjusttotal += $payment->{'amount'};
1228 if ( $multisection ) {
1229 my $money = $old_latex ? '' : $money_char;
1230 push @detail_items, {
1231 ext_description => [],
1234 description => &$escape_function($payment->{'description'}),
1235 amount => $money. $payment->{'amount'},
1237 section => $adjust_section,
1240 push @total_items, $total;
1242 push @buf, [ $payment->{'description'},
1243 $money_char. sprintf("%10.2f", $payment->{'amount'}),
1246 $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
1248 if ( $multisection ) {
1249 $adjust_section->{'subtotal'} = $other_money_char.
1250 sprintf('%.2f', $adjusttotal);
1251 push @sections, $adjust_section
1252 unless $adjust_section->{sort_weight};
1255 # create Balance Due message
1258 $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1259 $total->{'total_amount'} =
1260 &$embolden_function(
1261 $other_money_char. sprintf('%.2f', #why? $summarypage
1262 # ? $self->charged +
1263 # $self->billing_balance
1265 $self->owed + $pr_total
1268 if ( $multisection && !$adjust_section->{sort_weight} ) {
1269 $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
1270 $total->{'total_amount'};
1272 push @total_items, $total;
1274 push @buf,['','-----------'];
1275 push @buf,[$self->balance_due_msg, $money_char.
1276 sprintf("%10.2f", $balance_due ) ];
1279 if ( $conf->exists('previous_balance-show_credit')
1280 and $cust_main->balance < 0 ) {
1281 my $credit_total = {
1282 'total_item' => &$embolden_function($self->credit_balance_msg),
1283 'total_amount' => &$embolden_function(
1284 $other_money_char. sprintf('%.2f', -$cust_main->balance)
1287 if ( $multisection ) {
1288 $adjust_section->{'posttotal'} .= $newline_token .
1289 $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
1292 push @total_items, $credit_total;
1294 push @buf,['','-----------'];
1295 push @buf,[$self->credit_balance_msg, $money_char.
1296 sprintf("%10.2f", -$cust_main->balance ) ];
1300 } #end of default total adding ! can('_items_total')
1302 if ( $multisection ) {
1303 if ( $conf->exists('svc_phone_sections')
1304 && $self->can('_items_svc_phone_sections')
1308 $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1309 $total->{'total_amount'} =
1310 &$embolden_function(
1311 $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
1313 my $last_section = pop @sections;
1314 $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
1315 $total->{'total_amount'};
1316 push @sections, $last_section;
1318 push @sections, @$late_sections
1322 # make a discounts-available section, even without multisection
1323 if ( $conf->exists('discount-show_available')
1324 and my @discounts_avail = $self->_items_discounts_avail ) {
1325 my $discount_section = {
1326 'description' => $self->mt('Discounts Available'),
1331 push @sections, $discount_section;
1332 push @detail_items, map { +{
1333 'ref' => '', #should this be something else?
1334 'section' => $discount_section,
1335 'description' => &$escape_function( $_->{description} ),
1336 'amount' => $money_char . &$escape_function( $_->{amount} ),
1337 'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
1338 } } @discounts_avail;
1341 my @summary_subtotals;
1342 # the templates say "$_->{tax_section} || !$_->{summarized}"
1343 # except 'summarized' is only true when tax_section is true, so this
1344 # is always true, so what's the deal?
1345 foreach my $s (@sections) {
1346 # not to include in the "summary of new charges" block:
1347 # finance charges, adjustments, previous charges,
1348 # and itemized phone usage sections
1349 if ( $s eq $adjust_section or
1350 ($s eq $previous_section and $s ne $default_section) or
1351 ($invoice_data{'finance_section'} and
1352 $invoice_data{'finance_section'} eq $s->{description}) or
1353 $s->{'description'} =~ /^\d+ $/ ) {
1356 push @summary_subtotals, $s;
1358 $invoice_data{summary_subtotals} = \@summary_subtotals;
1360 # debugging hook: call this with 'diag' => 1 to just get a hash of
1361 # the invoice variables
1362 return \%invoice_data if ( $params{'diag'} );
1364 # All sections and items are built; now fill in templates.
1365 my @includelist = ();
1366 push @includelist, 'summary' if $summarypage;
1367 foreach my $include ( @includelist ) {
1369 my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
1372 if ( length( $conf->config($inc_file, $agentnum) ) ) {
1374 @inc_src = $conf->config($inc_file, $agentnum);
1378 $inc_file = $conf->key_orbase("invoice_latex$include", $template);
1380 my $convert_map = $convert_maps{$format}{$include};
1382 @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
1383 s/--\@\]/$delimiters{$format}[1]/g;
1386 &$convert_map( $conf->config($inc_file, $agentnum) );
1390 my $inc_tt = new Text::Template (
1392 SOURCE => [ map "$_\n", @inc_src ],
1393 DELIMITERS => $delimiters{$format},
1394 ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
1396 unless ( $inc_tt->compile() ) {
1397 my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
1398 warn $error. "Template:\n". join('', map "$_\n", @inc_src);
1402 $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
1404 $invoice_data{$include} =~ s/\n+$//
1405 if ($format eq 'latex');
1410 foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
1411 /invoice_lines\((\d*)\)/;
1412 $invoice_lines += $1 || scalar(@buf);
1415 die "no invoice_lines() functions in template?"
1416 if ( $format eq 'template' && !$wasfunc );
1418 if ($format eq 'template') {
1420 if ( $invoice_lines ) {
1421 $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
1422 $invoice_data{'total_pages'}++
1423 if scalar(@buf) % $invoice_lines;
1426 #setup subroutine for the template
1427 $invoice_data{invoice_lines} = sub {
1428 my $lines = shift || scalar(@buf);
1440 push @collect, split("\n",
1441 $text_template->fill_in( HASH => \%invoice_data )
1443 $invoice_data{'page'}++;
1445 map "$_\n", @collect;
1447 } else { # this is where we actually create the invoice
1449 warn "filling in template for invoice ". $self->invnum. "\n"
1451 warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
1454 $text_template->fill_in(HASH => \%invoice_data);
1458 sub notice_name { '('.shift->table.')'; }
1460 sub template_conf { 'invoice_'; }
1462 # helper routine for generating date ranges
1463 sub _prior_month30s {
1466 [ 1, 2592000 ], # 0-30 days ago
1467 [ 2592000, 5184000 ], # 30-60 days ago
1468 [ 5184000, 7776000 ], # 60-90 days ago
1469 [ 7776000, 0 ], # 90+ days ago
1472 map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
1473 $_->[1] ? $self->_date - $_->[1] - 1 : '',
1478 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
1480 Returns an postscript invoice, as a scalar.
1482 Options can be passed as a hashref (recommended) or as a list of time, template
1483 and then any key/value pairs for any other options.
1485 I<time> an optional value used to control the printing of overdue messages. The
1486 default is now. It isn't the date of the invoice; that's the `_date' field.
1487 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1488 L<Time::Local> and L<Date::Parse> for conversion functions.
1490 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1497 my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1498 my $ps = generate_ps($file);
1500 unlink($barcodefile) if $barcodefile;
1505 =item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
1507 Returns an PDF invoice, as a scalar.
1509 Options can be passed as a hashref (recommended) or as a list of time, template
1510 and then any key/value pairs for any other options.
1512 I<time> an optional value used to control the printing of overdue messages. The
1513 default is now. It isn't the date of the invoice; that's the `_date' field.
1514 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1515 L<Time::Local> and L<Date::Parse> for conversion functions.
1517 I<template>, if specified, is the name of a suffix for alternate invoices.
1519 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1526 my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1527 my $pdf = generate_pdf($file);
1529 unlink($barcodefile) if $barcodefile;
1534 =item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
1536 Returns an HTML invoice, as a scalar.
1538 I<time> an optional value used to control the printing of overdue messages. The
1539 default is now. It isn't the date of the invoice; that's the `_date' field.
1540 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1541 L<Time::Local> and L<Date::Parse> for conversion functions.
1543 I<template>, if specified, is the name of a suffix for alternate invoices.
1545 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1547 I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
1548 when emailing the invoice as part of a multipart/related MIME email.
1556 %params = %{ shift() };
1558 $params{'time'} = shift;
1559 $params{'template'} = shift;
1560 $params{'cid'} = shift;
1563 $params{'format'} = 'html';
1565 $self->print_generic( %params );
1568 # quick subroutine for print_latex
1570 # There are ten characters that LaTeX treats as special characters, which
1571 # means that they do not simply typeset themselves:
1572 # # $ % & ~ _ ^ \ { }
1574 # TeX ignores blanks following an escaped character; if you want a blank (as
1575 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
1579 $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
1580 $value =~ s/([<>])/\$$1\$/g;
1586 encode_entities($value);
1590 sub _html_escape_nbsp {
1591 my $value = _html_escape(shift);
1592 $value =~ s/ +/ /g;
1596 #utility methods for print_*
1598 sub _translate_old_latex_format {
1599 warn "_translate_old_latex_format called\n"
1606 if ( $line =~ /^%%Detail\s*$/ ) {
1608 push @template, q![@--!,
1609 q! foreach my $_tr_line (@detail_items) {!,
1610 q! if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
1611 q! $_tr_line->{'description'} .= !,
1612 q! "\\tabularnewline\n~~".!,
1613 q! join( "\\tabularnewline\n~~",!,
1614 q! @{$_tr_line->{'ext_description'}}!,
1618 while ( ( my $line_item_line = shift )
1619 !~ /^%%EndDetail\s*$/ ) {
1620 $line_item_line =~ s/'/\\'/g; # nice LTS
1621 $line_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
1622 $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1623 push @template, " \$OUT .= '$line_item_line';";
1626 push @template, '}',
1629 } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1631 push @template, '[@--',
1632 ' foreach my $_tr_line (@total_items) {';
1634 while ( ( my $total_item_line = shift )
1635 !~ /^%%EndTotalDetails\s*$/ ) {
1636 $total_item_line =~ s/'/\\'/g; # nice LTS
1637 $total_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
1638 $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1639 push @template, " \$OUT .= '$total_item_line';";
1642 push @template, '}',
1646 $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
1647 push @template, $line;
1653 warn "$_\n" foreach @template;
1661 my $conf = $self->conf;
1663 #check for an invoice-specific override
1664 return $self->invoice_terms if $self->invoice_terms;
1666 #check for a customer- specific override
1667 my $cust_main = $self->cust_main;
1668 return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
1670 #use configured default
1671 $conf->config('invoice_default_terms') || '';
1677 if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
1678 $duedate = $self->_date() + ( $1 * 86400 );
1685 $self->due_date ? time2str(shift, $self->due_date) : '';
1688 sub balance_due_msg {
1690 my $msg = $self->mt('Balance Due');
1691 return $msg unless $self->terms;
1692 if ( $self->due_date ) {
1693 $msg .= ' - ' . $self->mt('Please pay by'). ' '.
1694 $self->due_date2str($date_format);
1695 } elsif ( $self->terms ) {
1696 $msg .= ' - '. $self->terms;
1701 sub balance_due_date {
1703 my $conf = $self->conf;
1705 if ( $conf->exists('invoice_default_terms')
1706 && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
1707 $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
1712 sub credit_balance_msg {
1714 $self->mt('Credit Balance Remaining')
1719 Returns a string with the date, for example: "3/20/2008"
1725 time2str($date_format, $self->_date);
1728 =item _items_sections OPTIONS
1730 Generate section information for all items appearing on this invoice.
1731 This will only be called for multi-section invoices.
1733 For each line item (L<FS::cust_bill_pkg> record), this will fetch all
1734 related display records (L<FS::cust_bill_pkg_display>) and organize
1735 them into two groups ("early" and "late" according to whether they come
1736 before or after the total), then into sections. A subtotal is calculated
1739 Section descriptions are returned in sort weight order. Each consists
1740 of a hash containing:
1742 description: the package category name, escaped
1743 subtotal: the total charges in that section
1744 tax_section: a flag indicating that the section contains only tax charges
1745 summarized: same as tax_section, for some reason
1746 sort_weight: the package category's sort weight
1748 If 'condense' is set on the display record, it also contains everything
1749 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
1750 coderefs to generate parts of the invoice. This is not advised.
1752 The method returns two arrayrefs, one of "early" sections and one of "late"
1755 OPTIONS may include:
1757 by_location: a flag to divide the invoice into sections by location.
1758 Each section hash will have a 'location' element containing a hashref of
1759 the location fields (see L<FS::cust_location>). The section description
1760 will be the location label, but the template can use any of the location
1761 fields to create a suitable label.
1763 by_category: a flag to divide the invoice into sections using display
1764 records (see L<FS::cust_bill_pkg_display>). This is the "traditional"
1765 behavior. Each section hash will have a 'category' element containing
1766 the section name from the display record (which probably equals the
1767 category name of the package, but may not in some cases).
1769 summary: a flag indicating that this is a summary-format invoice.
1770 Turning this on has the following effects:
1771 - Ignores display items with the 'summary' flag.
1772 - Places all sections in the "early" group even if they have post_total.
1773 - Creates sections for all non-disabled package categories, even if they
1774 have no charges on this invoice, as well as a section with no name.
1776 escape: an escape function to use for section titles.
1778 extra_sections: an arrayref of additional sections to return after the
1779 sorted list. If there are any of these, section subtotals exclude
1782 format: 'latex', 'html', or 'template' (i.e. text). Not used, but
1783 passed through to C<_condense_section()>.
1787 use vars qw(%pkg_category_cache);
1788 sub _items_sections {
1792 my $escape = $opt{escape};
1793 my @extra_sections = @{ $opt{extra_sections} || [] };
1795 # $subtotal{$locationnum}{$categoryname} = amount.
1796 # if we're not using by_location, $locationnum is undef.
1797 # if we're not using by_category, you guessed it, $categoryname is undef.
1798 # if we're not using either one, we shouldn't be here in the first place...
1800 my %late_subtotal = ();
1803 # About tax items + multisection invoices:
1804 # If either invoice_*summary option is enabled, AND there is a
1805 # package category with the name of the tax, then there will be
1806 # a display record assigning the tax item to that category.
1808 # However, the taxes are always placed in the "Taxes, Surcharges,
1809 # and Fees" section regardless of that. The only effect of the
1810 # display record is to create a subtotal for the summary page.
1813 my $pkg_hash = $self->cust_pkg_hash;
1815 foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
1818 my $usage = $cust_bill_pkg->usage;
1821 if ( $opt{by_location} ) {
1822 if ( $cust_bill_pkg->pkgnum ) {
1823 $locationnum = $pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum;
1828 $locationnum = undef;
1831 # as in _items_cust_pkg, if a line item has no display records,
1832 # cust_bill_pkg_display() returns a default record for it
1834 foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
1835 next if ( $display->summary && $opt{summary} );
1837 my $section = $display->section;
1838 my $type = $display->type;
1839 $section = undef unless $opt{by_category};
1841 $not_tax{$locationnum}{$section} = 1
1842 unless $cust_bill_pkg->pkgnum == 0;
1844 # there's actually a very important piece of logic buried in here:
1845 # incrementing $late_subtotal{$section} CREATES
1846 # $late_subtotal{$section}. keys(%late_subtotal) is later used
1847 # to define the list of late sections, and likewise keys(%subtotal).
1848 # When _items_cust_bill_pkg is called to generate line items for
1849 # real, it will be called with 'section' => $section for each
1851 if ( $display->post_total && !$opt{summary} ) {
1852 if (! $type || $type eq 'S') {
1853 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
1854 if $cust_bill_pkg->setup != 0
1855 || $cust_bill_pkg->setup_show_zero;
1859 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
1860 if $cust_bill_pkg->recur != 0
1861 || $cust_bill_pkg->recur_show_zero;
1864 if ($type && $type eq 'R') {
1865 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
1866 if $cust_bill_pkg->recur != 0
1867 || $cust_bill_pkg->recur_show_zero;
1870 if ($type && $type eq 'U') {
1871 $late_subtotal{$locationnum}{$section} += $usage
1872 unless scalar(@extra_sections);
1875 } else { # it's a pre-total (normal) section
1877 # skip tax items unless they're explicitly included in a section
1878 next if $cust_bill_pkg->pkgnum == 0 && ! $section;
1880 if (! $type || $type eq 'S') {
1881 $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
1882 if $cust_bill_pkg->setup != 0
1883 || $cust_bill_pkg->setup_show_zero;
1887 $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
1888 if $cust_bill_pkg->recur != 0
1889 || $cust_bill_pkg->recur_show_zero;
1892 if ($type && $type eq 'R') {
1893 $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
1894 if $cust_bill_pkg->recur != 0
1895 || $cust_bill_pkg->recur_show_zero;
1898 if ($type && $type eq 'U') {
1899 $subtotal{$locationnum}{$section} += $usage
1900 unless scalar(@extra_sections);
1909 %pkg_category_cache = ();
1911 # summary invoices need subtotals for all non-disabled package categories,
1912 # even if they're zero
1913 # but currently assume that there are no location sections, or at least
1914 # that the summary page doesn't care about them
1915 if ( $opt{summary} ) {
1916 foreach my $category (qsearch('pkg_category', {disabled => ''})) {
1917 $subtotal{''}{$category->categoryname} ||= 0;
1919 $subtotal{''}{''} ||= 0;
1923 foreach my $post_total (0,1) {
1925 my $s = $post_total ? \%late_subtotal : \%subtotal;
1926 foreach my $locationnum (keys %$s) {
1927 foreach my $sectionname (keys %{ $s->{$locationnum} }) {
1929 'subtotal' => $s->{$locationnum}{$sectionname},
1930 'post_total' => $post_total,
1933 if ( $locationnum ) {
1934 $section->{'locationnum'} = $locationnum;
1935 my $location = FS::cust_location->by_key($locationnum);
1936 $section->{'description'} = &{ $escape }($location->location_label);
1937 # Better ideas? This will roughly group them by proximity,
1938 # which alpha sorting on any of the address fields won't.
1939 # Sorting by locationnum is meaningless.
1940 # We have to sort on _something_ or the order may change
1941 # randomly from one invoice to the next, which will confuse
1943 $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
1945 $section->{'location'} = {
1946 map { $_ => &{ $escape }($location->get($_)) }
1950 $section->{'category'} = $sectionname;
1951 $section->{'description'} = &{ $escape }($sectionname);
1952 if ( _pkg_category($_) ) {
1953 $section->{'sort_weight'} = _pkg_category($_)->weight;
1954 if ( _pkg_category($_)->condense ) {
1955 $section = { %$section, $self->_condense_section($opt{format}) };
1959 if ( !$post_total and !$not_tax{$locationnum}{$sectionname} ) {
1960 # then it's a tax-only section
1961 $section->{'summarized'} = 'Y';
1962 $section->{'tax_section'} = 'Y';
1964 push @these, $section;
1965 } # foreach $sectionname
1966 } #foreach $locationnum
1967 push @these, @extra_sections if $post_total == 0;
1968 # need an alpha sort for location sections, because postal codes can
1970 $sections[ $post_total ] = [ sort {
1971 $opt{'by_location'} ?
1972 ($a->{sort_weight} cmp $b->{sort_weight}) :
1973 ($a->{sort_weight} <=> $b->{sort_weight})
1975 } #foreach $post_total
1977 return @sections; # early, late
1980 #helper subs for above
1984 $self->{cust_pkg} ||= { map { $_->pkgnum => $_ } $self->cust_pkg };
1988 my $categoryname = shift;
1989 $pkg_category_cache{$categoryname} ||=
1990 qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
1993 my %condensed_format = (
1994 'label' => [ qw( Description Qty Amount ) ],
1996 sub { shift->{description} },
1997 sub { shift->{quantity} },
1998 sub { my($href, %opt) = @_;
1999 ($opt{dollar} || ''). $href->{amount};
2002 'align' => [ qw( l r r ) ],
2003 'span' => [ qw( 5 1 1 ) ], # unitprices?
2004 'width' => [ qw( 10.7cm 1.4cm 1.6cm ) ], # don't like this
2007 sub _condense_section {
2008 my ( $self, $format ) = ( shift, shift );
2010 map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
2011 qw( description_generator
2014 total_line_generator
2019 sub _condensed_generator_defaults {
2020 my ( $self, $format ) = ( shift, shift );
2021 return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
2030 sub _condensed_header_generator {
2031 my ( $self, $format ) = ( shift, shift );
2033 my ( $f, $prefix, $suffix, $separator, $column ) =
2034 _condensed_generator_defaults($format);
2036 if ($format eq 'latex') {
2037 $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
2038 $suffix = "\\\\\n\\hline";
2041 sub { my ($d,$a,$s,$w) = @_;
2042 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2044 } elsif ( $format eq 'html' ) {
2045 $prefix = '<th></th>';
2049 sub { my ($d,$a,$s,$w) = @_;
2050 return qq!<th align="$html_align{$a}">$d</th>!;
2058 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2060 &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
2063 $prefix. join($separator, @result). $suffix;
2068 sub _condensed_description_generator {
2069 my ( $self, $format ) = ( shift, shift );
2071 my ( $f, $prefix, $suffix, $separator, $column ) =
2072 _condensed_generator_defaults($format);
2074 my $money_char = '$';
2075 if ($format eq 'latex') {
2076 $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
2078 $separator = " & \n";
2080 sub { my ($d,$a,$s,$w) = @_;
2081 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2083 $money_char = '\\dollar';
2084 }elsif ( $format eq 'html' ) {
2085 $prefix = '"><td align="center"></td>';
2089 sub { my ($d,$a,$s,$w) = @_;
2090 return qq!<td align="$html_align{$a}">$d</td>!;
2092 #$money_char = $conf->config('money_char') || '$';
2093 $money_char = ''; # this is madness
2101 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2103 $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
2105 &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
2106 map { $f->{$_}->[$i] } qw(align span width)
2110 $prefix. join( $separator, @result ). $suffix;
2115 sub _condensed_total_generator {
2116 my ( $self, $format ) = ( shift, shift );
2118 my ( $f, $prefix, $suffix, $separator, $column ) =
2119 _condensed_generator_defaults($format);
2122 if ($format eq 'latex') {
2125 $separator = " & \n";
2127 sub { my ($d,$a,$s,$w) = @_;
2128 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2130 }elsif ( $format eq 'html' ) {
2134 $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2136 sub { my ($d,$a,$s,$w) = @_;
2137 return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2146 # my $r = &{$f->{fields}->[$i]}(@args);
2147 # $r .= ' Total' unless $i;
2149 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2151 &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
2152 map { $f->{$_}->[$i] } qw(align span width)
2156 $prefix. join( $separator, @result ). $suffix;
2161 =item total_line_generator FORMAT
2163 Returns a coderef used for generation of invoice total line items for this
2164 usage_class. FORMAT is either html or latex
2168 # should not be used: will have issues with hash element names (description vs
2169 # total_item and amount vs total_amount -- another array of functions?
2171 sub _condensed_total_line_generator {
2172 my ( $self, $format ) = ( shift, shift );
2174 my ( $f, $prefix, $suffix, $separator, $column ) =
2175 _condensed_generator_defaults($format);
2178 if ($format eq 'latex') {
2181 $separator = " & \n";
2183 sub { my ($d,$a,$s,$w) = @_;
2184 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2186 }elsif ( $format eq 'html' ) {
2190 $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2192 sub { my ($d,$a,$s,$w) = @_;
2193 return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2202 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2204 &{$column}( &{$f->{fields}->[$i]}(@args),
2205 map { $f->{$_}->[$i] } qw(align span width)
2209 $prefix. join( $separator, @result ). $suffix;
2214 =item _items_pkg [ OPTIONS ]
2216 Return line item hashes for each package item on this invoice. Nearly
2219 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
2221 The only OPTIONS accepted is 'section', which may point to a hashref
2222 with a key named 'condensed', which may have a true value. If it
2223 does, this method tries to merge identical items into items with
2224 'quantity' equal to the number of items (not the sum of their
2225 separate quantities, for some reason).
2231 grep { $_->pkgnum } $self->cust_bill_pkg;
2238 warn "$me _items_pkg searching for all package line items\n"
2241 my @cust_bill_pkg = $self->_items_nontax;
2243 warn "$me _items_pkg filtering line items\n"
2245 my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2247 if ($options{section} && $options{section}->{condensed}) {
2249 warn "$me _items_pkg condensing section\n"
2253 local $Storable::canonical = 1;
2254 foreach ( @items ) {
2256 delete $item->{ref};
2257 delete $item->{ext_description};
2258 my $key = freeze($item);
2259 $itemshash{$key} ||= 0;
2260 $itemshash{$key} ++; # += $item->{quantity};
2262 @items = sort { $a->{description} cmp $b->{description} }
2263 map { my $i = thaw($_);
2264 $i->{quantity} = $itemshash{$_};
2266 sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
2272 warn "$me _items_pkg returning ". scalar(@items). " items\n"
2279 return 0 unless $a->itemdesc cmp $b->itemdesc;
2280 return -1 if $b->itemdesc eq 'Tax';
2281 return 1 if $a->itemdesc eq 'Tax';
2282 return -1 if $b->itemdesc eq 'Other surcharges';
2283 return 1 if $a->itemdesc eq 'Other surcharges';
2284 $a->itemdesc cmp $b->itemdesc;
2289 my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
2290 my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2292 if ( $self->conf->exists('always_show_tax') ) {
2293 my $itemdesc = $self->conf->config('always_show_tax') || 'Tax';
2294 if (0 == grep { $_->{description} eq $itemdesc } @items) {
2296 { 'description' => $itemdesc,
2303 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
2305 Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
2306 list of hashrefs describing the line items they generate on the invoice.
2308 OPTIONS may include:
2310 format: the invoice format.
2312 escape_function: the function used to escape strings.
2314 DEPRECATED? (expensive, mostly unused?)
2315 format_function: the function used to format CDRs.
2317 section: a hashref containing 'category' and/or 'locationnum'; if this
2318 is present, only returns line items that belong to that category and/or
2319 location (whichever is defined).
2321 multisection: a flag indicating that this is a multisection invoice,
2322 which does something complicated.
2324 Returns a list of hashrefs, each of which may contain:
2326 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and
2327 ext_description, which is an arrayref of detail lines to show below
2332 sub _items_cust_bill_pkg {
2334 my $conf = $self->conf;
2335 my $cust_bill_pkgs = shift;
2338 my $format = $opt{format} || '';
2339 my $escape_function = $opt{escape_function} || sub { shift };
2340 my $format_function = $opt{format_function} || '';
2341 my $no_usage = $opt{no_usage} || '';
2342 my $unsquelched = $opt{unsquelched} || ''; #unused
2343 my ($section, $locationnum, $category);
2344 if ( $opt{section} ) {
2345 $category = $opt{section}->{category};
2346 $locationnum = $opt{section}->{locationnum};
2348 my $summary_page = $opt{summary_page} || ''; #unused
2349 my $multisection = defined($category) || defined($locationnum);
2350 my $discount_show_always = 0;
2352 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2354 my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
2355 # and location labels
2358 my ($s, $r, $u) = ( undef, undef, undef );
2359 foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
2362 foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2363 if ( $_ && !$cust_bill_pkg->hidden ) {
2364 $_->{amount} = sprintf( "%.2f", $_->{amount} ),
2365 $_->{amount} =~ s/^\-0\.00$/0.00/;
2366 $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2368 if $_->{amount} != 0
2369 || $discount_show_always
2370 || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2371 || ( $_->{_is_setup} && $_->{setup_show_zero} )
2377 if ( $locationnum ) {
2378 # this is a location section; skip packages that aren't at this
2380 next if $cust_bill_pkg->pkgnum == 0;
2381 next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum
2385 # Consider display records for this item to determine if it belongs
2386 # in this section. Note that if there are no display records, there
2387 # will be a default pseudo-record that includes all charge types
2388 # and has no section name.
2389 my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
2390 ? $cust_bill_pkg->cust_bill_pkg_display
2391 : ( $cust_bill_pkg );
2393 warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
2394 $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
2397 if ( defined($category) ) {
2398 # then this is a package category section; process all display records
2399 # that belong to this section.
2400 @cust_bill_pkg_display = grep { $_->section eq $category }
2401 @cust_bill_pkg_display;
2403 # otherwise, process all display records that aren't usage summaries
2404 # (I don't think there should be usage summaries if you aren't using
2405 # category sections, but this is the historical behavior)
2406 @cust_bill_pkg_display = grep { !$_->summary }
2407 @cust_bill_pkg_display;
2409 foreach my $display (@cust_bill_pkg_display) {
2411 warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
2412 $display->billpkgdisplaynum. "\n"
2415 my $type = $display->type;
2417 my $desc = $cust_bill_pkg->desc( $cust_main ? $cust_main->locale : '' );
2418 $desc = substr($desc, 0, $maxlength). '...'
2419 if $format eq 'latex' && length($desc) > $maxlength;
2421 my %details_opt = ( 'format' => $format,
2422 'escape_function' => $escape_function,
2423 'format_function' => $format_function,
2424 'no_usage' => $opt{'no_usage'},
2427 if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
2429 warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
2432 if ( $cust_bill_pkg->setup != 0 ) {
2433 my $description = $desc;
2434 $description .= ' Setup'
2435 if $cust_bill_pkg->recur != 0
2436 || $discount_show_always
2437 || $cust_bill_pkg->recur_show_zero;
2439 'description' => $description,
2440 'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
2443 if ( $cust_bill_pkg->recur != 0 ) {
2445 'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
2446 'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
2450 } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
2452 warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
2455 my $cust_pkg = $cust_bill_pkg->cust_pkg;
2457 # which pkgpart to show for display purposes?
2458 my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
2460 # start/end dates for invoice formats that do nonstandard
2462 my %item_dates = ();
2463 %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
2464 unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
2466 if ( (!$type || $type eq 'S')
2467 && ( $cust_bill_pkg->setup != 0
2468 || $cust_bill_pkg->setup_show_zero
2473 warn "$me _items_cust_bill_pkg adding setup\n"
2476 my $description = $desc;
2477 $description .= ' Setup'
2478 if $cust_bill_pkg->recur != 0
2479 || $discount_show_always
2480 || $cust_bill_pkg->recur_show_zero;
2484 unless ( $cust_pkg->part_pkg->hide_svc_detail
2485 || $cust_bill_pkg->hidden )
2488 my @svc_labels = map &{$escape_function}($_),
2489 $cust_pkg->h_labels_short($self->_date, undef, 'I');
2490 push @d, @svc_labels
2491 unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2492 $svc_label = $svc_labels[0];
2494 my $lnum = $cust_main ? $cust_main->ship_locationnum
2495 : $self->prospect_main->locationnum;
2496 # show the location label if it's not the customer's default
2497 # location, and we're not grouping items by location already
2498 if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
2499 my $loc = $cust_pkg->location_label;
2500 $loc = substr($loc, 0, $maxlength). '...'
2501 if $format eq 'latex' && length($loc) > $maxlength;
2502 push @d, &{$escape_function}($loc);
2505 } #unless hiding service details
2507 push @d, $cust_bill_pkg->details(%details_opt)
2508 if $cust_bill_pkg->recur == 0;
2510 if ( $cust_bill_pkg->hidden ) {
2511 $s->{amount} += $cust_bill_pkg->setup;
2512 $s->{unit_amount} += $cust_bill_pkg->unitsetup;
2513 push @{ $s->{ext_description} }, @d;
2517 description => $description,
2518 pkgpart => $pkgpart,
2519 pkgnum => $cust_bill_pkg->pkgnum,
2520 amount => $cust_bill_pkg->setup,
2521 setup_show_zero => $cust_bill_pkg->setup_show_zero,
2522 unit_amount => $cust_bill_pkg->unitsetup,
2523 quantity => $cust_bill_pkg->quantity,
2524 ext_description => \@d,
2525 svc_label => ($svc_label || ''),
2531 if ( ( !$type || $type eq 'R' || $type eq 'U' )
2533 $cust_bill_pkg->recur != 0
2534 || $cust_bill_pkg->setup == 0
2535 || $discount_show_always
2536 || $cust_bill_pkg->recur_show_zero
2541 warn "$me _items_cust_bill_pkg adding recur/usage\n"
2544 my $is_summary = $display->summary;
2545 my $description = ($is_summary && $type && $type eq 'U')
2546 ? "Usage charges" : $desc;
2548 my $part_pkg = $cust_pkg->part_pkg;
2550 #pry be a bit more efficient to look some of this conf stuff up
2553 $conf->exists('disable_line_item_date_ranges')
2554 || $part_pkg->option('disable_line_item_date_ranges',1)
2555 || ! $cust_bill_pkg->sdate
2556 || ! $cust_bill_pkg->edate
2559 my $date_style = '';
2560 $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monhtly',
2563 if $part_pkg && $part_pkg->freq !~ /^1m?$/;
2564 $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
2567 if ( defined($date_style) && $date_style eq 'month_of' ) {
2568 $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
2569 } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
2570 my $desc = $conf->config( 'cust_bill-line_item-date_description',
2573 $desc .= ' ' unless $desc =~ /\s$/;
2574 $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
2576 $time_period = time2str($date_format, $cust_bill_pkg->sdate).
2577 " - ". time2str($date_format, $cust_bill_pkg->edate);
2579 $description .= " ($time_period)";
2583 my @seconds = (); # for display of usage info
2586 #at least until cust_bill_pkg has "past" ranges in addition to
2587 #the "future" sdate/edate ones... see #3032
2588 my @dates = ( $self->_date );
2589 my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
2590 push @dates, $prev->sdate if $prev;
2591 push @dates, undef if !$prev;
2593 unless ( $part_pkg->hide_svc_detail
2594 || $cust_bill_pkg->itemdesc
2595 || $cust_bill_pkg->hidden
2596 || $is_summary && $type && $type eq 'U'
2600 warn "$me _items_cust_bill_pkg adding service details\n"
2603 my @svc_labels = map &{$escape_function}($_),
2604 $cust_pkg->h_labels_short(@dates, 'I');
2605 push @d, @svc_labels
2606 unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2607 $svc_label = $svc_labels[0];
2609 warn "$me _items_cust_bill_pkg done adding service details\n"
2612 my $lnum = $cust_main ? $cust_main->ship_locationnum
2613 : $self->prospect_main->locationnum;
2614 # show the location label if it's not the customer's default
2615 # location, and we're not grouping items by location already
2616 if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
2617 my $loc = $cust_pkg->location_label;
2618 $loc = substr($loc, 0, $maxlength). '...'
2619 if $format eq 'latex' && length($loc) > $maxlength;
2620 push @d, &{$escape_function}($loc);
2623 # Display of seconds_since_sqlradacct:
2624 # On the invoice, when processing @detail_items, look for a field
2625 # named 'seconds'. This will contain total seconds for each
2626 # service, in the same order as @ext_description. For services
2627 # that don't support this it will show undef.
2628 if ( $conf->exists('svc_acct-usage_seconds')
2629 and ! $cust_bill_pkg->pkgpart_override ) {
2630 foreach my $cust_svc (
2631 $cust_pkg->h_cust_svc(@dates, 'I')
2634 # eval because not having any part_export_usage exports
2635 # is a fatal error, last_bill/_date because that's how
2636 # sqlradius_hour billing does it
2638 $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
2640 push @seconds, $sec;
2642 } #if svc_acct-usage_seconds
2646 unless ( $is_summary ) {
2647 warn "$me _items_cust_bill_pkg adding details\n"
2650 #instead of omitting details entirely in this case (unwanted side
2651 # effects), just omit CDRs
2652 $details_opt{'no_usage'} = 1
2653 if $type && $type eq 'R';
2655 push @d, $cust_bill_pkg->details(%details_opt);
2658 warn "$me _items_cust_bill_pkg calculating amount\n"
2663 $amount = $cust_bill_pkg->recur;
2664 } elsif ($type eq 'R') {
2665 $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
2666 } elsif ($type eq 'U') {
2667 $amount = $cust_bill_pkg->usage;
2671 ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
2674 if ( !$type || $type eq 'R' ) {
2676 warn "$me _items_cust_bill_pkg adding recur\n"
2679 if ( $cust_bill_pkg->hidden ) {
2680 $r->{amount} += $amount;
2681 $r->{unit_amount} += $unit_amount;
2682 push @{ $r->{ext_description} }, @d;
2685 description => $description,
2686 pkgpart => $pkgpart,
2687 pkgnum => $cust_bill_pkg->pkgnum,
2689 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2690 unit_amount => $unit_amount,
2691 quantity => $cust_bill_pkg->quantity,
2693 ext_description => \@d,
2694 svc_label => ($svc_label || ''),
2696 $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
2699 } else { # $type eq 'U'
2701 warn "$me _items_cust_bill_pkg adding usage\n"
2704 if ( $cust_bill_pkg->hidden ) {
2705 $u->{amount} += $amount;
2706 $u->{unit_amount} += $unit_amount,
2707 push @{ $u->{ext_description} }, @d;
2710 description => $description,
2711 pkgpart => $pkgpart,
2712 pkgnum => $cust_bill_pkg->pkgnum,
2714 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2715 unit_amount => $unit_amount,
2716 quantity => $cust_bill_pkg->quantity,
2718 ext_description => \@d,
2723 } # recurring or usage with recurring charge
2725 } else { #pkgnum tax or one-shot line item (??)
2727 warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
2730 if ( $cust_bill_pkg->setup != 0 ) {
2732 'description' => $desc,
2733 'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
2736 if ( $cust_bill_pkg->recur != 0 ) {
2738 'description' => "$desc (".
2739 time2str($date_format, $cust_bill_pkg->sdate). ' - '.
2740 time2str($date_format, $cust_bill_pkg->edate). ')',
2741 'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
2749 $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
2750 && $conf->exists('discount-show-always'));
2754 foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2756 $_->{amount} = sprintf( "%.2f", $_->{amount} ),
2757 $_->{amount} =~ s/^\-0\.00$/0.00/;
2758 $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2760 if $_->{amount} != 0
2761 || $discount_show_always
2762 || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2763 || ( $_->{_is_setup} && $_->{setup_show_zero} )
2767 warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
2774 =item _items_discounts_avail
2776 Returns an array of line item hashrefs representing available term discounts
2777 for this invoice. This makes the same assumptions that apply to term
2778 discounts in general: that the package is billed monthly, at a flat rate,
2779 with no usage charges. A prorated first month will be handled, as will
2780 a setup fee if the discount is allowed to apply to setup fees.
2784 sub _items_discounts_avail {
2787 #maybe move this method from cust_bill when quotations support discount_plans
2788 return () unless $self->can('discount_plans');
2789 my %plans = $self->discount_plans;
2791 my $list_pkgnums = 0; # if any packages are not eligible for all discounts
2792 $list_pkgnums = grep { $_->list_pkgnums } values %plans;
2796 my $plan = $plans{$months};
2798 my $term_total = sprintf('%.2f', $plan->discounted_total);
2799 my $percent = sprintf('%.0f',
2800 100 * (1 - $term_total / $plan->base_total) );
2801 my $permonth = sprintf('%.2f', $term_total / $months);
2802 my $detail = $self->mt('discount on item'). ' '.
2803 join(', ', map { "#$_" } $plan->pkgnums)
2806 # discounts for non-integer months don't work anyway
2807 $months = sprintf("%d", $months);
2810 description => $self->mt('Save [_1]% by paying for [_2] months',
2812 amount => $self->mt('[_1] ([_2] per month)',
2813 $term_total, $money_char.$permonth),
2814 ext_description => ($detail || ''),
2817 sort { $b <=> $a } keys %plans;