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
760 my $tax_weight = _pkg_category($tax_section->{description})
761 ? _pkg_category($tax_section->{description})->weight
763 $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
764 $tax_section->{'sort_weight'} = $tax_weight;
767 my $adjust_section = {
768 'description' => $self->mt('Credits, Payments, and Adjustments'),
769 'adjust_section' => 1,
770 'subtotal' => 0, # adjusted below
772 my $adjust_weight = _pkg_category($adjust_section->{description})
773 ? _pkg_category($adjust_section->{description})->weight
775 $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
776 $adjust_section->{'sort_weight'} = $adjust_weight;
778 my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
779 my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
780 $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
781 $invoice_data{'multisection'} = $multisection;
783 my $extra_sections = [];
784 my $extra_lines = ();
786 # default section ('Charges')
787 my $default_section = { 'description' => '',
792 # Previous Charges section
793 # subtotal is the first return value from $self->previous
794 my $previous_section;
795 # if the invoice has major sections, or if we're summarizing previous
796 # charges with a single line, or if we've been specifically told to put them
797 # in a section, create a section for previous charges:
798 if ( $multisection or
799 $conf->exists('previous_balance-summary_only') or
800 $conf->exists('previous_balance-section') ) {
802 $previous_section = { 'description' => $self->mt('Previous Charges'),
803 'subtotal' => $other_money_char.
804 sprintf('%.2f', $pr_total),
805 'summarized' => '', #why? $summarypage ? 'Y' : '',
807 $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '.
808 join(' / ', map { $cust_main->balance_date_range(@$_) }
809 $self->_prior_month30s
811 if $conf->exists('invoice_include_aging');
814 # otherwise put them in the main section
815 $previous_section = $default_section;
818 if ( $multisection ) {
819 ($extra_sections, $extra_lines) =
820 $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
821 if $conf->exists('usage_class_as_a_section', $cust_main->agentnum)
822 && $self->can('_items_extra_usage_sections');
824 push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
826 push @detail_items, @$extra_lines if $extra_lines;
828 # the code is written so that both methods can be used together, but
829 # we haven't yet changed the template to take advantage of that, so for
830 # now, treat them as mutually exclusive.
831 my %section_method = ( by_category => 1 );
832 if ( $conf->exists($tc.'sections_by_location') ) {
833 %section_method = ( by_location => 1 );
836 $self->_items_sections( 'summary' => $summarypage,
837 'escape' => $escape_function_nonbsp,
838 'extra_sections' => $extra_sections,
842 push @sections, @$early;
843 $late_sections = $late;
845 if ( $conf->exists('svc_phone_sections')
846 && $self->can('_items_svc_phone_sections')
849 my ($phone_sections, $phone_lines) =
850 $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
851 push @{$late_sections}, @$phone_sections;
852 push @detail_items, @$phone_lines;
854 if ( $conf->exists('voip-cust_accountcode_cdr')
855 && $cust_main->accountcode_cdr
856 && $self->can('_items_accountcode_cdr')
859 my ($accountcode_section, $accountcode_lines) =
860 $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
861 if ( scalar(@$accountcode_lines) ) {
862 push @{$late_sections}, $accountcode_section;
863 push @detail_items, @$accountcode_lines;
866 } else {# not multisection
867 # make a default section
868 push @sections, $default_section;
869 # and calculate the finance charge total, since it won't get done otherwise.
870 # and the default section total
871 # XXX possibly finance_pkgclass should not be used in this manner?
874 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
875 if ( $invoice_data{finance_section} and
876 grep { $_->section eq $invoice_data{finance_section} }
877 $cust_bill_pkg->cust_bill_pkg_display ) {
878 # I think these are always setup fees, but just to be sure...
879 push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
881 push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
884 $invoice_data{finance_amount} =
885 sprintf('%.2f', sum( @finance_charges ) || 0);
886 $default_section->{subtotal} = $other_money_char.
887 sprintf('%.2f', sum( @charges ) || 0);
890 # previous invoice balances in the Previous Charges section if there
891 # is one, otherwise in the main detail section
892 # (except if summary_only is enabled, don't show them at all)
893 if ( $self->can('_items_previous') &&
894 $self->enable_previous &&
895 ! $conf->exists('previous_balance-summary_only') ) {
897 warn "$me adding previous balances\n"
900 foreach my $line_item ( $self->_items_previous ) {
903 ref => $line_item->{'pkgnum'},
904 pkgpart => $line_item->{'pkgpart'},
906 section => $previous_section, # which might be $default_section
907 description => &$escape_function($line_item->{'description'}),
908 ext_description => [ map { &$escape_function($_) }
909 @{ $line_item->{'ext_description'} || [] }
911 amount => ( $old_latex ? '' : $money_char).
912 $line_item->{'amount'},
913 product_code => $line_item->{'pkgpart'} || 'N/A',
916 push @detail_items, $detail;
917 push @buf, [ $detail->{'description'},
918 $money_char. sprintf("%10.2f", $line_item->{'amount'}),
924 if ( @pr_cust_bill && $self->enable_previous ) {
925 push @buf, ['','-----------'];
926 push @buf, [ $self->mt('Total Previous Balance'),
927 $money_char. sprintf("%10.2f", $pr_total) ];
931 if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) {
932 warn "$me adding DID summary\n"
935 my ($didsummary,$minutes) = $self->_did_summary;
936 my $didsummary_desc = 'DID Activity Summary (since last invoice)';
938 { 'description' => $didsummary_desc,
939 'ext_description' => [ $didsummary, $minutes ],
943 foreach my $section (@sections, @$late_sections) {
945 warn "$me adding section \n". Dumper($section)
948 # begin some normalization
949 $section->{'subtotal'} = $section->{'amount'}
951 && !exists($section->{subtotal})
952 && exists($section->{amount});
954 $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
955 if ( $invoice_data{finance_section} &&
956 $section->{'description'} eq $invoice_data{finance_section} );
958 $section->{'subtotal'} = $other_money_char.
959 sprintf('%.2f', $section->{'subtotal'})
962 # continue some normalization
963 $section->{'amount'} = $section->{'subtotal'}
967 if ( $section->{'description'} ) {
968 push @buf, ( [ &$escape_function($section->{'description'}), '' ],
973 warn "$me setting options\n"
977 $options{'section'} = $section if $multisection;
978 $options{'format'} = $format;
979 $options{'escape_function'} = $escape_function;
980 $options{'no_usage'} = 1 unless $unsquelched;
981 $options{'unsquelched'} = $unsquelched;
982 $options{'summary_page'} = $summarypage;
983 $options{'skip_usage'} =
984 scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
986 warn "$me searching for line items\n"
989 foreach my $line_item ( $self->_items_pkg(%options) ) {
991 warn "$me adding line item $line_item\n"
995 ext_description => [],
997 $detail->{'ref'} = $line_item->{'pkgnum'};
998 $detail->{'pkgpart'} = $line_item->{'pkgpart'};
999 $detail->{'quantity'} = $line_item->{'quantity'};
1000 $detail->{'section'} = $section;
1001 $detail->{'description'} = &$escape_function($line_item->{'description'});
1002 if ( exists $line_item->{'ext_description'} ) {
1003 @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
1005 $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
1006 $line_item->{'amount'};
1007 if ( exists $line_item->{'unit_amount'} ) {
1008 $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
1009 $line_item->{'unit_amount'};
1011 $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
1013 $detail->{'sdate'} = $line_item->{'sdate'};
1014 $detail->{'edate'} = $line_item->{'edate'};
1015 $detail->{'seconds'} = $line_item->{'seconds'};
1016 $detail->{'svc_label'} = $line_item->{'svc_label'};
1018 push @detail_items, $detail;
1019 push @buf, ( [ $detail->{'description'},
1020 $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1022 map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
1026 if ( $section->{'description'} ) {
1027 push @buf, ( ['','-----------'],
1028 [ $section->{'description'}. ' sub-total',
1029 $section->{'subtotal'} # already formatted this
1038 $invoice_data{current_less_finance} =
1039 sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
1041 # if there's anything in the Previous Charges section, prepend it to the list
1042 if ( $pr_total and $previous_section ne $default_section ) {
1043 unshift @sections, $previous_section;
1046 warn "$me adding taxes\n"
1049 my @items_tax = $self->_items_tax;
1050 foreach my $tax ( @items_tax ) {
1052 $taxtotal += $tax->{'amount'};
1054 my $description = &$escape_function( $tax->{'description'} );
1055 my $amount = sprintf( '%.2f', $tax->{'amount'} );
1057 if ( $multisection ) {
1059 my $money = $old_latex ? '' : $money_char;
1060 push @detail_items, {
1061 ext_description => [],
1064 description => $description,
1065 amount => $money. $amount,
1067 section => $tax_section,
1072 push @total_items, {
1073 'total_item' => $description,
1074 'total_amount' => $other_money_char. $amount,
1079 push @buf,[ $description,
1080 $money_char. $amount,
1087 $total->{'total_item'} = $self->mt('Sub-total');
1088 $total->{'total_amount'} =
1089 $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
1091 if ( $multisection ) {
1092 $tax_section->{'subtotal'} = $other_money_char.
1093 sprintf('%.2f', $taxtotal);
1094 $tax_section->{'pretotal'} = 'New charges sub-total '.
1095 $total->{'total_amount'};
1096 push @sections, $tax_section if $taxtotal;
1098 unshift @total_items, $total;
1101 $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
1103 push @buf,['','-----------'];
1104 push @buf,[$self->mt(
1105 (!$self->enable_previous)
1107 : 'Total New Charges'
1109 $money_char. sprintf("%10.2f",$self->charged) ];
1117 my %embolden_functions = (
1118 'latex' => sub { return '\textbf{'. shift(). '}' },
1119 'html' => sub { return '<b>'. shift(). '</b>' },
1120 'template' => sub { shift },
1122 my $embolden_function = $embolden_functions{$format};
1124 if ( $self->can('_items_total') ) { # quotations
1126 $self->_items_total(\@total_items);
1128 foreach ( @total_items ) {
1129 $_->{'total_item'} = &$embolden_function( $_->{'total_item'} );
1130 $_->{'total_amount'} = &$embolden_function( $other_money_char.
1131 $_->{'total_amount'}
1135 } else { #normal invoice case
1137 # calculate total, possibly including total owed on previous
1141 $item = $conf->config('previous_balance-exclude_from_total')
1142 || 'Total New Charges'
1143 if $conf->exists('previous_balance-exclude_from_total');
1144 my $amount = $self->charged;
1145 if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) {
1146 $amount += $pr_total;
1149 $total->{'total_item'} = &$embolden_function($self->mt($item));
1150 $total->{'total_amount'} =
1151 &$embolden_function( $other_money_char. sprintf( '%.2f', $amount ) );
1152 if ( $multisection ) {
1153 if ( $adjust_section->{'sort_weight'} ) {
1154 $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
1155 $other_money_char. sprintf("%.2f", ($self->billing_balance || 0) );
1157 $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
1158 $other_money_char. sprintf('%.2f', $self->charged );
1161 push @total_items, $total;
1163 push @buf,['','-----------'];
1166 sprintf( '%10.2f', $amount )
1170 # if we're showing previous invoices, also show previous
1171 # credits and payments
1172 if ( $self->enable_previous
1173 and $self->can('_items_credits')
1174 and $self->can('_items_payments') )
1176 #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
1179 my $credittotal = 0;
1180 foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
1183 $total->{'total_item'} = &$escape_function($credit->{'description'});
1184 $credittotal += $credit->{'amount'};
1185 $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
1186 $adjusttotal += $credit->{'amount'};
1187 if ( $multisection ) {
1188 my $money = $old_latex ? '' : $money_char;
1189 push @detail_items, {
1190 ext_description => [],
1193 description => &$escape_function($credit->{'description'}),
1194 amount => $money. $credit->{'amount'},
1196 section => $adjust_section,
1199 push @total_items, $total;
1203 $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
1206 foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
1207 push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
1211 my $paymenttotal = 0;
1212 foreach my $payment ( $self->_items_payments ) {
1214 $total->{'total_item'} = &$escape_function($payment->{'description'});
1215 $paymenttotal += $payment->{'amount'};
1216 $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
1217 $adjusttotal += $payment->{'amount'};
1218 if ( $multisection ) {
1219 my $money = $old_latex ? '' : $money_char;
1220 push @detail_items, {
1221 ext_description => [],
1224 description => &$escape_function($payment->{'description'}),
1225 amount => $money. $payment->{'amount'},
1227 section => $adjust_section,
1230 push @total_items, $total;
1232 push @buf, [ $payment->{'description'},
1233 $money_char. sprintf("%10.2f", $payment->{'amount'}),
1236 $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
1238 if ( $multisection ) {
1239 $adjust_section->{'subtotal'} = $other_money_char.
1240 sprintf('%.2f', $adjusttotal);
1241 push @sections, $adjust_section
1242 unless $adjust_section->{sort_weight};
1245 # create Balance Due message
1248 $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1249 $total->{'total_amount'} =
1250 &$embolden_function(
1251 $other_money_char. sprintf('%.2f', #why? $summarypage
1252 # ? $self->charged +
1253 # $self->billing_balance
1255 $self->owed + $pr_total
1258 if ( $multisection && !$adjust_section->{sort_weight} ) {
1259 $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
1260 $total->{'total_amount'};
1262 push @total_items, $total;
1264 push @buf,['','-----------'];
1265 push @buf,[$self->balance_due_msg, $money_char.
1266 sprintf("%10.2f", $balance_due ) ];
1269 if ( $conf->exists('previous_balance-show_credit')
1270 and $cust_main->balance < 0 ) {
1271 my $credit_total = {
1272 'total_item' => &$embolden_function($self->credit_balance_msg),
1273 'total_amount' => &$embolden_function(
1274 $other_money_char. sprintf('%.2f', -$cust_main->balance)
1277 if ( $multisection ) {
1278 $adjust_section->{'posttotal'} .= $newline_token .
1279 $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
1282 push @total_items, $credit_total;
1284 push @buf,['','-----------'];
1285 push @buf,[$self->credit_balance_msg, $money_char.
1286 sprintf("%10.2f", -$cust_main->balance ) ];
1290 } #end of default total adding ! can('_items_total')
1292 if ( $multisection ) {
1293 if ( $conf->exists('svc_phone_sections')
1294 && $self->can('_items_svc_phone_sections')
1298 $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1299 $total->{'total_amount'} =
1300 &$embolden_function(
1301 $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
1303 my $last_section = pop @sections;
1304 $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
1305 $total->{'total_amount'};
1306 push @sections, $last_section;
1308 push @sections, @$late_sections
1312 # make a discounts-available section, even without multisection
1313 if ( $conf->exists('discount-show_available')
1314 and my @discounts_avail = $self->_items_discounts_avail ) {
1315 my $discount_section = {
1316 'description' => $self->mt('Discounts Available'),
1321 push @sections, $discount_section;
1322 push @detail_items, map { +{
1323 'ref' => '', #should this be something else?
1324 'section' => $discount_section,
1325 'description' => &$escape_function( $_->{description} ),
1326 'amount' => $money_char . &$escape_function( $_->{amount} ),
1327 'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
1328 } } @discounts_avail;
1331 my @summary_subtotals;
1332 # the templates say "$_->{tax_section} || !$_->{summarized}"
1333 # except 'summarized' is only true when tax_section is true, so this
1334 # is always true, so what's the deal?
1335 foreach my $s (@sections) {
1336 # not to include in the "summary of new charges" block:
1337 # finance charges, adjustments, previous charges,
1338 # and itemized phone usage sections
1339 if ( $s eq $adjust_section or
1340 ($s eq $previous_section and $s ne $default_section) or
1341 ($invoice_data{'finance_section'} and
1342 $invoice_data{'finance_section'} eq $s->{description}) or
1343 $s->{'description'} =~ /^\d+ $/ ) {
1346 push @summary_subtotals, $s;
1348 $invoice_data{summary_subtotals} = \@summary_subtotals;
1350 # debugging hook: call this with 'diag' => 1 to just get a hash of
1351 # the invoice variables
1352 return \%invoice_data if ( $params{'diag'} );
1354 # All sections and items are built; now fill in templates.
1355 my @includelist = ();
1356 push @includelist, 'summary' if $summarypage;
1357 foreach my $include ( @includelist ) {
1359 my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
1362 if ( length( $conf->config($inc_file, $agentnum) ) ) {
1364 @inc_src = $conf->config($inc_file, $agentnum);
1368 $inc_file = $conf->key_orbase("invoice_latex$include", $template);
1370 my $convert_map = $convert_maps{$format}{$include};
1372 @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
1373 s/--\@\]/$delimiters{$format}[1]/g;
1376 &$convert_map( $conf->config($inc_file, $agentnum) );
1380 my $inc_tt = new Text::Template (
1382 SOURCE => [ map "$_\n", @inc_src ],
1383 DELIMITERS => $delimiters{$format},
1384 ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
1386 unless ( $inc_tt->compile() ) {
1387 my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
1388 warn $error. "Template:\n". join('', map "$_\n", @inc_src);
1392 $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
1394 $invoice_data{$include} =~ s/\n+$//
1395 if ($format eq 'latex');
1400 foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
1401 /invoice_lines\((\d*)\)/;
1402 $invoice_lines += $1 || scalar(@buf);
1405 die "no invoice_lines() functions in template?"
1406 if ( $format eq 'template' && !$wasfunc );
1408 if ($format eq 'template') {
1410 if ( $invoice_lines ) {
1411 $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
1412 $invoice_data{'total_pages'}++
1413 if scalar(@buf) % $invoice_lines;
1416 #setup subroutine for the template
1417 $invoice_data{invoice_lines} = sub {
1418 my $lines = shift || scalar(@buf);
1430 push @collect, split("\n",
1431 $text_template->fill_in( HASH => \%invoice_data )
1433 $invoice_data{'page'}++;
1435 map "$_\n", @collect;
1437 } else { # this is where we actually create the invoice
1439 warn "filling in template for invoice ". $self->invnum. "\n"
1441 warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
1444 $text_template->fill_in(HASH => \%invoice_data);
1448 sub notice_name { '('.shift->table.')'; }
1450 sub template_conf { 'invoice_'; }
1452 # helper routine for generating date ranges
1453 sub _prior_month30s {
1456 [ 1, 2592000 ], # 0-30 days ago
1457 [ 2592000, 5184000 ], # 30-60 days ago
1458 [ 5184000, 7776000 ], # 60-90 days ago
1459 [ 7776000, 0 ], # 90+ days ago
1462 map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
1463 $_->[1] ? $self->_date - $_->[1] - 1 : '',
1468 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
1470 Returns an postscript invoice, as a scalar.
1472 Options can be passed as a hashref (recommended) or as a list of time, template
1473 and then any key/value pairs for any other options.
1475 I<time> an optional value used to control the printing of overdue messages. The
1476 default is now. It isn't the date of the invoice; that's the `_date' field.
1477 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1478 L<Time::Local> and L<Date::Parse> for conversion functions.
1480 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1487 my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1488 my $ps = generate_ps($file);
1490 unlink($barcodefile) if $barcodefile;
1495 =item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
1497 Returns an PDF invoice, as a scalar.
1499 Options can be passed as a hashref (recommended) or as a list of time, template
1500 and then any key/value pairs for any other options.
1502 I<time> an optional value used to control the printing of overdue messages. The
1503 default is now. It isn't the date of the invoice; that's the `_date' field.
1504 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1505 L<Time::Local> and L<Date::Parse> for conversion functions.
1507 I<template>, if specified, is the name of a suffix for alternate invoices.
1509 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1516 my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1517 my $pdf = generate_pdf($file);
1519 unlink($barcodefile) if $barcodefile;
1524 =item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
1526 Returns an HTML invoice, as a scalar.
1528 I<time> an optional value used to control the printing of overdue messages. The
1529 default is now. It isn't the date of the invoice; that's the `_date' field.
1530 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1531 L<Time::Local> and L<Date::Parse> for conversion functions.
1533 I<template>, if specified, is the name of a suffix for alternate invoices.
1535 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1537 I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
1538 when emailing the invoice as part of a multipart/related MIME email.
1546 %params = %{ shift() };
1548 $params{'time'} = shift;
1549 $params{'template'} = shift;
1550 $params{'cid'} = shift;
1553 $params{'format'} = 'html';
1555 $self->print_generic( %params );
1558 # quick subroutine for print_latex
1560 # There are ten characters that LaTeX treats as special characters, which
1561 # means that they do not simply typeset themselves:
1562 # # $ % & ~ _ ^ \ { }
1564 # TeX ignores blanks following an escaped character; if you want a blank (as
1565 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
1569 $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
1570 $value =~ s/([<>])/\$$1\$/g;
1576 encode_entities($value);
1580 sub _html_escape_nbsp {
1581 my $value = _html_escape(shift);
1582 $value =~ s/ +/ /g;
1586 #utility methods for print_*
1588 sub _translate_old_latex_format {
1589 warn "_translate_old_latex_format called\n"
1596 if ( $line =~ /^%%Detail\s*$/ ) {
1598 push @template, q![@--!,
1599 q! foreach my $_tr_line (@detail_items) {!,
1600 q! if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
1601 q! $_tr_line->{'description'} .= !,
1602 q! "\\tabularnewline\n~~".!,
1603 q! join( "\\tabularnewline\n~~",!,
1604 q! @{$_tr_line->{'ext_description'}}!,
1608 while ( ( my $line_item_line = shift )
1609 !~ /^%%EndDetail\s*$/ ) {
1610 $line_item_line =~ s/'/\\'/g; # nice LTS
1611 $line_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
1612 $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1613 push @template, " \$OUT .= '$line_item_line';";
1616 push @template, '}',
1619 } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1621 push @template, '[@--',
1622 ' foreach my $_tr_line (@total_items) {';
1624 while ( ( my $total_item_line = shift )
1625 !~ /^%%EndTotalDetails\s*$/ ) {
1626 $total_item_line =~ s/'/\\'/g; # nice LTS
1627 $total_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
1628 $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1629 push @template, " \$OUT .= '$total_item_line';";
1632 push @template, '}',
1636 $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
1637 push @template, $line;
1643 warn "$_\n" foreach @template;
1651 my $conf = $self->conf;
1653 #check for an invoice-specific override
1654 return $self->invoice_terms if $self->invoice_terms;
1656 #check for a customer- specific override
1657 my $cust_main = $self->cust_main;
1658 return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
1660 #use configured default
1661 $conf->config('invoice_default_terms') || '';
1667 if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
1668 $duedate = $self->_date() + ( $1 * 86400 );
1675 $self->due_date ? time2str(shift, $self->due_date) : '';
1678 sub balance_due_msg {
1680 my $msg = $self->mt('Balance Due');
1681 return $msg unless $self->terms;
1682 if ( $self->due_date ) {
1683 $msg .= ' - ' . $self->mt('Please pay by'). ' '.
1684 $self->due_date2str($date_format);
1685 } elsif ( $self->terms ) {
1686 $msg .= ' - '. $self->terms;
1691 sub balance_due_date {
1693 my $conf = $self->conf;
1695 if ( $conf->exists('invoice_default_terms')
1696 && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
1697 $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
1702 sub credit_balance_msg {
1704 $self->mt('Credit Balance Remaining')
1709 Returns a string with the date, for example: "3/20/2008"
1715 time2str($date_format, $self->_date);
1718 =item _items_sections OPTIONS
1720 Generate section information for all items appearing on this invoice.
1721 This will only be called for multi-section invoices.
1723 For each line item (L<FS::cust_bill_pkg> record), this will fetch all
1724 related display records (L<FS::cust_bill_pkg_display>) and organize
1725 them into two groups ("early" and "late" according to whether they come
1726 before or after the total), then into sections. A subtotal is calculated
1729 Section descriptions are returned in sort weight order. Each consists
1730 of a hash containing:
1732 description: the package category name, escaped
1733 subtotal: the total charges in that section
1734 tax_section: a flag indicating that the section contains only tax charges
1735 summarized: same as tax_section, for some reason
1736 sort_weight: the package category's sort weight
1738 If 'condense' is set on the display record, it also contains everything
1739 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
1740 coderefs to generate parts of the invoice. This is not advised.
1742 The method returns two arrayrefs, one of "early" sections and one of "late"
1745 OPTIONS may include:
1747 by_location: a flag to divide the invoice into sections by location.
1748 Each section hash will have a 'location' element containing a hashref of
1749 the location fields (see L<FS::cust_location>). The section description
1750 will be the location label, but the template can use any of the location
1751 fields to create a suitable label.
1753 by_category: a flag to divide the invoice into sections using display
1754 records (see L<FS::cust_bill_pkg_display>). This is the "traditional"
1755 behavior. Each section hash will have a 'category' element containing
1756 the section name from the display record (which probably equals the
1757 category name of the package, but may not in some cases).
1759 summary: a flag indicating that this is a summary-format invoice.
1760 Turning this on has the following effects:
1761 - Ignores display items with the 'summary' flag.
1762 - Places all sections in the "early" group even if they have post_total.
1763 - Creates sections for all non-disabled package categories, even if they
1764 have no charges on this invoice, as well as a section with no name.
1766 escape: an escape function to use for section titles.
1768 extra_sections: an arrayref of additional sections to return after the
1769 sorted list. If there are any of these, section subtotals exclude
1772 format: 'latex', 'html', or 'template' (i.e. text). Not used, but
1773 passed through to C<_condense_section()>.
1777 use vars qw(%pkg_category_cache);
1778 sub _items_sections {
1782 my $escape = $opt{escape};
1783 my @extra_sections = @{ $opt{extra_sections} || [] };
1785 # $subtotal{$locationnum}{$categoryname} = amount.
1786 # if we're not using by_location, $locationnum is undef.
1787 # if we're not using by_category, you guessed it, $categoryname is undef.
1788 # if we're not using either one, we shouldn't be here in the first place...
1790 my %late_subtotal = ();
1793 # About tax items + multisection invoices:
1794 # If either invoice_*summary option is enabled, AND there is a
1795 # package category with the name of the tax, then there will be
1796 # a display record assigning the tax item to that category.
1798 # However, the taxes are always placed in the "Taxes, Surcharges,
1799 # and Fees" section regardless of that. The only effect of the
1800 # display record is to create a subtotal for the summary page.
1803 my $pkg_hash = $self->cust_pkg_hash;
1805 foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
1808 my $usage = $cust_bill_pkg->usage;
1811 if ( $opt{by_location} ) {
1812 if ( $cust_bill_pkg->pkgnum ) {
1813 $locationnum = $pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum;
1818 $locationnum = undef;
1821 # as in _items_cust_pkg, if a line item has no display records,
1822 # cust_bill_pkg_display() returns a default record for it
1824 foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
1825 next if ( $display->summary && $opt{summary} );
1827 my $section = $display->section;
1828 my $type = $display->type;
1829 $section = undef unless $opt{by_category};
1831 $not_tax{$locationnum}{$section} = 1
1832 unless $cust_bill_pkg->pkgnum == 0;
1834 # there's actually a very important piece of logic buried in here:
1835 # incrementing $late_subtotal{$section} CREATES
1836 # $late_subtotal{$section}. keys(%late_subtotal) is later used
1837 # to define the list of late sections, and likewise keys(%subtotal).
1838 # When _items_cust_bill_pkg is called to generate line items for
1839 # real, it will be called with 'section' => $section for each
1841 if ( $display->post_total && !$opt{summary} ) {
1842 if (! $type || $type eq 'S') {
1843 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
1844 if $cust_bill_pkg->setup != 0
1845 || $cust_bill_pkg->setup_show_zero;
1849 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
1850 if $cust_bill_pkg->recur != 0
1851 || $cust_bill_pkg->recur_show_zero;
1854 if ($type && $type eq 'R') {
1855 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
1856 if $cust_bill_pkg->recur != 0
1857 || $cust_bill_pkg->recur_show_zero;
1860 if ($type && $type eq 'U') {
1861 $late_subtotal{$locationnum}{$section} += $usage
1862 unless scalar(@extra_sections);
1865 } else { # it's a pre-total (normal) section
1867 # skip tax items unless they're explicitly included in a section
1868 next if $cust_bill_pkg->pkgnum == 0 && ! $section;
1870 if (! $type || $type eq 'S') {
1871 $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
1872 if $cust_bill_pkg->setup != 0
1873 || $cust_bill_pkg->setup_show_zero;
1877 $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
1878 if $cust_bill_pkg->recur != 0
1879 || $cust_bill_pkg->recur_show_zero;
1882 if ($type && $type eq 'R') {
1883 $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
1884 if $cust_bill_pkg->recur != 0
1885 || $cust_bill_pkg->recur_show_zero;
1888 if ($type && $type eq 'U') {
1889 $subtotal{$locationnum}{$section} += $usage
1890 unless scalar(@extra_sections);
1899 %pkg_category_cache = ();
1901 # summary invoices need subtotals for all non-disabled package categories,
1902 # even if they're zero
1903 # but currently assume that there are no location sections, or at least
1904 # that the summary page doesn't care about them
1905 if ( $opt{summary} ) {
1906 foreach my $category (qsearch('pkg_category', {disabled => ''})) {
1907 $subtotal{''}{$category->categoryname} ||= 0;
1909 $subtotal{''}{''} ||= 0;
1913 foreach my $post_total (0,1) {
1915 my $s = $post_total ? \%late_subtotal : \%subtotal;
1916 foreach my $locationnum (keys %$s) {
1917 foreach my $sectionname (keys %{ $s->{$locationnum} }) {
1919 'subtotal' => $s->{$locationnum}{$sectionname},
1920 'post_total' => $post_total,
1923 if ( $locationnum ) {
1924 $section->{'locationnum'} = $locationnum;
1925 my $location = FS::cust_location->by_key($locationnum);
1926 $section->{'description'} = &{ $escape }($location->location_label);
1927 # Better ideas? This will roughly group them by proximity,
1928 # which alpha sorting on any of the address fields won't.
1929 # Sorting by locationnum is meaningless.
1930 # We have to sort on _something_ or the order may change
1931 # randomly from one invoice to the next, which will confuse
1933 $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
1935 $section->{'location'} = {
1936 map { $_ => &{ $escape }($location->get($_)) }
1940 $section->{'category'} = $sectionname;
1941 $section->{'description'} = &{ $escape }($sectionname);
1942 if ( _pkg_category($_) ) {
1943 $section->{'sort_weight'} = _pkg_category($_)->weight;
1944 if ( _pkg_category($_)->condense ) {
1945 $section = { %$section, $self->_condense_section($opt{format}) };
1949 if ( !$post_total and !$not_tax{$locationnum}{$sectionname} ) {
1950 # then it's a tax-only section
1951 $section->{'summarized'} = 'Y';
1952 $section->{'tax_section'} = 'Y';
1954 push @these, $section;
1955 } # foreach $sectionname
1956 } #foreach $locationnum
1957 push @these, @extra_sections if $post_total == 0;
1958 # need an alpha sort for location sections, because postal codes can
1960 $sections[ $post_total ] = [ sort {
1961 $opt{'by_location'} ?
1962 ($a->{sort_weight} cmp $b->{sort_weight}) :
1963 ($a->{sort_weight} <=> $b->{sort_weight})
1965 } #foreach $post_total
1967 return @sections; # early, late
1970 #helper subs for above
1974 $self->{cust_pkg} ||= { map { $_->pkgnum => $_ } $self->cust_pkg };
1978 my $categoryname = shift;
1979 $pkg_category_cache{$categoryname} ||=
1980 qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
1983 my %condensed_format = (
1984 'label' => [ qw( Description Qty Amount ) ],
1986 sub { shift->{description} },
1987 sub { shift->{quantity} },
1988 sub { my($href, %opt) = @_;
1989 ($opt{dollar} || ''). $href->{amount};
1992 'align' => [ qw( l r r ) ],
1993 'span' => [ qw( 5 1 1 ) ], # unitprices?
1994 'width' => [ qw( 10.7cm 1.4cm 1.6cm ) ], # don't like this
1997 sub _condense_section {
1998 my ( $self, $format ) = ( shift, shift );
2000 map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
2001 qw( description_generator
2004 total_line_generator
2009 sub _condensed_generator_defaults {
2010 my ( $self, $format ) = ( shift, shift );
2011 return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
2020 sub _condensed_header_generator {
2021 my ( $self, $format ) = ( shift, shift );
2023 my ( $f, $prefix, $suffix, $separator, $column ) =
2024 _condensed_generator_defaults($format);
2026 if ($format eq 'latex') {
2027 $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
2028 $suffix = "\\\\\n\\hline";
2031 sub { my ($d,$a,$s,$w) = @_;
2032 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2034 } elsif ( $format eq 'html' ) {
2035 $prefix = '<th></th>';
2039 sub { my ($d,$a,$s,$w) = @_;
2040 return qq!<th align="$html_align{$a}">$d</th>!;
2048 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2050 &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
2053 $prefix. join($separator, @result). $suffix;
2058 sub _condensed_description_generator {
2059 my ( $self, $format ) = ( shift, shift );
2061 my ( $f, $prefix, $suffix, $separator, $column ) =
2062 _condensed_generator_defaults($format);
2064 my $money_char = '$';
2065 if ($format eq 'latex') {
2066 $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
2068 $separator = " & \n";
2070 sub { my ($d,$a,$s,$w) = @_;
2071 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2073 $money_char = '\\dollar';
2074 }elsif ( $format eq 'html' ) {
2075 $prefix = '"><td align="center"></td>';
2079 sub { my ($d,$a,$s,$w) = @_;
2080 return qq!<td align="$html_align{$a}">$d</td>!;
2082 #$money_char = $conf->config('money_char') || '$';
2083 $money_char = ''; # this is madness
2091 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2093 $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
2095 &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
2096 map { $f->{$_}->[$i] } qw(align span width)
2100 $prefix. join( $separator, @result ). $suffix;
2105 sub _condensed_total_generator {
2106 my ( $self, $format ) = ( shift, shift );
2108 my ( $f, $prefix, $suffix, $separator, $column ) =
2109 _condensed_generator_defaults($format);
2112 if ($format eq 'latex') {
2115 $separator = " & \n";
2117 sub { my ($d,$a,$s,$w) = @_;
2118 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2120 }elsif ( $format eq 'html' ) {
2124 $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2126 sub { my ($d,$a,$s,$w) = @_;
2127 return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2136 # my $r = &{$f->{fields}->[$i]}(@args);
2137 # $r .= ' Total' unless $i;
2139 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2141 &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
2142 map { $f->{$_}->[$i] } qw(align span width)
2146 $prefix. join( $separator, @result ). $suffix;
2151 =item total_line_generator FORMAT
2153 Returns a coderef used for generation of invoice total line items for this
2154 usage_class. FORMAT is either html or latex
2158 # should not be used: will have issues with hash element names (description vs
2159 # total_item and amount vs total_amount -- another array of functions?
2161 sub _condensed_total_line_generator {
2162 my ( $self, $format ) = ( shift, shift );
2164 my ( $f, $prefix, $suffix, $separator, $column ) =
2165 _condensed_generator_defaults($format);
2168 if ($format eq 'latex') {
2171 $separator = " & \n";
2173 sub { my ($d,$a,$s,$w) = @_;
2174 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2176 }elsif ( $format eq 'html' ) {
2180 $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2182 sub { my ($d,$a,$s,$w) = @_;
2183 return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2192 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2194 &{$column}( &{$f->{fields}->[$i]}(@args),
2195 map { $f->{$_}->[$i] } qw(align span width)
2199 $prefix. join( $separator, @result ). $suffix;
2204 =item _items_pkg [ OPTIONS ]
2206 Return line item hashes for each package item on this invoice. Nearly
2209 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
2211 The only OPTIONS accepted is 'section', which may point to a hashref
2212 with a key named 'condensed', which may have a true value. If it
2213 does, this method tries to merge identical items into items with
2214 'quantity' equal to the number of items (not the sum of their
2215 separate quantities, for some reason).
2221 grep { $_->pkgnum } $self->cust_bill_pkg;
2228 warn "$me _items_pkg searching for all package line items\n"
2231 my @cust_bill_pkg = $self->_items_nontax;
2233 warn "$me _items_pkg filtering line items\n"
2235 my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2237 if ($options{section} && $options{section}->{condensed}) {
2239 warn "$me _items_pkg condensing section\n"
2243 local $Storable::canonical = 1;
2244 foreach ( @items ) {
2246 delete $item->{ref};
2247 delete $item->{ext_description};
2248 my $key = freeze($item);
2249 $itemshash{$key} ||= 0;
2250 $itemshash{$key} ++; # += $item->{quantity};
2252 @items = sort { $a->{description} cmp $b->{description} }
2253 map { my $i = thaw($_);
2254 $i->{quantity} = $itemshash{$_};
2256 sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
2262 warn "$me _items_pkg returning ". scalar(@items). " items\n"
2269 return 0 unless $a->itemdesc cmp $b->itemdesc;
2270 return -1 if $b->itemdesc eq 'Tax';
2271 return 1 if $a->itemdesc eq 'Tax';
2272 return -1 if $b->itemdesc eq 'Other surcharges';
2273 return 1 if $a->itemdesc eq 'Other surcharges';
2274 $a->itemdesc cmp $b->itemdesc;
2279 my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
2280 my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
2282 if ( $self->conf->exists('always_show_tax') ) {
2283 my $itemdesc = $self->conf->config('always_show_tax') || 'Tax';
2284 if (0 == grep { $_->{description} eq $itemdesc } @items) {
2286 { 'description' => $itemdesc,
2293 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
2295 Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
2296 list of hashrefs describing the line items they generate on the invoice.
2298 OPTIONS may include:
2300 format: the invoice format.
2302 escape_function: the function used to escape strings.
2304 DEPRECATED? (expensive, mostly unused?)
2305 format_function: the function used to format CDRs.
2307 section: a hashref containing 'category' and/or 'locationnum'; if this
2308 is present, only returns line items that belong to that category and/or
2309 location (whichever is defined).
2311 multisection: a flag indicating that this is a multisection invoice,
2312 which does something complicated.
2314 Returns a list of hashrefs, each of which may contain:
2316 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and
2317 ext_description, which is an arrayref of detail lines to show below
2322 sub _items_cust_bill_pkg {
2324 my $conf = $self->conf;
2325 my $cust_bill_pkgs = shift;
2328 my $format = $opt{format} || '';
2329 my $escape_function = $opt{escape_function} || sub { shift };
2330 my $format_function = $opt{format_function} || '';
2331 my $no_usage = $opt{no_usage} || '';
2332 my $unsquelched = $opt{unsquelched} || ''; #unused
2333 my ($section, $locationnum, $category);
2334 if ( $opt{section} ) {
2335 $category = $opt{section}->{category};
2336 $locationnum = $opt{section}->{locationnum};
2338 my $summary_page = $opt{summary_page} || ''; #unused
2339 my $multisection = defined($category) || defined($locationnum);
2340 my $discount_show_always = 0;
2342 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
2344 my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
2345 # and location labels
2348 my ($s, $r, $u) = ( undef, undef, undef );
2349 foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
2352 foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2353 if ( $_ && !$cust_bill_pkg->hidden ) {
2354 $_->{amount} = sprintf( "%.2f", $_->{amount} ),
2355 $_->{amount} =~ s/^\-0\.00$/0.00/;
2356 $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2358 if $_->{amount} != 0
2359 || $discount_show_always
2360 || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2361 || ( $_->{_is_setup} && $_->{setup_show_zero} )
2367 if ( $locationnum ) {
2368 # this is a location section; skip packages that aren't at this
2370 next if $cust_bill_pkg->pkgnum == 0;
2371 next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum
2375 # Consider display records for this item to determine if it belongs
2376 # in this section. Note that if there are no display records, there
2377 # will be a default pseudo-record that includes all charge types
2378 # and has no section name.
2379 my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
2380 ? $cust_bill_pkg->cust_bill_pkg_display
2381 : ( $cust_bill_pkg );
2383 warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
2384 $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
2387 if ( defined($category) ) {
2388 # then this is a package category section; process all display records
2389 # that belong to this section.
2390 @cust_bill_pkg_display = grep { $_->section eq $category }
2391 @cust_bill_pkg_display;
2393 # otherwise, process all display records that aren't usage summaries
2394 # (I don't think there should be usage summaries if you aren't using
2395 # category sections, but this is the historical behavior)
2396 @cust_bill_pkg_display = grep { !$_->summary }
2397 @cust_bill_pkg_display;
2399 foreach my $display (@cust_bill_pkg_display) {
2401 warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
2402 $display->billpkgdisplaynum. "\n"
2405 my $type = $display->type;
2407 my $desc = $cust_bill_pkg->desc( $cust_main ? $cust_main->locale : '' );
2408 $desc = substr($desc, 0, $maxlength). '...'
2409 if $format eq 'latex' && length($desc) > $maxlength;
2411 my %details_opt = ( 'format' => $format,
2412 'escape_function' => $escape_function,
2413 'format_function' => $format_function,
2414 'no_usage' => $opt{'no_usage'},
2417 if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
2419 warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
2422 if ( $cust_bill_pkg->setup != 0 ) {
2423 my $description = $desc;
2424 $description .= ' Setup'
2425 if $cust_bill_pkg->recur != 0
2426 || $discount_show_always
2427 || $cust_bill_pkg->recur_show_zero;
2429 'description' => $description,
2430 'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
2433 if ( $cust_bill_pkg->recur != 0 ) {
2435 'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
2436 'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
2440 } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
2442 warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
2445 my $cust_pkg = $cust_bill_pkg->cust_pkg;
2447 # which pkgpart to show for display purposes?
2448 my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
2450 # start/end dates for invoice formats that do nonstandard
2452 my %item_dates = ();
2453 %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
2454 unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
2456 if ( (!$type || $type eq 'S')
2457 && ( $cust_bill_pkg->setup != 0
2458 || $cust_bill_pkg->setup_show_zero
2463 warn "$me _items_cust_bill_pkg adding setup\n"
2466 my $description = $desc;
2467 $description .= ' Setup'
2468 if $cust_bill_pkg->recur != 0
2469 || $discount_show_always
2470 || $cust_bill_pkg->recur_show_zero;
2474 unless ( $cust_pkg->part_pkg->hide_svc_detail
2475 || $cust_bill_pkg->hidden )
2478 my @svc_labels = map &{$escape_function}($_),
2479 $cust_pkg->h_labels_short($self->_date, undef, 'I');
2480 push @d, @svc_labels
2481 unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2482 $svc_label = $svc_labels[0];
2484 my $lnum = $cust_main ? $cust_main->ship_locationnum
2485 : $self->prospect_main->locationnum;
2486 # show the location label if it's not the customer's default
2487 # location, and we're not grouping items by location already
2488 if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
2489 my $loc = $cust_pkg->location_label;
2490 $loc = substr($loc, 0, $maxlength). '...'
2491 if $format eq 'latex' && length($loc) > $maxlength;
2492 push @d, &{$escape_function}($loc);
2495 } #unless hiding service details
2497 push @d, $cust_bill_pkg->details(%details_opt)
2498 if $cust_bill_pkg->recur == 0;
2500 if ( $cust_bill_pkg->hidden ) {
2501 $s->{amount} += $cust_bill_pkg->setup;
2502 $s->{unit_amount} += $cust_bill_pkg->unitsetup;
2503 push @{ $s->{ext_description} }, @d;
2507 description => $description,
2508 pkgpart => $pkgpart,
2509 pkgnum => $cust_bill_pkg->pkgnum,
2510 amount => $cust_bill_pkg->setup,
2511 setup_show_zero => $cust_bill_pkg->setup_show_zero,
2512 unit_amount => $cust_bill_pkg->unitsetup,
2513 quantity => $cust_bill_pkg->quantity,
2514 ext_description => \@d,
2515 svc_label => ($svc_label || ''),
2521 if ( ( !$type || $type eq 'R' || $type eq 'U' )
2523 $cust_bill_pkg->recur != 0
2524 || $cust_bill_pkg->setup == 0
2525 || $discount_show_always
2526 || $cust_bill_pkg->recur_show_zero
2531 warn "$me _items_cust_bill_pkg adding recur/usage\n"
2534 my $is_summary = $display->summary;
2535 my $description = $desc;
2536 if ( $type eq 'U' and ($is_summary or $cust_bill_pkg->hidden) ) {
2537 $description = $self->mt('Usage charges');
2540 my $part_pkg = $cust_pkg->part_pkg;
2542 #pry be a bit more efficient to look some of this conf stuff up
2545 $conf->exists('disable_line_item_date_ranges')
2546 || $part_pkg->option('disable_line_item_date_ranges',1)
2547 || ! $cust_bill_pkg->sdate
2548 || ! $cust_bill_pkg->edate
2551 my $date_style = '';
2552 $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monhtly',
2555 if $part_pkg && $part_pkg->freq !~ /^1m?$/;
2556 $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
2559 if ( defined($date_style) && $date_style eq 'month_of' ) {
2560 $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
2561 } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
2562 my $desc = $conf->config( 'cust_bill-line_item-date_description',
2565 $desc .= ' ' unless $desc =~ /\s$/;
2566 $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
2568 $time_period = time2str($date_format, $cust_bill_pkg->sdate).
2569 " - ". time2str($date_format, $cust_bill_pkg->edate);
2571 $description .= " ($time_period)";
2575 my @seconds = (); # for display of usage info
2578 #at least until cust_bill_pkg has "past" ranges in addition to
2579 #the "future" sdate/edate ones... see #3032
2580 my @dates = ( $self->_date );
2581 my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
2582 push @dates, $prev->sdate if $prev;
2583 push @dates, undef if !$prev;
2585 unless ( $part_pkg->hide_svc_detail
2586 || $cust_bill_pkg->itemdesc
2587 || $cust_bill_pkg->hidden
2588 || $is_summary && $type && $type eq 'U'
2592 warn "$me _items_cust_bill_pkg adding service details\n"
2595 my @svc_labels = map &{$escape_function}($_),
2596 $cust_pkg->h_labels_short(@dates, 'I');
2597 push @d, @svc_labels
2598 unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
2599 $svc_label = $svc_labels[0];
2601 warn "$me _items_cust_bill_pkg done adding service details\n"
2604 my $lnum = $cust_main ? $cust_main->ship_locationnum
2605 : $self->prospect_main->locationnum;
2606 # show the location label if it's not the customer's default
2607 # location, and we're not grouping items by location already
2608 if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
2609 my $loc = $cust_pkg->location_label;
2610 $loc = substr($loc, 0, $maxlength). '...'
2611 if $format eq 'latex' && length($loc) > $maxlength;
2612 push @d, &{$escape_function}($loc);
2615 # Display of seconds_since_sqlradacct:
2616 # On the invoice, when processing @detail_items, look for a field
2617 # named 'seconds'. This will contain total seconds for each
2618 # service, in the same order as @ext_description. For services
2619 # that don't support this it will show undef.
2620 if ( $conf->exists('svc_acct-usage_seconds')
2621 and ! $cust_bill_pkg->pkgpart_override ) {
2622 foreach my $cust_svc (
2623 $cust_pkg->h_cust_svc(@dates, 'I')
2626 # eval because not having any part_export_usage exports
2627 # is a fatal error, last_bill/_date because that's how
2628 # sqlradius_hour billing does it
2630 $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
2632 push @seconds, $sec;
2634 } #if svc_acct-usage_seconds
2638 unless ( $is_summary ) {
2639 warn "$me _items_cust_bill_pkg adding details\n"
2642 #instead of omitting details entirely in this case (unwanted side
2643 # effects), just omit CDRs
2644 $details_opt{'no_usage'} = 1
2645 if $type && $type eq 'R';
2647 push @d, $cust_bill_pkg->details(%details_opt);
2650 warn "$me _items_cust_bill_pkg calculating amount\n"
2655 $amount = $cust_bill_pkg->recur;
2656 } elsif ($type eq 'R') {
2657 $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
2658 } elsif ($type eq 'U') {
2659 $amount = $cust_bill_pkg->usage;
2663 ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
2666 if ( !$type || $type eq 'R' ) {
2668 warn "$me _items_cust_bill_pkg adding recur\n"
2671 if ( $cust_bill_pkg->hidden ) {
2672 $r->{amount} += $amount;
2673 $r->{unit_amount} += $unit_amount;
2674 push @{ $r->{ext_description} }, @d;
2677 description => $description,
2678 pkgpart => $pkgpart,
2679 pkgnum => $cust_bill_pkg->pkgnum,
2681 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2682 unit_amount => $unit_amount,
2683 quantity => $cust_bill_pkg->quantity,
2685 ext_description => \@d,
2686 svc_label => ($svc_label || ''),
2688 $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
2691 } else { # $type eq 'U'
2693 warn "$me _items_cust_bill_pkg adding usage\n"
2696 if ( $cust_bill_pkg->hidden and defined($u) ) {
2697 # if this is a hidden package and there's already a usage
2698 # line for the bundle, add this package's total amount and
2699 # usage details to it
2700 $u->{amount} += $amount;
2701 $u->{unit_amount} += $unit_amount,
2702 push @{ $u->{ext_description} }, @d;
2703 } elsif ( $amount ) {
2704 # create a new usage line
2706 description => $description,
2707 pkgpart => $pkgpart,
2708 pkgnum => $cust_bill_pkg->pkgnum,
2710 recur_show_zero => $cust_bill_pkg->recur_show_zero,
2711 unit_amount => $unit_amount,
2712 quantity => $cust_bill_pkg->quantity,
2714 ext_description => \@d,
2716 } # else this has no usage, so don't create a usage section
2719 } # recurring or usage with recurring charge
2721 } else { #pkgnum tax or one-shot line item (??)
2723 warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
2726 if ( $cust_bill_pkg->setup != 0 ) {
2728 'description' => $desc,
2729 'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
2732 if ( $cust_bill_pkg->recur != 0 ) {
2734 'description' => "$desc (".
2735 time2str($date_format, $cust_bill_pkg->sdate). ' - '.
2736 time2str($date_format, $cust_bill_pkg->edate). ')',
2737 'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
2745 $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
2746 && $conf->exists('discount-show-always'));
2750 foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
2752 $_->{amount} = sprintf( "%.2f", $_->{amount} ),
2753 $_->{amount} =~ s/^\-0\.00$/0.00/;
2754 $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
2756 if $_->{amount} != 0
2757 || $discount_show_always
2758 || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
2759 || ( $_->{_is_setup} && $_->{setup_show_zero} )
2763 warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
2770 =item _items_discounts_avail
2772 Returns an array of line item hashrefs representing available term discounts
2773 for this invoice. This makes the same assumptions that apply to term
2774 discounts in general: that the package is billed monthly, at a flat rate,
2775 with no usage charges. A prorated first month will be handled, as will
2776 a setup fee if the discount is allowed to apply to setup fees.
2780 sub _items_discounts_avail {
2783 #maybe move this method from cust_bill when quotations support discount_plans
2784 return () unless $self->can('discount_plans');
2785 my %plans = $self->discount_plans;
2787 my $list_pkgnums = 0; # if any packages are not eligible for all discounts
2788 $list_pkgnums = grep { $_->list_pkgnums } values %plans;
2792 my $plan = $plans{$months};
2794 my $term_total = sprintf('%.2f', $plan->discounted_total);
2795 my $percent = sprintf('%.0f',
2796 100 * (1 - $term_total / $plan->base_total) );
2797 my $permonth = sprintf('%.2f', $term_total / $months);
2798 my $detail = $self->mt('discount on item'). ' '.
2799 join(', ', map { "#$_" } $plan->pkgnums)
2802 # discounts for non-integer months don't work anyway
2803 $months = sprintf("%d", $months);
2806 description => $self->mt('Save [_1]% by paying for [_2] months',
2808 amount => $self->mt('[_1] ([_2] per month)',
2809 $term_total, $money_char.$permonth),
2810 ext_description => ($detail || ''),
2813 sort { $b <=> $a } keys %plans;