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 if ( $self->custnum && $self->invnum ) {
613 if ( $self->previous_bill ) {
614 my $last_bill = $self->previous_bill;
615 $invoice_data{'last_bill'} = {
616 '_date' => $last_bill->_date, #unformatted
618 my (@payments, @credits);
619 # for formats that itemize previous payments
620 foreach my $cust_pay ( qsearch('cust_pay', {
621 'custnum' => $self->custnum,
622 '_date' => { op => '>=',
623 value => $last_bill->_date }
626 next if $cust_pay->_date > $self->_date;
628 '_date' => $cust_pay->_date,
629 'date' => time2str($date_format, $cust_pay->_date),
630 'payinfo' => $cust_pay->payby_payinfo_pretty,
631 'amount' => sprintf('%.2f', $cust_pay->paid),
633 # not concerned about applications
635 foreach my $cust_credit ( qsearch('cust_credit', {
636 'custnum' => $self->custnum,
637 '_date' => { op => '>=',
638 value => $last_bill->_date }
641 next if $cust_credit->_date > $self->_date;
643 '_date' => $cust_credit->_date,
644 'date' => time2str($date_format, $cust_credit->_date),
645 'creditreason'=> $cust_credit->reason,
646 'amount' => sprintf('%.2f', $cust_credit->amount),
649 $invoice_data{'previous_payments'} = \@payments;
650 $invoice_data{'previous_credits'} = \@credits;
655 my $summarypage = '';
656 if ( $conf->exists('invoice_usesummary', $agentnum) ) {
659 $invoice_data{'summarypage'} = $summarypage;
661 warn "$me substituting variables in notes, footer, smallfooter\n"
664 my $tc = $self->template_conf;
665 my @include = ( [ $tc, 'notes' ],
666 [ 'invoice_', 'footer' ],
667 [ 'invoice_', 'smallfooter', ],
669 push @include, [ $tc, 'coupon', ]
670 unless $params{'no_coupon'};
672 foreach my $i (@include) {
674 my($base, $include) = @$i;
676 my $inc_file = $conf->key_orbase("$base$format$include", $template);
679 if ( $conf->exists($inc_file, $agentnum)
680 && length( $conf->config($inc_file, $agentnum) ) ) {
682 @inc_src = $conf->config($inc_file, $agentnum);
686 $inc_file = $conf->key_orbase("${base}latex$include", $template);
688 my $convert_map = $convert_maps{$format}{$include};
690 @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
691 s/--\@\]/$delimiters{$format}[1]/g;
694 &$convert_map( $conf->config($inc_file, $agentnum) );
698 my $inc_tt = new Text::Template (
700 SOURCE => [ map "$_\n", @inc_src ],
701 DELIMITERS => $delimiters{$format},
702 ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
704 unless ( $inc_tt->compile() ) {
705 my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
706 warn $error. "Template:\n". join('', map "$_\n", @inc_src);
710 $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
712 $invoice_data{$include} =~ s/\n+$//
713 if ($format eq 'latex');
716 # let invoices use either of these as needed
717 $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL')
718 ? $cust_main->payinfo : '';
719 $invoice_data{'po_line'} =
720 ( $cust_main->payby eq 'BILL' && $cust_main->payinfo )
721 ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
724 my %money_chars = ( 'latex' => '',
725 'html' => $conf->config('money_char') || '$',
728 my $money_char = $money_chars{$format};
730 my %other_money_chars = ( 'latex' => '\dollar ',#XXX should be a config too
731 'html' => $conf->config('money_char') || '$',
734 my $other_money_char = $other_money_chars{$format};
735 $invoice_data{'dollar'} = $other_money_char;
737 my %minus_signs = ( 'latex' => '$-$',
739 'template' => '- ' );
740 my $minus = $minus_signs{$format};
742 my @detail_items = ();
743 my @total_items = ();
747 $invoice_data{'detail_items'} = \@detail_items;
748 $invoice_data{'total_items'} = \@total_items;
749 $invoice_data{'buf'} = \@buf;
750 $invoice_data{'sections'} = \@sections;
752 warn "$me generating sections\n"
756 my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
757 'subtotal' => $taxtotal, # adjusted below
759 my $tax_weight = _pkg_category($tax_section->{description})
760 ? _pkg_category($tax_section->{description})->weight
762 $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
763 $tax_section->{'sort_weight'} = $tax_weight;
766 my $adjust_section = {
767 'description' => $self->mt('Credits, Payments, and Adjustments'),
768 'adjust_section' => 1,
769 'subtotal' => 0, # adjusted below
771 my $adjust_weight = _pkg_category($adjust_section->{description})
772 ? _pkg_category($adjust_section->{description})->weight
774 $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
775 $adjust_section->{'sort_weight'} = $adjust_weight;
777 my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
778 my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
779 $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
780 $invoice_data{'multisection'} = $multisection;
782 my $extra_sections = [];
783 my $extra_lines = ();
785 # default section ('Charges')
786 my $default_section = { 'description' => '',
791 # Previous Charges section
792 # subtotal is the first return value from $self->previous
793 my $previous_section;
794 # if the invoice has major sections, or if we're summarizing previous
795 # charges with a single line, or if we've been specifically told to put them
796 # in a section, create a section for previous charges:
797 if ( $multisection or
798 $conf->exists('previous_balance-summary_only') or
799 $conf->exists('previous_balance-section') ) {
801 $previous_section = { 'description' => $self->mt('Previous Charges'),
802 'subtotal' => $other_money_char.
803 sprintf('%.2f', $pr_total),
804 'summarized' => '', #why? $summarypage ? 'Y' : '',
806 $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '.
807 join(' / ', map { $cust_main->balance_date_range(@$_) }
808 $self->_prior_month30s
810 if $conf->exists('invoice_include_aging');
813 # otherwise put them in the main section
814 $previous_section = $default_section;
817 if ( $multisection ) {
818 ($extra_sections, $extra_lines) =
819 $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
820 if $conf->exists('usage_class_as_a_section', $cust_main->agentnum)
821 && $self->can('_items_extra_usage_sections');
823 push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
825 push @detail_items, @$extra_lines if $extra_lines;
827 # the code is written so that both methods can be used together, but
828 # we haven't yet changed the template to take advantage of that, so for
829 # now, treat them as mutually exclusive.
830 my %section_method = ( by_category => 1 );
831 if ( $conf->exists($tc.'sections_by_location') ) {
832 %section_method = ( by_location => 1 );
835 $self->_items_sections( 'summary' => $summarypage,
836 'escape' => $escape_function_nonbsp,
837 'extra_sections' => $extra_sections,
841 push @sections, @$early;
842 $late_sections = $late;
844 if ( $conf->exists('svc_phone_sections')
845 && $self->can('_items_svc_phone_sections')
848 my ($phone_sections, $phone_lines) =
849 $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
850 push @{$late_sections}, @$phone_sections;
851 push @detail_items, @$phone_lines;
853 if ( $conf->exists('voip-cust_accountcode_cdr')
854 && $cust_main->accountcode_cdr
855 && $self->can('_items_accountcode_cdr')
858 my ($accountcode_section, $accountcode_lines) =
859 $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
860 if ( scalar(@$accountcode_lines) ) {
861 push @{$late_sections}, $accountcode_section;
862 push @detail_items, @$accountcode_lines;
865 } else {# not multisection
866 # make a default section
867 push @sections, $default_section;
868 # and calculate the finance charge total, since it won't get done otherwise.
869 # and the default section total
870 # XXX possibly finance_pkgclass should not be used in this manner?
873 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
874 if ( $invoice_data{finance_section} and
875 grep { $_->section eq $invoice_data{finance_section} }
876 $cust_bill_pkg->cust_bill_pkg_display ) {
877 # I think these are always setup fees, but just to be sure...
878 push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
880 push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
883 $invoice_data{finance_amount} =
884 sprintf('%.2f', sum( @finance_charges ) || 0);
885 $default_section->{subtotal} = $other_money_char.
886 sprintf('%.2f', sum( @charges ) || 0);
889 # previous invoice balances in the Previous Charges section if there
890 # is one, otherwise in the main detail section
891 # (except if summary_only is enabled, don't show them at all)
892 if ( $self->can('_items_previous') &&
893 $self->enable_previous &&
894 ! $conf->exists('previous_balance-summary_only') ) {
896 warn "$me adding previous balances\n"
899 foreach my $line_item ( $self->_items_previous ) {
902 ref => $line_item->{'pkgnum'},
903 pkgpart => $line_item->{'pkgpart'},
905 section => $previous_section, # which might be $default_section
906 description => &$escape_function($line_item->{'description'}),
907 ext_description => [ map { &$escape_function($_) }
908 @{ $line_item->{'ext_description'} || [] }
910 amount => ( $old_latex ? '' : $money_char).
911 $line_item->{'amount'},
912 product_code => $line_item->{'pkgpart'} || 'N/A',
915 push @detail_items, $detail;
916 push @buf, [ $detail->{'description'},
917 $money_char. sprintf("%10.2f", $line_item->{'amount'}),
923 if ( @pr_cust_bill && $self->enable_previous ) {
924 push @buf, ['','-----------'];
925 push @buf, [ $self->mt('Total Previous Balance'),
926 $money_char. sprintf("%10.2f", $pr_total) ];
930 if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) {
931 warn "$me adding DID summary\n"
934 my ($didsummary,$minutes) = $self->_did_summary;
935 my $didsummary_desc = 'DID Activity Summary (since last invoice)';
937 { 'description' => $didsummary_desc,
938 'ext_description' => [ $didsummary, $minutes ],
942 foreach my $section (@sections, @$late_sections) {
944 warn "$me adding section \n". Dumper($section)
947 # begin some normalization
948 $section->{'subtotal'} = $section->{'amount'}
950 && !exists($section->{subtotal})
951 && exists($section->{amount});
953 $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
954 if ( $invoice_data{finance_section} &&
955 $section->{'description'} eq $invoice_data{finance_section} );
957 $section->{'subtotal'} = $other_money_char.
958 sprintf('%.2f', $section->{'subtotal'})
961 # continue some normalization
962 $section->{'amount'} = $section->{'subtotal'}
966 if ( $section->{'description'} ) {
967 push @buf, ( [ &$escape_function($section->{'description'}), '' ],
972 warn "$me setting options\n"
976 $options{'section'} = $section if $multisection;
977 $options{'format'} = $format;
978 $options{'escape_function'} = $escape_function;
979 $options{'no_usage'} = 1 unless $unsquelched;
980 $options{'unsquelched'} = $unsquelched;
981 $options{'summary_page'} = $summarypage;
982 $options{'skip_usage'} =
983 scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
985 warn "$me searching for line items\n"
988 foreach my $line_item ( $self->_items_pkg(%options) ) {
990 warn "$me adding line item $line_item\n"
994 ext_description => [],
996 $detail->{'ref'} = $line_item->{'pkgnum'};
997 $detail->{'pkgpart'} = $line_item->{'pkgpart'};
998 $detail->{'quantity'} = $line_item->{'quantity'};
999 $detail->{'section'} = $section;
1000 $detail->{'description'} = &$escape_function($line_item->{'description'});
1001 if ( exists $line_item->{'ext_description'} ) {
1002 @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
1004 $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
1005 $line_item->{'amount'};
1006 if ( exists $line_item->{'unit_amount'} ) {
1007 $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
1008 $line_item->{'unit_amount'};
1010 $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
1012 $detail->{'sdate'} = $line_item->{'sdate'};
1013 $detail->{'edate'} = $line_item->{'edate'};
1014 $detail->{'seconds'} = $line_item->{'seconds'};
1015 $detail->{'svc_label'} = $line_item->{'svc_label'};
1017 push @detail_items, $detail;
1018 push @buf, ( [ $detail->{'description'},
1019 $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1021 map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
1025 if ( $section->{'description'} ) {
1026 push @buf, ( ['','-----------'],
1027 [ $section->{'description'}. ' sub-total',
1028 $section->{'subtotal'} # already formatted this
1037 $invoice_data{current_less_finance} =
1038 sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
1040 # if there's anything in the Previous Charges section, prepend it to the list
1041 if ( $pr_total and $previous_section ne $default_section ) {
1042 unshift @sections, $previous_section;
1045 warn "$me adding taxes\n"
1048 my @items_tax = $self->_items_tax;
1049 foreach my $tax ( @items_tax ) {
1051 $taxtotal += $tax->{'amount'};
1053 my $description = &$escape_function( $tax->{'description'} );
1054 my $amount = sprintf( '%.2f', $tax->{'amount'} );
1056 if ( $multisection ) {
1058 my $money = $old_latex ? '' : $money_char;
1059 push @detail_items, {
1060 ext_description => [],
1063 description => $description,
1064 amount => $money. $amount,
1066 section => $tax_section,
1071 push @total_items, {
1072 'total_item' => $description,
1073 'total_amount' => $other_money_char. $amount,
1078 push @buf,[ $description,
1079 $money_char. $amount,
1086 $total->{'total_item'} = $self->mt('Sub-total');
1087 $total->{'total_amount'} =
1088 $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
1090 if ( $multisection ) {
1091 $tax_section->{'subtotal'} = $other_money_char.
1092 sprintf('%.2f', $taxtotal);
1093 $tax_section->{'pretotal'} = 'New charges sub-total '.
1094 $total->{'total_amount'};
1095 push @sections, $tax_section if $taxtotal;
1097 unshift @total_items, $total;
1100 $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
1102 push @buf,['','-----------'];
1103 push @buf,[$self->mt(
1104 (!$self->enable_previous)
1106 : 'Total New Charges'
1108 $money_char. sprintf("%10.2f",$self->charged) ];
1116 my %embolden_functions = (
1117 'latex' => sub { return '\textbf{'. shift(). '}' },
1118 'html' => sub { return '<b>'. shift(). '</b>' },
1119 'template' => sub { shift },
1121 my $embolden_function = $embolden_functions{$format};
1123 if ( $self->can('_items_total') ) { # quotations
1125 $self->_items_total(\@total_items);
1127 foreach ( @total_items ) {
1128 $_->{'total_item'} = &$embolden_function( $_->{'total_item'} );
1129 $_->{'total_amount'} = &$embolden_function( $other_money_char.
1130 $_->{'total_amount'}
1134 } else { #normal invoice case
1136 # calculate total, possibly including total owed on previous
1140 $item = $conf->config('previous_balance-exclude_from_total')
1141 || 'Total New Charges'
1142 if $conf->exists('previous_balance-exclude_from_total');
1143 my $amount = $self->charged;
1144 if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) {
1145 $amount += $pr_total;
1148 $total->{'total_item'} = &$embolden_function($self->mt($item));
1149 $total->{'total_amount'} =
1150 &$embolden_function( $other_money_char. sprintf( '%.2f', $amount ) );
1151 if ( $multisection ) {
1152 if ( $adjust_section->{'sort_weight'} ) {
1153 $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
1154 $other_money_char. sprintf("%.2f", ($self->billing_balance || 0) );
1156 $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
1157 $other_money_char. sprintf('%.2f', $self->charged );
1160 push @total_items, $total;
1162 push @buf,['','-----------'];
1165 sprintf( '%10.2f', $amount )
1169 # if we're showing previous invoices, also show previous
1170 # credits and payments
1171 if ( $self->enable_previous
1172 and $self->can('_items_credits')
1173 and $self->can('_items_payments') )
1175 #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
1178 my $credittotal = 0;
1179 foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
1182 $total->{'total_item'} = &$escape_function($credit->{'description'});
1183 $credittotal += $credit->{'amount'};
1184 $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
1185 $adjusttotal += $credit->{'amount'};
1186 if ( $multisection ) {
1187 my $money = $old_latex ? '' : $money_char;
1188 push @detail_items, {
1189 ext_description => [],
1192 description => &$escape_function($credit->{'description'}),
1193 amount => $money. $credit->{'amount'},
1195 section => $adjust_section,
1198 push @total_items, $total;
1202 $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
1205 foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
1206 push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
1210 my $paymenttotal = 0;
1211 foreach my $payment ( $self->_items_payments ) {
1213 $total->{'total_item'} = &$escape_function($payment->{'description'});
1214 $paymenttotal += $payment->{'amount'};
1215 $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
1216 $adjusttotal += $payment->{'amount'};
1217 if ( $multisection ) {
1218 my $money = $old_latex ? '' : $money_char;
1219 push @detail_items, {
1220 ext_description => [],
1223 description => &$escape_function($payment->{'description'}),
1224 amount => $money. $payment->{'amount'},
1226 section => $adjust_section,
1229 push @total_items, $total;
1231 push @buf, [ $payment->{'description'},
1232 $money_char. sprintf("%10.2f", $payment->{'amount'}),
1235 $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
1237 if ( $multisection ) {
1238 $adjust_section->{'subtotal'} = $other_money_char.
1239 sprintf('%.2f', $adjusttotal);
1240 push @sections, $adjust_section
1241 unless $adjust_section->{sort_weight};
1244 # create Balance Due message
1247 $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1248 $total->{'total_amount'} =
1249 &$embolden_function(
1250 $other_money_char. sprintf('%.2f', #why? $summarypage
1251 # ? $self->charged +
1252 # $self->billing_balance
1254 $self->owed + $pr_total
1257 if ( $multisection && !$adjust_section->{sort_weight} ) {
1258 $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
1259 $total->{'total_amount'};
1261 push @total_items, $total;
1263 push @buf,['','-----------'];
1264 push @buf,[$self->balance_due_msg, $money_char.
1265 sprintf("%10.2f", $balance_due ) ];
1268 if ( $conf->exists('previous_balance-show_credit')
1269 and $cust_main->balance < 0 ) {
1270 my $credit_total = {
1271 'total_item' => &$embolden_function($self->credit_balance_msg),
1272 'total_amount' => &$embolden_function(
1273 $other_money_char. sprintf('%.2f', -$cust_main->balance)
1276 if ( $multisection ) {
1277 $adjust_section->{'posttotal'} .= $newline_token .
1278 $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
1281 push @total_items, $credit_total;
1283 push @buf,['','-----------'];
1284 push @buf,[$self->credit_balance_msg, $money_char.
1285 sprintf("%10.2f", -$cust_main->balance ) ];
1289 } #end of default total adding ! can('_items_total')
1291 if ( $multisection ) {
1292 if ( $conf->exists('svc_phone_sections')
1293 && $self->can('_items_svc_phone_sections')
1297 $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1298 $total->{'total_amount'} =
1299 &$embolden_function(
1300 $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
1302 my $last_section = pop @sections;
1303 $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
1304 $total->{'total_amount'};
1305 push @sections, $last_section;
1307 push @sections, @$late_sections
1311 # make a discounts-available section, even without multisection
1312 if ( $conf->exists('discount-show_available')
1313 and my @discounts_avail = $self->_items_discounts_avail ) {
1314 my $discount_section = {
1315 'description' => $self->mt('Discounts Available'),
1320 push @sections, $discount_section;
1321 push @detail_items, map { +{
1322 'ref' => '', #should this be something else?
1323 'section' => $discount_section,
1324 'description' => &$escape_function( $_->{description} ),
1325 'amount' => $money_char . &$escape_function( $_->{amount} ),
1326 'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
1327 } } @discounts_avail;
1330 my @summary_subtotals;
1331 # the templates say "$_->{tax_section} || !$_->{summarized}"
1332 # except 'summarized' is only true when tax_section is true, so this
1333 # is always true, so what's the deal?
1334 foreach my $s (@sections) {
1335 # not to include in the "summary of new charges" block:
1336 # finance charges, adjustments, previous charges,
1337 # and itemized phone usage sections
1338 if ( $s eq $adjust_section or
1339 ($s eq $previous_section and $s ne $default_section) or
1340 ($invoice_data{'finance_section'} and
1341 $invoice_data{'finance_section'} eq $s->{description}) or
1342 $s->{'description'} =~ /^\d+ $/ ) {
1345 push @summary_subtotals, $s;
1347 $invoice_data{summary_subtotals} = \@summary_subtotals;
1349 # debugging hook: call this with 'diag' => 1 to just get a hash of
1350 # the invoice variables
1351 return \%invoice_data if ( $params{'diag'} );
1353 # All sections and items are built; now fill in templates.
1354 my @includelist = ();
1355 push @includelist, 'summary' if $summarypage;
1356 foreach my $include ( @includelist ) {
1358 my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
1361 if ( length( $conf->config($inc_file, $agentnum) ) ) {
1363 @inc_src = $conf->config($inc_file, $agentnum);
1367 $inc_file = $conf->key_orbase("invoice_latex$include", $template);
1369 my $convert_map = $convert_maps{$format}{$include};
1371 @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
1372 s/--\@\]/$delimiters{$format}[1]/g;
1375 &$convert_map( $conf->config($inc_file, $agentnum) );
1379 my $inc_tt = new Text::Template (
1381 SOURCE => [ map "$_\n", @inc_src ],
1382 DELIMITERS => $delimiters{$format},
1383 ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
1385 unless ( $inc_tt->compile() ) {
1386 my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
1387 warn $error. "Template:\n". join('', map "$_\n", @inc_src);
1391 $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
1393 $invoice_data{$include} =~ s/\n+$//
1394 if ($format eq 'latex');
1399 foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
1400 /invoice_lines\((\d*)\)/;
1401 $invoice_lines += $1 || scalar(@buf);
1404 die "no invoice_lines() functions in template?"
1405 if ( $format eq 'template' && !$wasfunc );
1407 if ($format eq 'template') {
1409 if ( $invoice_lines ) {
1410 $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
1411 $invoice_data{'total_pages'}++
1412 if scalar(@buf) % $invoice_lines;
1415 #setup subroutine for the template
1416 $invoice_data{invoice_lines} = sub {
1417 my $lines = shift || scalar(@buf);
1429 push @collect, split("\n",
1430 $text_template->fill_in( HASH => \%invoice_data )
1432 $invoice_data{'page'}++;
1434 map "$_\n", @collect;
1436 } else { # this is where we actually create the invoice
1438 warn "filling in template for invoice ". $self->invnum. "\n"
1440 warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
1443 $text_template->fill_in(HASH => \%invoice_data);
1447 sub notice_name { '('.shift->table.')'; }
1449 sub template_conf { 'invoice_'; }
1451 # helper routine for generating date ranges
1452 sub _prior_month30s {
1455 [ 1, 2592000 ], # 0-30 days ago
1456 [ 2592000, 5184000 ], # 30-60 days ago
1457 [ 5184000, 7776000 ], # 60-90 days ago
1458 [ 7776000, 0 ], # 90+ days ago
1461 map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
1462 $_->[1] ? $self->_date - $_->[1] - 1 : '',
1467 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
1469 Returns an postscript invoice, as a scalar.
1471 Options can be passed as a hashref (recommended) or as a list of time, template
1472 and then any key/value pairs for any other options.
1474 I<time> an optional value used to control the printing of overdue messages. The
1475 default is now. It isn't the date of the invoice; that's the `_date' field.
1476 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1477 L<Time::Local> and L<Date::Parse> for conversion functions.
1479 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1486 my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1487 my $ps = generate_ps($file);
1489 unlink($barcodefile) if $barcodefile;
1494 =item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
1496 Returns an PDF invoice, as a scalar.
1498 Options can be passed as a hashref (recommended) or as a list of time, template
1499 and then any key/value pairs for any other options.
1501 I<time> an optional value used to control the printing of overdue messages. The
1502 default is now. It isn't the date of the invoice; that's the `_date' field.
1503 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1504 L<Time::Local> and L<Date::Parse> for conversion functions.
1506 I<template>, if specified, is the name of a suffix for alternate invoices.
1508 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1515 my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1516 my $pdf = generate_pdf($file);
1518 unlink($barcodefile) if $barcodefile;
1523 =item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
1525 Returns an HTML invoice, as a scalar.
1527 I<time> an optional value used to control the printing of overdue messages. The
1528 default is now. It isn't the date of the invoice; that's the `_date' field.
1529 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1530 L<Time::Local> and L<Date::Parse> for conversion functions.
1532 I<template>, if specified, is the name of a suffix for alternate invoices.
1534 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1536 I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
1537 when emailing the invoice as part of a multipart/related MIME email.
1545 %params = %{ shift() };
1547 $params{'time'} = shift;
1548 $params{'template'} = shift;
1549 $params{'cid'} = shift;
1552 $params{'format'} = 'html';
1554 $self->print_generic( %params );
1557 # quick subroutine for print_latex
1559 # There are ten characters that LaTeX treats as special characters, which
1560 # means that they do not simply typeset themselves:
1561 # # $ % & ~ _ ^ \ { }
1563 # TeX ignores blanks following an escaped character; if you want a blank (as
1564 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
1568 $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
1569 $value =~ s/([<>])/\$$1\$/g;
1575 encode_entities($value);
1579 sub _html_escape_nbsp {
1580 my $value = _html_escape(shift);
1581 $value =~ s/ +/ /g;
1585 #utility methods for print_*
1587 sub _translate_old_latex_format {
1588 warn "_translate_old_latex_format called\n"
1595 if ( $line =~ /^%%Detail\s*$/ ) {
1597 push @template, q![@--!,
1598 q! foreach my $_tr_line (@detail_items) {!,
1599 q! if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
1600 q! $_tr_line->{'description'} .= !,
1601 q! "\\tabularnewline\n~~".!,
1602 q! join( "\\tabularnewline\n~~",!,
1603 q! @{$_tr_line->{'ext_description'}}!,
1607 while ( ( my $line_item_line = shift )
1608 !~ /^%%EndDetail\s*$/ ) {
1609 $line_item_line =~ s/'/\\'/g; # nice LTS
1610 $line_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
1611 $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1612 push @template, " \$OUT .= '$line_item_line';";
1615 push @template, '}',
1618 } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1620 push @template, '[@--',
1621 ' foreach my $_tr_line (@total_items) {';
1623 while ( ( my $total_item_line = shift )
1624 !~ /^%%EndTotalDetails\s*$/ ) {
1625 $total_item_line =~ s/'/\\'/g; # nice LTS
1626 $total_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
1627 $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1628 push @template, " \$OUT .= '$total_item_line';";
1631 push @template, '}',
1635 $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
1636 push @template, $line;
1642 warn "$_\n" foreach @template;
1650 my $conf = $self->conf;
1652 #check for an invoice-specific override
1653 return $self->invoice_terms if $self->invoice_terms;
1655 #check for a customer- specific override
1656 my $cust_main = $self->cust_main;
1657 return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
1659 #use configured default
1660 $conf->config('invoice_default_terms') || '';
1666 if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
1667 $duedate = $self->_date() + ( $1 * 86400 );
1674 $self->due_date ? time2str(shift, $self->due_date) : '';
1677 sub balance_due_msg {
1679 my $msg = $self->mt('Balance Due');
1680 return $msg unless $self->terms;
1681 if ( $self->due_date ) {
1682 $msg .= ' - ' . $self->mt('Please pay by'). ' '.
1683 $self->due_date2str($date_format);
1684 } elsif ( $self->terms ) {
1685 $msg .= ' - '. $self->terms;
1690 sub balance_due_date {
1692 my $conf = $self->conf;
1694 if ( $conf->exists('invoice_default_terms')
1695 && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
1696 $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
1701 sub credit_balance_msg {
1703 $self->mt('Credit Balance Remaining')
1708 Returns a string with the date, for example: "3/20/2008"
1714 time2str($date_format, $self->_date);
1717 =item _items_sections OPTIONS
1719 Generate section information for all items appearing on this invoice.
1720 This will only be called for multi-section invoices.
1722 For each line item (L<FS::cust_bill_pkg> record), this will fetch all
1723 related display records (L<FS::cust_bill_pkg_display>) and organize
1724 them into two groups ("early" and "late" according to whether they come
1725 before or after the total), then into sections. A subtotal is calculated
1728 Section descriptions are returned in sort weight order. Each consists
1729 of a hash containing:
1731 description: the package category name, escaped
1732 subtotal: the total charges in that section
1733 tax_section: a flag indicating that the section contains only tax charges
1734 summarized: same as tax_section, for some reason
1735 sort_weight: the package category's sort weight
1737 If 'condense' is set on the display record, it also contains everything
1738 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
1739 coderefs to generate parts of the invoice. This is not advised.
1741 The method returns two arrayrefs, one of "early" sections and one of "late"
1744 OPTIONS may include:
1746 by_location: a flag to divide the invoice into sections by location.
1747 Each section hash will have a 'location' element containing a hashref of
1748 the location fields (see L<FS::cust_location>). The section description
1749 will be the location label, but the template can use any of the location
1750 fields to create a suitable label.
1752 by_category: a flag to divide the invoice into sections using display
1753 records (see L<FS::cust_bill_pkg_display>). This is the "traditional"
1754 behavior. Each section hash will have a 'category' element containing
1755 the section name from the display record (which probably equals the
1756 category name of the package, but may not in some cases).
1758 summary: a flag indicating that this is a summary-format invoice.
1759 Turning this on has the following effects:
1760 - Ignores display items with the 'summary' flag.
1761 - Places all sections in the "early" group even if they have post_total.
1762 - Creates sections for all non-disabled package categories, even if they
1763 have no charges on this invoice, as well as a section with no name.
1765 escape: an escape function to use for section titles.
1767 extra_sections: an arrayref of additional sections to return after the
1768 sorted list. If there are any of these, section subtotals exclude
1771 format: 'latex', 'html', or 'template' (i.e. text). Not used, but
1772 passed through to C<_condense_section()>.
1776 use vars qw(%pkg_category_cache);
1777 sub _items_sections {
1781 my $escape = $opt{escape};
1782 my @extra_sections = @{ $opt{extra_sections} || [] };
1784 # $subtotal{$locationnum}{$categoryname} = amount.
1785 # if we're not using by_location, $locationnum is undef.
1786 # if we're not using by_category, you guessed it, $categoryname is undef.
1787 # if we're not using either one, we shouldn't be here in the first place...
1789 my %late_subtotal = ();
1792 # About tax items + multisection invoices:
1793 # If either invoice_*summary option is enabled, AND there is a
1794 # package category with the name of the tax, then there will be
1795 # a display record assigning the tax item to that category.
1797 # However, the taxes are always placed in the "Taxes, Surcharges,
1798 # and Fees" section regardless of that. The only effect of the
1799 # display record is to create a subtotal for the summary page.
1802 my $pkg_hash = $self->cust_pkg_hash;
1804 foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
1807 my $usage = $cust_bill_pkg->usage;
1810 if ( $opt{by_location} ) {
1811 if ( $cust_bill_pkg->pkgnum ) {
1812 $locationnum = $pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum;
1817 $locationnum = undef;
1820 # as in _items_cust_pkg, if a line item has no display records,
1821 # cust_bill_pkg_display() returns a default record for it
1823 foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
1824 next if ( $display->summary && $opt{summary} );
1826 my $section = $display->section;
1827 my $type = $display->type;
1828 $section = undef unless $opt{by_category};
1830 $not_tax{$locationnum}{$section} = 1
1831 unless $cust_bill_pkg->pkgnum == 0;
1833 # there's actually a very important piece of logic buried in here:
1834 # incrementing $late_subtotal{$section} CREATES
1835 # $late_subtotal{$section}. keys(%late_subtotal) is later used
1836 # to define the list of late sections, and likewise keys(%subtotal).
1837 # When _items_cust_bill_pkg is called to generate line items for
1838 # real, it will be called with 'section' => $section for each
1840 if ( $display->post_total && !$opt{summary} ) {
1841 if (! $type || $type eq 'S') {
1842 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
1843 if $cust_bill_pkg->setup != 0
1844 || $cust_bill_pkg->setup_show_zero;
1848 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
1849 if $cust_bill_pkg->recur != 0
1850 || $cust_bill_pkg->recur_show_zero;
1853 if ($type && $type eq 'R') {
1854 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
1855 if $cust_bill_pkg->recur != 0
1856 || $cust_bill_pkg->recur_show_zero;
1859 if ($type && $type eq 'U') {
1860 $late_subtotal{$locationnum}{$section} += $usage
1861 unless scalar(@extra_sections);
1864 } else { # it's a pre-total (normal) section
1866 # skip tax items unless they're explicitly included in a section
1867 next if $cust_bill_pkg->pkgnum == 0 && ! $section;
1869 if (! $type || $type eq 'S') {
1870 $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
1871 if $cust_bill_pkg->setup != 0
1872 || $cust_bill_pkg->setup_show_zero;
1876 $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
1877 if $cust_bill_pkg->recur != 0
1878 || $cust_bill_pkg->recur_show_zero;
1881 if ($type && $type eq 'R') {
1882 $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
1883 if $cust_bill_pkg->recur != 0
1884 || $cust_bill_pkg->recur_show_zero;
1887 if ($type && $type eq 'U') {
1888 $subtotal{$locationnum}{$section} += $usage
1889 unless scalar(@extra_sections);
1898 %pkg_category_cache = ();
1900 # summary invoices need subtotals for all non-disabled package categories,
1901 # even if they're zero
1902 # but currently assume that there are no location sections, or at least
1903 # that the summary page doesn't care about them
1904 if ( $opt{summary} ) {
1905 foreach my $category (qsearch('pkg_category', {disabled => ''})) {
1906 $subtotal{''}{$category->categoryname} ||= 0;
1908 $subtotal{''}{''} ||= 0;
1912 foreach my $post_total (0,1) {
1914 my $s = $post_total ? \%late_subtotal : \%subtotal;
1915 foreach my $locationnum (keys %$s) {
1916 foreach my $sectionname (keys %{ $s->{$locationnum} }) {
1918 'subtotal' => $s->{$locationnum}{$sectionname},
1919 'post_total' => $post_total,
1922 if ( $locationnum ) {
1923 $section->{'locationnum'} = $locationnum;
1924 my $location = FS::cust_location->by_key($locationnum);
1925 $section->{'description'} = &{ $escape }($location->location_label);
1926 # Better ideas? This will roughly group them by proximity,
1927 # which alpha sorting on any of the address fields won't.
1928 # Sorting by locationnum is meaningless.
1929 # We have to sort on _something_ or the order may change
1930 # randomly from one invoice to the next, which will confuse
1932 $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
1934 $section->{'location'} = {
1935 map { $_ => &{ $escape }($location->get($_)) }
1939 $section->{'category'} = $sectionname;
1940 $section->{'description'} = &{ $escape }($sectionname);
1941 if ( _pkg_category($_) ) {
1942 $section->{'sort_weight'} = _pkg_category($_)->weight;
1943 if ( _pkg_category($_)->condense ) {
1944 $section = { %$section, $self->_condense_section($opt{format}) };
1948 if ( !$post_total and !$not_tax{$locationnum}{$sectionname} ) {
1949 # then it's a tax-only section
1950 $section->{'summarized'} = 'Y';
1951 $section->{'tax_section'} = 'Y';
1953 push @these, $section;
1954 } # foreach $sectionname
1955 } #foreach $locationnum
1956 push @these, @extra_sections if $post_total == 0;
1957 # need an alpha sort for location sections, because postal codes can
1959 $sections[ $post_total ] = [ sort {
1960 $opt{'by_location'} ?
1961 ($a->{sort_weight} cmp $b->{sort_weight}) :
1962 ($a->{sort_weight} <=> $b->{sort_weight})
1964 } #foreach $post_total
1966 return @sections; # early, late
1969 #helper subs for above
1973 $self->{cust_pkg} ||= { map { $_->pkgnum => $_ } $self->cust_pkg };
1977 my $categoryname = shift;
1978 $pkg_category_cache{$categoryname} ||=
1979 qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
1982 my %condensed_format = (
1983 'label' => [ qw( Description Qty Amount ) ],
1985 sub { shift->{description} },
1986 sub { shift->{quantity} },
1987 sub { my($href, %opt) = @_;
1988 ($opt{dollar} || ''). $href->{amount};
1991 'align' => [ qw( l r r ) ],
1992 'span' => [ qw( 5 1 1 ) ], # unitprices?
1993 'width' => [ qw( 10.7cm 1.4cm 1.6cm ) ], # don't like this
1996 sub _condense_section {
1997 my ( $self, $format ) = ( shift, shift );
1999 map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
2000 qw( description_generator
2003 total_line_generator
2008 sub _condensed_generator_defaults {
2009 my ( $self, $format ) = ( shift, shift );
2010 return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
2019 sub _condensed_header_generator {
2020 my ( $self, $format ) = ( shift, shift );
2022 my ( $f, $prefix, $suffix, $separator, $column ) =
2023 _condensed_generator_defaults($format);
2025 if ($format eq 'latex') {
2026 $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
2027 $suffix = "\\\\\n\\hline";
2030 sub { my ($d,$a,$s,$w) = @_;
2031 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2033 } elsif ( $format eq 'html' ) {
2034 $prefix = '<th></th>';
2038 sub { my ($d,$a,$s,$w) = @_;
2039 return qq!<th align="$html_align{$a}">$d</th>!;
2047 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2049 &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
2052 $prefix. join($separator, @result). $suffix;
2057 sub _condensed_description_generator {
2058 my ( $self, $format ) = ( shift, shift );
2060 my ( $f, $prefix, $suffix, $separator, $column ) =
2061 _condensed_generator_defaults($format);
2063 my $money_char = '$';
2064 if ($format eq 'latex') {
2065 $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
2067 $separator = " & \n";
2069 sub { my ($d,$a,$s,$w) = @_;
2070 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2072 $money_char = '\\dollar';
2073 }elsif ( $format eq 'html' ) {
2074 $prefix = '"><td align="center"></td>';
2078 sub { my ($d,$a,$s,$w) = @_;
2079 return qq!<td align="$html_align{$a}">$d</td>!;
2081 #$money_char = $conf->config('money_char') || '$';
2082 $money_char = ''; # this is madness
2090 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2092 $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
2094 &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
2095 map { $f->{$_}->[$i] } qw(align span width)
2099 $prefix. join( $separator, @result ). $suffix;
2104 sub _condensed_total_generator {
2105 my ( $self, $format ) = ( shift, shift );
2107 my ( $f, $prefix, $suffix, $separator, $column ) =
2108 _condensed_generator_defaults($format);
2111 if ($format eq 'latex') {
2114 $separator = " & \n";
2116 sub { my ($d,$a,$s,$w) = @_;
2117 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2119 }elsif ( $format eq 'html' ) {
2123 $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2125 sub { my ($d,$a,$s,$w) = @_;
2126 return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2135 # my $r = &{$f->{fields}->[$i]}(@args);
2136 # $r .= ' Total' unless $i;
2138 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2140 &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
2141 map { $f->{$_}->[$i] } qw(align span width)
2145 $prefix. join( $separator, @result ). $suffix;
2150 =item total_line_generator FORMAT
2152 Returns a coderef used for generation of invoice total line items for this
2153 usage_class. FORMAT is either html or latex
2157 # should not be used: will have issues with hash element names (description vs
2158 # total_item and amount vs total_amount -- another array of functions?
2160 sub _condensed_total_line_generator {
2161 my ( $self, $format ) = ( shift, shift );
2163 my ( $f, $prefix, $suffix, $separator, $column ) =
2164 _condensed_generator_defaults($format);
2167 if ($format eq 'latex') {
2170 $separator = " & \n";
2172 sub { my ($d,$a,$s,$w) = @_;
2173 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2175 }elsif ( $format eq 'html' ) {
2179 $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2181 sub { my ($d,$a,$s,$w) = @_;
2182 return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2191 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2193 &{$column}( &{$f->{fields}->[$i]}(@args),
2194 map { $f->{$_}->[$i] } qw(align span width)
2198 $prefix. join( $separator, @result ). $suffix;
2203 =item _items_pkg [ OPTIONS ]
2205 Return line item hashes for each package item on this invoice. Nearly
2208 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
2210 The only OPTIONS accepted is 'section', which may point to a hashref
2211 with a key named 'condensed', which may have a true value. If it
2212 does, this method tries to merge identical items into items with
2213 'quantity' equal to the number of items (not the sum of their
2214 separate quantities, for some reason).
2220 grep { $_->pkgnum } $self->cust_bill_pkg;
2227 warn "$me _items_pkg searching for all package line items\n"
2230 my @cust_bill_pkg = $self->_items_nontax;
2232 warn "$me _items_pkg filtering line items\n"
2234 my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2236 if ($options{section} && $options{section}->{condensed}) {
2238 warn "$me _items_pkg condensing section\n"
2242 local $Storable::canonical = 1;
2243 foreach ( @items ) {
2245 delete $item->{ref};
2246 delete $item->{ext_description};
2247 my $key = freeze($item);
2248 $itemshash{$key} ||= 0;
2249 $itemshash{$key} ++; # += $item->{quantity};
2251 @items = sort { $a->{description} cmp $b->{description} }
2252 map { my $i = thaw($_);
2253 $i->{quantity} = $itemshash{$_};
2255 sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
2261 warn "$me _items_pkg returning ". scalar(@items). " items\n"
2268 return 0 unless $a->itemdesc cmp $b->itemdesc;
2269 return -1 if $b->itemdesc eq 'Tax';
2270 return 1 if $a->itemdesc eq 'Tax';
2271 return -1 if $b->itemdesc eq 'Other surcharges';
2272 return 1 if $a->itemdesc eq 'Other surcharges';
2273 $a->itemdesc cmp $b->itemdesc;
2278 my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
2279 my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2281 if ( $self->conf->exists('always_show_tax') ) {
2282 my $itemdesc = $self->conf->config('always_show_tax') || 'Tax';
2283 if (0 == grep { $_->{description} eq $itemdesc } @items) {
2285 { 'description' => $itemdesc,
2292 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
2294 Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
2295 list of hashrefs describing the line items they generate on the invoice.
2297 OPTIONS may include:
2299 format: the invoice format.
2301 escape_function: the function used to escape strings.
2303 DEPRECATED? (expensive, mostly unused?)
2304 format_function: the function used to format CDRs.
2306 section: a hashref containing 'category' and/or 'locationnum'; if this
2307 is present, only returns line items that belong to that category and/or
2308 location (whichever is defined).
2310 multisection: a flag indicating that this is a multisection invoice,
2311 which does something complicated.
2313 Returns a list of hashrefs, each of which may contain:
2315 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and
2316 ext_description, which is an arrayref of detail lines to show below
2321 sub _items_cust_bill_pkg {
2323 my $conf = $self->conf;
2324 my $cust_bill_pkgs = shift;
2327 my $format = $opt{format} || '';
2328 my $escape_function = $opt{escape_function} || sub { shift };
2329 my $format_function = $opt{format_function} || '';
2330 my $no_usage = $opt{no_usage} || '';
2331 my $unsquelched = $opt{unsquelched} || ''; #unused
2332 my ($section, $locationnum, $category);
2333 if ( $opt{section} ) {
2334 $category = $opt{section}->{category};
2335 $locationnum = $opt{section}->{locationnum};
2337 my $summary_page = $opt{summary_page} || ''; #unused
2338 my $multisection = defined($category) || defined($locationnum);
2339 my $discount_show_always = 0;
2341 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2343 my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
2344 # and location labels
2347 my ($s, $r, $u) = ( undef, undef, undef );
2348 foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
2351 foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2352 if ( $_ && !$cust_bill_pkg->hidden ) {
2353 $_->{amount} = sprintf( "%.2f", $_->{amount} ),
2354 $_->{amount} =~ s/^\-0\.00$/0.00/;
2355 $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2357 if $_->{amount} != 0
2358 || $discount_show_always
2359 || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2360 || ( $_->{_is_setup} && $_->{setup_show_zero} )
2366 if ( $locationnum ) {
2367 # this is a location section; skip packages that aren't at this
2369 next if $cust_bill_pkg->pkgnum == 0;
2370 next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum
2374 # Consider display records for this item to determine if it belongs
2375 # in this section. Note that if there are no display records, there
2376 # will be a default pseudo-record that includes all charge types
2377 # and has no section name.
2378 my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
2379 ? $cust_bill_pkg->cust_bill_pkg_display
2380 : ( $cust_bill_pkg );
2382 warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
2383 $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
2386 if ( defined($category) ) {
2387 # then this is a package category section; process all display records
2388 # that belong to this section.
2389 @cust_bill_pkg_display = grep { $_->section eq $category }
2390 @cust_bill_pkg_display;
2392 # otherwise, process all display records that aren't usage summaries
2393 # (I don't think there should be usage summaries if you aren't using
2394 # category sections, but this is the historical behavior)
2395 @cust_bill_pkg_display = grep { !$_->summary }
2396 @cust_bill_pkg_display;
2398 foreach my $display (@cust_bill_pkg_display) {
2400 warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
2401 $display->billpkgdisplaynum. "\n"
2404 my $type = $display->type;
2406 my $desc = $cust_bill_pkg->desc( $cust_main ? $cust_main->locale : '' );
2407 $desc = substr($desc, 0, $maxlength). '...'
2408 if $format eq 'latex' && length($desc) > $maxlength;
2410 my %details_opt = ( 'format' => $format,
2411 'escape_function' => $escape_function,
2412 'format_function' => $format_function,
2413 'no_usage' => $opt{'no_usage'},
2416 if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
2418 warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
2421 if ( $cust_bill_pkg->setup != 0 ) {
2422 my $description = $desc;
2423 $description .= ' Setup'
2424 if $cust_bill_pkg->recur != 0
2425 || $discount_show_always
2426 || $cust_bill_pkg->recur_show_zero;
2428 'description' => $description,
2429 'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
2432 if ( $cust_bill_pkg->recur != 0 ) {
2434 'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
2435 'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
2439 } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
2441 warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
2444 my $cust_pkg = $cust_bill_pkg->cust_pkg;
2446 # which pkgpart to show for display purposes?
2447 my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
2449 # start/end dates for invoice formats that do nonstandard
2451 my %item_dates = ();
2452 %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
2453 unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
2455 if ( (!$type || $type eq 'S')
2456 && ( $cust_bill_pkg->setup != 0
2457 || $cust_bill_pkg->setup_show_zero
2462 warn "$me _items_cust_bill_pkg adding setup\n"
2465 my $description = $desc;
2466 $description .= ' Setup'
2467 if $cust_bill_pkg->recur != 0
2468 || $discount_show_always
2469 || $cust_bill_pkg->recur_show_zero;
2473 unless ( $cust_pkg->part_pkg->hide_svc_detail
2474 || $cust_bill_pkg->hidden )
2477 my @svc_labels = map &{$escape_function}($_),
2478 $cust_pkg->h_labels_short($self->_date, undef, 'I');
2479 push @d, @svc_labels
2480 unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2481 $svc_label = $svc_labels[0];
2483 my $lnum = $cust_main ? $cust_main->ship_locationnum
2484 : $self->prospect_main->locationnum;
2485 # show the location label if it's not the customer's default
2486 # location, and we're not grouping items by location already
2487 if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
2488 my $loc = $cust_pkg->location_label;
2489 $loc = substr($loc, 0, $maxlength). '...'
2490 if $format eq 'latex' && length($loc) > $maxlength;
2491 push @d, &{$escape_function}($loc);
2494 } #unless hiding service details
2496 push @d, $cust_bill_pkg->details(%details_opt)
2497 if $cust_bill_pkg->recur == 0;
2499 if ( $cust_bill_pkg->hidden ) {
2500 $s->{amount} += $cust_bill_pkg->setup;
2501 $s->{unit_amount} += $cust_bill_pkg->unitsetup;
2502 push @{ $s->{ext_description} }, @d;
2506 description => $description,
2507 pkgpart => $pkgpart,
2508 pkgnum => $cust_bill_pkg->pkgnum,
2509 amount => $cust_bill_pkg->setup,
2510 setup_show_zero => $cust_bill_pkg->setup_show_zero,
2511 unit_amount => $cust_bill_pkg->unitsetup,
2512 quantity => $cust_bill_pkg->quantity,
2513 ext_description => \@d,
2514 svc_label => ($svc_label || ''),
2520 if ( ( !$type || $type eq 'R' || $type eq 'U' )
2522 $cust_bill_pkg->recur != 0
2523 || $cust_bill_pkg->setup == 0
2524 || $discount_show_always
2525 || $cust_bill_pkg->recur_show_zero
2530 warn "$me _items_cust_bill_pkg adding recur/usage\n"
2533 my $is_summary = $display->summary;
2534 my $description = $desc;
2535 if ( $type eq 'U' and ($is_summary or $cust_bill_pkg->hidden) ) {
2536 $description = $self->mt('Usage charges');
2539 my $part_pkg = $cust_pkg->part_pkg;
2541 #pry be a bit more efficient to look some of this conf stuff up
2544 $conf->exists('disable_line_item_date_ranges')
2545 || $part_pkg->option('disable_line_item_date_ranges',1)
2546 || ! $cust_bill_pkg->sdate
2547 || ! $cust_bill_pkg->edate
2550 my $date_style = '';
2551 $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monhtly',
2554 if $part_pkg && $part_pkg->freq !~ /^1m?$/;
2555 $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
2558 if ( defined($date_style) && $date_style eq 'month_of' ) {
2559 $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
2560 } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
2561 my $desc = $conf->config( 'cust_bill-line_item-date_description',
2564 $desc .= ' ' unless $desc =~ /\s$/;
2565 $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
2567 $time_period = time2str($date_format, $cust_bill_pkg->sdate).
2568 " - ". time2str($date_format, $cust_bill_pkg->edate);
2570 $description .= " ($time_period)";
2574 my @seconds = (); # for display of usage info
2577 #at least until cust_bill_pkg has "past" ranges in addition to
2578 #the "future" sdate/edate ones... see #3032
2579 my @dates = ( $self->_date );
2580 my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
2581 push @dates, $prev->sdate if $prev;
2582 push @dates, undef if !$prev;
2584 unless ( $part_pkg->hide_svc_detail
2585 || $cust_bill_pkg->itemdesc
2586 || $cust_bill_pkg->hidden
2587 || $is_summary && $type && $type eq 'U'
2591 warn "$me _items_cust_bill_pkg adding service details\n"
2594 my @svc_labels = map &{$escape_function}($_),
2595 $cust_pkg->h_labels_short(@dates, 'I');
2596 push @d, @svc_labels
2597 unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2598 $svc_label = $svc_labels[0];
2600 warn "$me _items_cust_bill_pkg done adding service details\n"
2603 my $lnum = $cust_main ? $cust_main->ship_locationnum
2604 : $self->prospect_main->locationnum;
2605 # show the location label if it's not the customer's default
2606 # location, and we're not grouping items by location already
2607 if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
2608 my $loc = $cust_pkg->location_label;
2609 $loc = substr($loc, 0, $maxlength). '...'
2610 if $format eq 'latex' && length($loc) > $maxlength;
2611 push @d, &{$escape_function}($loc);
2614 # Display of seconds_since_sqlradacct:
2615 # On the invoice, when processing @detail_items, look for a field
2616 # named 'seconds'. This will contain total seconds for each
2617 # service, in the same order as @ext_description. For services
2618 # that don't support this it will show undef.
2619 if ( $conf->exists('svc_acct-usage_seconds')
2620 and ! $cust_bill_pkg->pkgpart_override ) {
2621 foreach my $cust_svc (
2622 $cust_pkg->h_cust_svc(@dates, 'I')
2625 # eval because not having any part_export_usage exports
2626 # is a fatal error, last_bill/_date because that's how
2627 # sqlradius_hour billing does it
2629 $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
2631 push @seconds, $sec;
2633 } #if svc_acct-usage_seconds
2637 unless ( $is_summary ) {
2638 warn "$me _items_cust_bill_pkg adding details\n"
2641 #instead of omitting details entirely in this case (unwanted side
2642 # effects), just omit CDRs
2643 $details_opt{'no_usage'} = 1
2644 if $type && $type eq 'R';
2646 push @d, $cust_bill_pkg->details(%details_opt);
2649 warn "$me _items_cust_bill_pkg calculating amount\n"
2654 $amount = $cust_bill_pkg->recur;
2655 } elsif ($type eq 'R') {
2656 $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
2657 } elsif ($type eq 'U') {
2658 $amount = $cust_bill_pkg->usage;
2662 ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
2665 if ( !$type || $type eq 'R' ) {
2667 warn "$me _items_cust_bill_pkg adding recur\n"
2670 if ( $cust_bill_pkg->hidden ) {
2671 $r->{amount} += $amount;
2672 $r->{unit_amount} += $unit_amount;
2673 push @{ $r->{ext_description} }, @d;
2676 description => $description,
2677 pkgpart => $pkgpart,
2678 pkgnum => $cust_bill_pkg->pkgnum,
2680 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2681 unit_amount => $unit_amount,
2682 quantity => $cust_bill_pkg->quantity,
2684 ext_description => \@d,
2685 svc_label => ($svc_label || ''),
2687 $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
2690 } else { # $type eq 'U'
2692 warn "$me _items_cust_bill_pkg adding usage\n"
2695 if ( $cust_bill_pkg->hidden and defined($u) ) {
2696 # if this is a hidden package and there's already a usage
2697 # line for the bundle, add this package's total amount and
2698 # usage details to it
2699 $u->{amount} += $amount;
2700 $u->{unit_amount} += $unit_amount,
2701 push @{ $u->{ext_description} }, @d;
2702 } elsif ( $amount ) {
2703 # create a new usage line
2705 description => $description,
2706 pkgpart => $pkgpart,
2707 pkgnum => $cust_bill_pkg->pkgnum,
2709 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2710 unit_amount => $unit_amount,
2711 quantity => $cust_bill_pkg->quantity,
2713 ext_description => \@d,
2715 } # else this has no usage, so don't create a usage section
2718 } # recurring or usage with recurring charge
2720 } else { #pkgnum tax or one-shot line item (??)
2722 warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
2725 if ( $cust_bill_pkg->setup != 0 ) {
2727 'description' => $desc,
2728 'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
2731 if ( $cust_bill_pkg->recur != 0 ) {
2733 'description' => "$desc (".
2734 time2str($date_format, $cust_bill_pkg->sdate). ' - '.
2735 time2str($date_format, $cust_bill_pkg->edate). ')',
2736 'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
2744 $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
2745 && $conf->exists('discount-show-always'));
2749 foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2751 $_->{amount} = sprintf( "%.2f", $_->{amount} ),
2752 $_->{amount} =~ s/^\-0\.00$/0.00/;
2753 $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2755 if $_->{amount} != 0
2756 || $discount_show_always
2757 || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2758 || ( $_->{_is_setup} && $_->{setup_show_zero} )
2762 warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
2769 =item _items_discounts_avail
2771 Returns an array of line item hashrefs representing available term discounts
2772 for this invoice. This makes the same assumptions that apply to term
2773 discounts in general: that the package is billed monthly, at a flat rate,
2774 with no usage charges. A prorated first month will be handled, as will
2775 a setup fee if the discount is allowed to apply to setup fees.
2779 sub _items_discounts_avail {
2782 #maybe move this method from cust_bill when quotations support discount_plans
2783 return () unless $self->can('discount_plans');
2784 my %plans = $self->discount_plans;
2786 my $list_pkgnums = 0; # if any packages are not eligible for all discounts
2787 $list_pkgnums = grep { $_->list_pkgnums } values %plans;
2791 my $plan = $plans{$months};
2793 my $term_total = sprintf('%.2f', $plan->discounted_total);
2794 my $percent = sprintf('%.0f',
2795 100 * (1 - $term_total / $plan->base_total) );
2796 my $permonth = sprintf('%.2f', $term_total / $months);
2797 my $detail = $self->mt('discount on item'). ' '.
2798 join(', ', map { "#$_" } $plan->pkgnums)
2801 # discounts for non-integer months don't work anyway
2802 $months = sprintf("%d", $months);
2805 description => $self->mt('Save [_1]% by paying for [_2] months',
2807 amount => $self->mt('[_1] ([_2] per month)',
2808 $term_total, $money_char.$permonth),
2809 ext_description => ($detail || ''),
2812 sort { $b <=> $a } keys %plans;