1 package FS::Template_Mixin;
4 use vars qw( $DEBUG $me
9 use vars qw( $invoice_lines @buf ); #yuck
10 use List::Util qw(sum); #can't import first, it conflicts with cust_main.first
13 use Time::Local qw( timelocal );
14 use Text::Template 1.20;
16 use Archive::Zip qw( :ERROR_CODES :CONSTANTS );
21 use FS::Misc qw( send_email );
22 use FS::Record qw( qsearch qsearchs );
24 use FS::Misc qw( generate_ps generate_pdf );
31 $me = '[FS::Template_Mixin]';
32 FS::UID->install_callback( sub {
33 my $conf = new FS::Conf; #global
34 $money_char = $conf->config('money_char') || '$';
35 $date_format = $conf->config('date_format') || '%x'; #/YY
40 Returns a configuration handle (L<FS::Conf>) set to the customer's locale.
42 If the "mode" pseudo-field is set on the object, the configuration handle
43 will be an L<FS::invoice_conf> for that invoice mode (and the customer's
50 my $mode = $self->get('mode');
51 if ($self->{_conf} and !defined($mode)) {
52 return $self->{_conf};
55 my $cust_main = $self->cust_main;
56 my $locale = $cust_main ? $cust_main->locale : '';
59 if ( ref $mode and $mode->isa('FS::invoice_mode') ) {
60 $mode = $mode->modenum;
61 } elsif ( $mode =~ /\D/ ) {
62 die "invalid invoice mode $mode";
64 $conf = qsearchs('invoice_conf', { modenum => $mode, locale => $locale });
66 $conf = qsearchs('invoice_conf', { modenum => $mode, locale => '' });
67 # it doesn't have a locale, but system conf still might
68 $conf->set('locale' => $locale) if $conf;
71 # if $mode is unspecified, or if there is no invoice_conf matching this mode
72 # and locale, then use the system config only (but with the locale)
73 $conf ||= FS::Conf->new({ 'locale' => $locale });
75 return $self->{_conf} = $conf;
78 =item print_text OPTIONS
80 Returns an text invoice, as a list of lines.
82 Options can be passed as a hash.
84 I<time>, if specified, is used to control the printing of overdue messages. The
85 default is now. It isn't the date of the invoice; that's the `_date' field.
86 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
87 L<Time::Local> and L<Date::Parse> for conversion functions.
89 I<template>, if specified, is the name of a suffix for alternate invoices.
91 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
99 %params = %{ shift() };
104 $params{'format'} = 'template'; # for some reason
106 $self->print_generic( %params );
109 =item print_latex HASHREF
111 Internal method - returns a filename of a filled-in LaTeX template for this
112 invoice (Note: add ".tex" to get the actual filename), and a filename of
113 an associated logo (with the .eps extension included).
115 See print_ps and print_pdf for methods that return PostScript and PDF output.
117 Options can be passed as a hash.
119 I<time>, if specified, is used to control the printing of overdue messages. The
120 default is now. It isn't the date of the invoice; that's the `_date' field.
121 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
122 L<Time::Local> and L<Date::Parse> for conversion functions.
124 I<template>, if specified, is the name of a suffix for alternate invoices.
125 This is strongly deprecated; see L<FS::invoice_conf> for the right way to
126 customize invoice templates for different purposes.
128 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
137 %params = %{ shift() };
142 $params{'format'} = 'latex';
143 my $conf = $self->conf;
145 # this needs to go away
146 my $template = $params{'template'};
147 # and this especially
148 $template ||= $self->_agent_template
149 if $self->can('_agent_template');
152 $self->set('mode', $params{mode})
155 my $pkey = $self->primary_key;
156 my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
158 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
159 my $lh = new File::Temp(
160 TEMPLATE => $tmp_template,
164 ) or die "can't open temp file: $!\n";
166 my $agentnum = $self->agentnum;
168 if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
169 print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
170 or die "can't write temp file: $!\n";
172 print $lh $conf->config_binary('logo.eps', $agentnum)
173 or die "can't write temp file: $!\n";
176 $params{'logo_file'} = $lh->filename;
178 if( $conf->exists('invoice-barcode')
179 && $self->can('invoice_barcode')
180 && $self->invnum ) { # don't try to barcode statements
181 my $png_file = $self->invoice_barcode($dir);
182 my $eps_file = $png_file;
183 $eps_file =~ s/\.png$/.eps/g;
184 $png_file =~ /(barcode.*png)/;
186 $eps_file =~ /(barcode.*eps)/;
189 my $curr_dir = cwd();
191 # after painfuly long experimentation, it was determined that sam2p won't
192 # accept : and other chars in the path, no matter how hard I tried to
193 # escape them, hence the chdir (and chdir back, just to be safe)
194 system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0
195 or die "sam2p failed: $!\n";
199 $params{'barcode_file'} = $eps_file;
202 my @filled_in = $self->print_generic( %params );
204 my $fh = new File::Temp( TEMPLATE => $tmp_template,
208 ) or die "can't open temp file: $!\n";
209 binmode($fh, ':utf8'); # language support
210 print $fh join('', @filled_in );
213 $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
214 return ($1, $params{'logo_file'}, $params{'barcode_file'});
220 my $cust_main = $self->cust_main;
221 $cust_main ? $cust_main->agentnum : $self->prospect_main->agentnum;
224 =item print_generic OPTION => VALUE ...
226 Internal method - returns a filled-in template for this invoice as a scalar.
228 See print_ps and print_pdf for methods that return PostScript and PDF output.
236 The B<format> option is required and should be set to html, latex (print and PDF) or template (plaintext).
246 Overrides "Invoice" as the name of the sent document.
250 Used to control the printing of overdue messages. The
251 default is now. It isn't the date of the invoice; that's the `_date' field.
252 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
253 L<Time::Local> and L<Date::Parse> for conversion functions.
257 Logo file (path to temporary EPS file on the local filesystem)
261 CID for inline (emailed) images (logo)
265 Override customer's locale
269 Overrides any per customer cdr squelching when true
273 Supress the (invoice, quotation, statement, etc.) number
281 Supress the payment coupon
285 Barcode file (path to temporary EPS file on the local filesystem)
289 Flag indicating the barcode image should be a link (normal HTML dipaly)
293 Barcode CID for inline (emailed) images
295 =item preref_callback
297 Coderef run for each line item, code should return HTML to be displayed
298 before that line item (quotations only)
302 Dprecated. Used as a suffix for a configuration template. Please
303 don't use this, it deprecated in favor of more flexible alternatives.
309 #what's with all the sprintf('%10.2f')'s in here? will it cause any
310 # (alignment in text invoice?) problems to change them all to '%.2f' ?
311 # yes: fixed width/plain text printing will be borked
313 my( $self, %params ) = @_;
314 my $conf = $self->conf;
316 my $today = $params{today} ? $params{today} : time;
317 warn "$me print_generic called on $self with suffix $params{template}\n"
320 my $format = $params{format};
321 die "Unknown format: $format"
322 unless $format =~ /^(latex|html|template)$/;
324 my $cust_main = $self->cust_main || $self->prospect_main;
326 my $locale = $params{'locale'} || $cust_main->locale;
328 my %delimiters = ( 'latex' => [ '[@--', '--@]' ],
329 'html' => [ '<%=', '%>' ],
330 'template' => [ '{', '}' ],
333 warn "$me print_generic creating template\n"
336 # set the notice name here, and nowhere else.
337 my $notice_name = $params{notice_name}
338 || $conf->config('notice_name')
339 || $self->notice_name;
342 my $template = $params{template} ? $params{template} : $self->_agent_template;
343 my $templatefile = $self->template_conf. $format;
344 $templatefile .= "_$template"
345 if length($template) && $conf->exists($templatefile."_$template");
347 $self->set('_template',$template);
350 my @invoice_template = map "$_\n", $conf->config($templatefile)
351 or die "cannot load config data $templatefile";
353 if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
354 #change this to a die when the old code is removed
355 # it's been almost ten years, changing it to a die
356 die "old-style invoice template $templatefile; ".
357 "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
358 #$old_latex = 'true';
359 #@invoice_template = _translate_old_latex_format(@invoice_template);
362 warn "$me print_generic creating T:T object\n"
365 my $text_template = new Text::Template(
367 SOURCE => \@invoice_template,
368 DELIMITERS => $delimiters{$format},
371 warn "$me print_generic compiling T:T object\n"
374 $text_template->compile()
375 or die "Can't compile $templatefile: $Text::Template::ERROR\n";
378 # additional substitution could possibly cause breakage in existing templates
381 'notes' => sub { map "$_", @_ },
382 'footer' => sub { map "$_", @_ },
383 'smallfooter' => sub { map "$_", @_ },
384 'returnaddress' => sub { map "$_", @_ },
385 'coupon' => sub { map "$_", @_ },
386 'summary' => sub { map "$_", @_ },
392 s/%%(.*)$/<!-- $1 -->/g;
393 s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
394 s/\\begin\{enumerate\}/<ol>/g;
396 s/\\end\{enumerate\}/<\/ol>/g;
397 s/\\textbf\{(.*)\}/<b>$1<\/b>/g;
406 sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
408 sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
414 s/\\hyphenation\{[\w\s\-]+}//;
419 'coupon' => sub { "" },
420 'summary' => sub { "" },
427 s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
428 s/\\begin\{enumerate\}//g;
430 s/\\end\{enumerate\}//g;
431 s/\\textbf\{(.*)\}/$1/g;
438 sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
440 sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
445 s/\\\\\*?\s*$/\n/; # dubious
446 s/\\hyphenation\{[\w\s\-]+}//;
450 'coupon' => sub { "" },
451 'summary' => sub { "" },
456 # hashes for differing output formats
457 my %nbsps = ( 'latex' => '~',
458 'html' => '', # '&nbps;' would be nice
459 'template' => '', # not used
461 my $nbsp = $nbsps{$format};
463 my %escape_functions = ( 'latex' => \&_latex_escape,
464 'html' => \&_html_escape_nbsp,#\&encode_entities,
465 'template' => sub { shift },
467 my $escape_function = $escape_functions{$format};
468 my $escape_function_nonbsp = ($format eq 'html')
469 ? \&_html_escape : $escape_function;
471 my %newline_tokens = ( 'latex' => '\\\\',
475 my $newline_token = $newline_tokens{$format};
477 warn "$me generating template variables\n"
480 # generate template variables
484 defined( $conf->config_orbase( "invoice_${format}returnaddress",
488 && length( $conf->config_orbase( "invoice_${format}returnaddress",
494 $returnaddress = join("\n",
495 $conf->config_orbase("invoice_${format}returnaddress", $template)
499 $conf->config_orbase('invoice_latexreturnaddress', $template) ) {
501 my $convert_map = $convert_maps{$format}{'returnaddress'};
504 &$convert_map( $conf->config_orbase( "invoice_latexreturnaddress",
509 } elsif ( grep /\S/, $conf->config('company_address', $cust_main->agentnum) ) {
511 my $convert_map = $convert_maps{$format}{'returnaddress'};
512 $returnaddress = join( "\n", &$convert_map(
513 map { s/( {2,})/'~' x length($1)/eg;
517 ( $conf->config('company_name', $cust_main->agentnum),
518 $conf->config('company_address', $cust_main->agentnum),
525 my $warning = "Couldn't find a return address; ".
526 "do you need to set the company_address configuration value?";
528 $returnaddress = $nbsp;
529 #$returnaddress = $warning;
533 warn "$me generating invoice data\n"
536 my $agentnum = $cust_main->agentnum;
541 'company_name' => scalar( $conf->config('company_name', $agentnum) ),
542 'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n",
543 'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ),
544 'returnaddress' => $returnaddress,
545 'agent' => &$escape_function($cust_main->agent->agent),
547 #invoice/quotation info
548 'no_number' => $params{'no_number'},
549 'invnum' => ( $params{'no_number'} ? '' : $self->invnum ),
550 'quotationnum' => $self->quotationnum,
551 'no_date' => $params{'no_date'},
552 '_date' => ( $params{'no_date'} ? '' : $self->_date ),
553 # workaround for inconsistent behavior in the early plain text
554 # templates; see RT#28271
555 'date' => ( $params{'no_date'}
557 : ($format eq 'template'
559 : $self->time2str_local('long', $self->_date, $format)
562 'today' => $self->time2str_local('long', $today, $format),
563 'terms' => $self->terms,
564 'template' => $template, #params{'template'},
565 'notice_name' => $notice_name, # escape?
566 'current_charges' => sprintf("%.2f", $self->charged),
567 'duedate' => $self->due_date2str('rdate'), #date_format?
568 'duedate_long' => $self->due_date2str('long'),
571 'custnum' => $cust_main->display_custnum,
572 'prospectnum' => $cust_main->prospectnum,
573 'agent_custid' => &$escape_function($cust_main->agent_custid),
574 ( map { $_ => &$escape_function($cust_main->$_()) }
575 qw( company address1 address2 city state zip fax )
577 'payname' => &$escape_function( $cust_main->invoice_attn
578 || $cust_main->contact_firstlast ),
581 'ship_enable' => $cust_main->invoice_ship_address || $conf->exists('invoice-ship_address'),
582 'unitprices' => $conf->exists('invoice-unitprice'),
583 'smallernotes' => $conf->exists('invoice-smallernotes'),
584 'smallerfooter' => $conf->exists('invoice-smallerfooter'),
585 'balance_due_below_line' => $conf->exists('balance_due_below_line'),
587 #layout info -- would be fancy to calc some of this and bury the template
589 'topmargin' => scalar($conf->config('invoice_latextopmargin', $agentnum)),
590 'headsep' => scalar($conf->config('invoice_latexheadsep', $agentnum)),
591 'textheight' => scalar($conf->config('invoice_latextextheight', $agentnum)),
592 'extracouponspace' => scalar($conf->config('invoice_latexextracouponspace', $agentnum)),
593 'couponfootsep' => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)),
594 'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum),
595 'addresssep' => scalar($conf->config('invoice_latexaddresssep', $agentnum)),
596 'amountenclosedsep' => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
597 'coupontoaddresssep' => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
598 'addcompanytoaddress' => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
600 # better hang on to conf_dir for a while (for old templates)
601 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
603 #these are only used when doing paged plaintext
609 #quotations have $name
610 $invoice_data{'name'} = $invoice_data{'payname'};
613 $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
614 # prototype here to silence warnings
615 $invoice_data{'time2str'} = sub ($;$$) { $self->time2str_local(@_, $format) };
617 my $min_sdate = 999999999999;
619 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
620 next unless $cust_bill_pkg->pkgnum > 0;
621 $min_sdate = $cust_bill_pkg->sdate
622 if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate;
623 $max_edate = $cust_bill_pkg->edate
624 if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate;
627 $invoice_data{'bill_period'} = '';
628 $invoice_data{'bill_period'} =
629 $self->time2str_local('%e %h', $min_sdate, $format)
631 $self->time2str_local('%e %h', $max_edate, $format)
632 if ($max_edate != 0 && $min_sdate != 999999999999);
634 $invoice_data{finance_section} = '';
635 if ( $conf->config('finance_pkgclass') ) {
637 qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
638 $invoice_data{finance_section} = $pkg_class->categoryname;
640 $invoice_data{finance_amount} = '0.00';
641 $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
643 my $countrydefault = $conf->config('countrydefault') || 'US';
644 foreach ( qw( address1 address2 city state zip country fax) ){
645 my $method = 'ship_'.$_;
646 $invoice_data{"ship_$_"} = $escape_function->($cust_main->$method);
648 if ( length($cust_main->ship_company) ) {
649 $invoice_data{'ship_company'} = $escape_function->($cust_main->ship_company);
651 $invoice_data{'ship_company'} = $escape_function->($cust_main->company);
653 $invoice_data{'ship_contact'} = $escape_function->($cust_main->contact);
654 $invoice_data{'ship_country'} = ''
655 if ( $invoice_data{'ship_country'} eq $countrydefault );
657 $invoice_data{'cid'} = $params{'cid'}
660 if ( $cust_main->country eq $countrydefault ) {
661 $invoice_data{'country'} = '';
663 $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full);
667 $invoice_data{'address'} = \@address;
669 $invoice_data{'payname'}.
670 ( $cust_main->po_number
671 ? " (P.O. #". $cust_main->po_number. ")"
675 push @address, $cust_main->company
676 if $cust_main->company;
677 push @address, $cust_main->address1;
678 push @address, $cust_main->address2
679 if $cust_main->address2;
681 $cust_main->city. ", ". $cust_main->state. " ". $cust_main->zip;
682 push @address, $invoice_data{'country'}
683 if $invoice_data{'country'};
685 while (scalar(@address) < 5);
687 $invoice_data{'logo_file'} = $params{'logo_file'}
688 if $params{'logo_file'};
689 $invoice_data{'barcode_file'} = $params{'barcode_file'}
690 if $params{'barcode_file'};
691 $invoice_data{'barcode_img'} = $params{'barcode_img'}
692 if $params{'barcode_img'};
693 $invoice_data{'barcode_cid'} = $params{'barcode_cid'}
694 if $params{'barcode_cid'};
698 # using owed_on_invoice() instead of owed() here for $balance_due
699 # using _items_previous_total() instead of ->previous() for $pr_total
701 # owed_on_invoice() is aware of configuration flags that affect how an
702 # invoice is rendered. May not return actual current balance. Will
703 # return balance appropriate for the invoice being rendered, based
704 # on which past due items, current charges, and future payments are
707 # Going forward, usage of owed(), or bypassing cust_bill helper methods
708 # when generating invoice lines may lead to incorrect or misleading
711 # Helper methods that are aware of invoicing conf flags:
712 # - owed_on_invoice # use instead of owed()
713 # - _items_previous() # use instead of previous()
714 # - _items_credits() # use instead of cust_credit()
715 # - _items_payments()
717 # - _items_previous_total() # use instead of previous()
718 # - _items_payments_total()
719 # - _items_credits_total() # use instead of cust_credit()
721 my $pr_total = $self->_items_previous_total();
723 my $balance_due = $self->owed_on_invoice();
724 $invoice_data{'balance'} = sprintf("%.2f", $balance_due);
726 # flag telling this invoice to have a first-page summary
727 my $summarypage = '';
729 if ( $self->custnum && $self->invnum ) {
730 # XXX should be an FS::cust_bill method to set the defaults, instead
731 # of checking the type here
733 # info from customer's last invoice before this one, for some
735 $invoice_data{'last_bill'} = {};
737 # my $last_bill = $self->previous_bill;
738 # if ( $last_bill ) {
740 # Populate template stash for previous balance and payments
742 # Used on summary page as "Previous Balance"
743 $invoice_data{'true_previous_balance'} = sprintf("%.2f", $pr_total);
745 # Used on summary page as "Payments"
746 $invoice_data{'balance_adjustments'} = sprintf("%.2f",
747 $self->_items_payments_total() + $self->_items_credits_total()
750 # Used in invoice template as "Previous Balance"
751 $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
753 # $invoice_data{last_bill}{_date}:
754 # Not used in default templates, but may be in use by someone
756 # ! May be a problem field if they are using it... this field
757 # stores the date of the previous invoice... it is possible to
758 # carry a balance, but have the immediately previous invoice paid off.
759 # In this case, this field might be presenting bad data? Not
760 # altering the problematic behavior, because someone might be
761 # expecting this bad behavior in their templates for some other
762 # purpose, such as a "your last bill was dated %_date%"
763 my $last_bill = $self->previous_bill;
764 $invoice_data{'last_bill'}{'_date'}
766 ? $last_bill->_date()
769 # $invoice_data{previous_payments}
770 # Not used in default templates, but may be in use by someone
772 # Returns an array of hrefs representing payments, each with keys:
773 # - _date: epoch timestamp
774 # - date: text formatted date
775 # - amount: money formatted amount string
776 # - payinfo: string from payby_payinfo_pretty()
777 # - paynum: id for cust_pay
778 # - description: Text description for bill line item
780 my @payments = $self->_items_payments();
781 $invoice_data{previous_payments} = \@payments;
783 # $invoice_data{previous_credits}
784 # Not used in default templates, but may be in use by someone
786 # Returns an array of hrefs representing credits, each with keys:
787 # - _date: epoch timestamp
788 # - date: text formatted date
789 # - amount: money formatted amount string
790 # - crednum: id for cust_credit
791 # - description: Text description for bill line item
792 # - creditreason: reason() from cust_credit
794 my @credits = $self->_items_credits();
795 $invoice_data{previous_credits} = \@credits;
797 # Populate formatted date field
798 for my $pmt_href (@payments, @credits) {
799 $pmt_href->{date} = $self->time2str_local(
807 # There are no outstanding invoices = YAPH
808 $invoice_data{'true_previous_balance'} =
809 $invoice_data{'balance_adjustments'} =
810 $invoice_data{'previous_balance'} = '0.00';
811 $invoice_data{'previous_payments'} =
812 $invoice_data{'previous_credits'} = [];
815 # Condencing a lot of debug staements here
817 warn "\$invoice_data{$_}: $invoice_data{$_}"
819 true_previous_balance
827 if ( $conf->exists('invoice_usesummary', $agentnum) ) {
828 $invoice_data{'summarypage'} = $summarypage = 1;
831 } # if this is an invoice
833 warn "$me substituting variables in notes, footer, smallfooter\n"
836 my $tc = $self->template_conf;
837 my @include = ( [ $tc, 'notes' ],
838 [ 'invoice_', 'footer' ],
839 [ 'invoice_', 'smallfooter', ],
840 [ 'invoice_', 'watermark' ],
842 push @include, [ $tc, 'coupon', ]
843 unless $params{'no_coupon'};
845 foreach my $i (@include) {
847 # load the configuration for this sub-template
849 my($base, $include) = @$i;
851 my $inc_file = $conf->key_orbase("$base$format$include", $template);
853 my @inc_src = $conf->config($inc_file, $agentnum);
855 my $converter = $convert_maps{$format}{$include};
857 # then attempt to convert LaTeX to the requested format
858 $inc_file = $conf->key_orbase($base.'latex'.$include, $template);
859 @inc_src = &$converter( $conf->config($inc_file, $agentnum) );
861 # this isn't included in the convert_maps
862 my ($open, $close) = @{ $delimiters{$format} };
867 } # else @inc_src is empty and that's fine
869 # make a Text::Template out of it
871 my $inc_tt = new Text::Template (
873 SOURCE => [ map "$_\n", @inc_src ],
874 DELIMITERS => $delimiters{$format},
875 ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
877 unless ( $inc_tt->compile() ) {
878 my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
879 warn $error. "Template:\n". join('', map "$_\n", @inc_src);
885 $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
887 $invoice_data{$include} =~ s/\n+$//
888 if ($format eq 'latex');
891 # if (well, probably when) we still need PO numbers in the brave new world of
892 # 4.x, then we'll have to add them back as their own customer fields
893 # # let invoices use either of these as needed
894 # $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL')
895 # ? $cust_main->payinfo : '';
896 # $invoice_data{'po_line'} =
897 # ( $cust_main->payby eq 'BILL' && $cust_main->payinfo )
898 # ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo)
901 my %money_chars = ( 'latex' => '',
902 'html' => $conf->config('money_char') || '$',
905 my $money_char = $money_chars{$format};
908 my %other_money_chars = ( 'latex' => '\dollar ',#XXX should be a config too
909 'html' => $conf->config('money_char') || '$',
912 my $other_money_char = $other_money_chars{$format};
913 $invoice_data{'dollar'} = $other_money_char;
915 my %minus_signs = ( 'latex' => '$-$',
917 'template' => '- ' );
918 my $minus = $minus_signs{$format};
920 my @detail_items = ();
921 my @total_items = ();
925 $invoice_data{'detail_items'} = \@detail_items;
926 $invoice_data{'total_items'} = \@total_items;
927 $invoice_data{'buf'} = \@buf;
928 $invoice_data{'sections'} = \@sections;
930 warn "$me generating sections\n"
933 my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
934 my $multisection = $self->has_sections;
935 $conf->exists($tc.'sections', $cust_main->agentnum) ||
936 $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
937 $invoice_data{'multisection'} = $multisection;
939 my $extra_sections = [];
940 my $extra_lines = ();
942 # default section ('Charges')
943 my $default_section = { 'description' => '',
948 # Previous Charges section
949 # subtotal is the first return value from $self->previous
950 my $previous_section;
951 # if the invoice has major sections, or if we're summarizing previous
952 # charges with a single line, or if we've been specifically told to put them
953 # in a section, create a section for previous charges:
954 if ( $multisection or
955 $conf->exists('previous_balance-summary_only') or
956 $conf->exists('previous_balance-section') ) {
958 $previous_section = { 'description' => $self->mt('Previous Charges'),
959 'subtotal' => $other_money_char.
960 sprintf('%.2f', $pr_total),
961 'summarized' => '', #why? $summarypage ? 'Y' : '',
964 # Include balance aging line and template variables
965 my @aged_balances = $self->_items_aging_balances();
966 ( $invoice_data{aged_balance_current},
967 $invoice_data{aged_balance_30d},
968 $invoice_data{aged_balance_60d},
969 $invoice_data{aged_balance_90d}
972 if ($conf->exists('invoice_include_aging')) {
973 $previous_section->{posttotal} = sprintf(
974 '0 / 30 / 60 / 90 days overdue %.2f / %.2f / %.2f / %.2f',
980 # otherwise put them in the main section
981 $previous_section = $default_section;
984 my $adjust_section = {
985 'description' => $self->mt('Credits, Payments, and Adjustments'),
986 'adjust_section' => 1,
987 'subtotal' => 0, # adjusted below
989 my $adjust_weight = _pkg_category($adjust_section->{description})
990 ? _pkg_category($adjust_section->{description})->weight
992 $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
993 # Note: 'sort_weight' here is actually a flag telling whether there is an
994 # explicit package category for the adjust section. If so, certain behavior
996 $adjust_section->{'sort_weight'} = $adjust_weight;
999 if ( $multisection ) {
1000 ($extra_sections, $extra_lines) =
1001 $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
1002 if $conf->exists('usage_class_as_a_section', $cust_main->agentnum)
1003 && $self->can('_items_extra_usage_sections');
1005 push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
1007 push @detail_items, @$extra_lines if $extra_lines;
1009 # the code is written so that both methods can be used together, but
1010 # we haven't yet changed the template to take advantage of that, so for
1011 # now, treat them as mutually exclusive.
1012 my %section_method = ( by_category => 1 );
1013 if ( $conf->config($tc.'sections_method') eq 'location' ) {
1014 %section_method = ( by_location => 1 );
1016 my ($early, $late) =
1017 $self->_items_sections( 'summary' => $summarypage,
1018 'escape' => $escape_function_nonbsp,
1019 'extra_sections' => $extra_sections,
1020 'format' => $format,
1023 push @sections, @$early;
1024 $late_sections = $late;
1026 if ( $conf->exists('svc_phone_sections')
1027 && $self->can('_items_svc_phone_sections')
1030 my ($phone_sections, $phone_lines) =
1031 $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
1032 push @{$late_sections}, @$phone_sections;
1033 push @detail_items, @$phone_lines;
1035 if ( $conf->exists('voip-cust_accountcode_cdr')
1036 && $cust_main->accountcode_cdr
1037 && $self->can('_items_accountcode_cdr')
1040 my ($accountcode_section, $accountcode_lines) =
1041 $self->_items_accountcode_cdr($escape_function_nonbsp,$format);
1042 if ( scalar(@$accountcode_lines) ) {
1043 push @{$late_sections}, $accountcode_section;
1044 push @detail_items, @$accountcode_lines;
1047 } else {# not multisection
1048 # make a default section
1049 push @sections, $default_section;
1050 # and calculate the finance charge total, since it won't get done otherwise.
1051 # and the default section total
1052 # XXX possibly finance_pkgclass should not be used in this manner?
1053 my @finance_charges;
1055 foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
1056 if ( $invoice_data{finance_section} and
1057 grep { $_->section eq $invoice_data{finance_section} }
1058 $cust_bill_pkg->cust_bill_pkg_display ) {
1059 # I think these are always setup fees, but just to be sure...
1060 push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
1062 push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
1065 $invoice_data{finance_amount} =
1066 sprintf('%.2f', sum( @finance_charges ) || 0);
1067 $default_section->{subtotal} = $other_money_char.
1068 sprintf('%.2f', sum( @charges ) || 0);
1071 # start setting up summary subtotals
1072 my @summary_subtotals;
1073 my $method = $conf->config('summary_subtotals_method');
1074 if ( $method and $method ne $conf->config($tc.'sections_method') ) {
1075 # then re-section them by the correct method
1076 my %section_method = ( by_category => 1 );
1077 if ( $conf->config('summary_subtotals_method') eq 'location' ) {
1078 %section_method = ( by_location => 1 );
1080 my ($early, $late) =
1081 $self->_items_sections( 'summary' => $summarypage,
1082 'escape' => $escape_function_nonbsp,
1083 'extra_sections' => $extra_sections,
1084 'format' => $format,
1087 foreach ( @$early ) {
1088 next if $_->{subtotal} == 0;
1089 $_->{subtotal} = $other_money_char.sprintf('%.2f', $_->{subtotal});
1090 push @summary_subtotals, $_;
1093 # subtotal sectioning is the same as for the actual invoice sections
1094 @summary_subtotals = @sections;
1097 # Hereafter, push sections to both @sections and @summary_subtotals
1098 # if they belong in both places (e.g. tax section). Late sections are
1099 # never in @summary_subtotals.
1101 # previous invoice balances in the Previous Charges section if there
1102 # is one, otherwise in the main detail section
1103 # (except if summary_only is enabled, don't show them at all)
1104 if ( $self->can('_items_previous') &&
1105 $self->enable_previous &&
1106 ! $conf->exists('previous_balance-summary_only') ) {
1108 warn "$me adding previous balances\n"
1111 foreach my $line_item ( $self->_items_previous ) {
1114 ref => $line_item->{'pkgnum'},
1115 pkgpart => $line_item->{'pkgpart'},
1116 #quantity => 1, # not really correct
1117 section => $previous_section, # which might be $default_section
1118 description => &$escape_function($line_item->{'description'}),
1119 ext_description => [ map { &$escape_function($_) }
1120 @{ $line_item->{'ext_description'} || [] }
1122 amount => $money_char . $line_item->{'amount'},
1123 product_code => $line_item->{'pkgpart'} || 'N/A',
1126 push @detail_items, $detail;
1127 push @buf, [ $detail->{'description'},
1128 $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1134 if ( $pr_total && $self->enable_previous ) {
1135 push @buf, ['','-----------'];
1136 push @buf, [ $self->mt('Total Previous Balance'),
1137 $money_char. sprintf("%10.2f", $pr_total) ];
1141 if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) {
1142 warn "$me adding DID summary\n"
1145 my ($didsummary,$minutes) = $self->_did_summary;
1146 my $didsummary_desc = 'DID Activity Summary (since last invoice)';
1148 { 'description' => $didsummary_desc,
1149 'ext_description' => [ $didsummary, $minutes ],
1153 foreach my $section (@sections, @$late_sections) {
1155 # begin some normalization
1156 $section->{'subtotal'} = $section->{'amount'}
1158 && !exists($section->{subtotal})
1159 && exists($section->{amount});
1161 $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} )
1162 if ( $invoice_data{finance_section} &&
1163 $section->{'description'} eq $invoice_data{finance_section} );
1165 if ( $multisection ) {
1167 if ( ref($section->{'subtotal'}) ) {
1169 $section->{'subtotal'} =
1170 sprintf("$other_money_char%.2f to $other_money_char%.2f",
1171 $section->{'subtotal'}[0],
1172 $section->{'subtotal'}[1]
1177 $section->{'subtotal'} = $other_money_char.
1178 sprintf('%.2f', $section->{'subtotal'})
1182 # continue some normalization
1183 $section->{'amount'} = $section->{'subtotal'}
1187 if ( $section->{'description'} ) {
1188 push @buf, ( [ &$escape_function($section->{'description'}), '' ],
1193 warn "$me setting options\n"
1197 $options{'section'} = $section if $multisection;
1198 $options{'format'} = $format;
1199 $options{'escape_function'} = $escape_function;
1200 $options{'no_usage'} = 1 unless $unsquelched;
1201 $options{'unsquelched'} = $unsquelched;
1202 $options{'summary_page'} = $summarypage;
1203 $options{'skip_usage'} =
1204 scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
1205 $options{'preref_callback'} = $params{'preref_callback'};
1207 warn "$me searching for line items\n"
1210 foreach my $line_item ( $self->_items_pkg(%options),
1211 $self->_items_fee(%options) ) {
1213 warn "$me adding line item ".
1214 join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
1217 push @buf, ( [ $line_item->{'description'},
1218 $money_char. sprintf("%10.2f", $line_item->{'amount'}),
1220 map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
1223 $line_item->{'ref'} = $line_item->{'pkgnum'};
1224 $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
1225 $line_item->{'section'} = $section;
1226 $line_item->{'description'} = &$escape_function($line_item->{'description'});
1227 $line_item->{'amount'} = $money_char.$line_item->{'amount'};
1229 if ( length($line_item->{'unit_amount'}) ) {
1230 $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
1232 $line_item->{'ext_description'} ||= [];
1234 push @detail_items, $line_item;
1237 if ( $section->{'description'} ) {
1238 push @buf, ( ['','-----------'],
1239 [ $section->{'description'}. ' sub-total',
1240 $section->{'subtotal'} # already formatted this
1249 $invoice_data{current_less_finance} =
1250 sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
1252 # if there's anything in the Previous Charges section, prepend it to the list
1253 if ( $pr_total and $previous_section ne $default_section ) {
1254 unshift @sections, $previous_section;
1255 # but not @summary_subtotals
1258 warn "$me adding taxes\n"
1261 # create a tax section if we don't yet have one
1262 my @items_tax = $self->_items_tax;
1263 my $tax_description = 'Taxes, Surcharges, and Fees';
1265 List::Util::first { $_->{description} eq $tax_description } @sections;
1266 if (!$tax_section) {
1267 $tax_section = { 'description' => $tax_description };
1268 push @sections, $tax_section if $multisection and @items_tax > 0;
1270 $tax_section->{tax_section} = 1; # mark this section as containing taxes
1271 # if this is an existing tax section, we're merging the tax items into it.
1272 # grab the taxtotal that's already there, strip the money symbol if any
1273 my $taxtotal = $tax_section->{'subtotal'} || 0;
1274 $taxtotal =~ s/^\Q$other_money_char\E//;
1277 #my $tax_weight = _pkg_category($tax_section->{description})
1278 # ? _pkg_category($tax_section->{description})->weight
1280 #$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
1281 #$tax_section->{'sort_weight'} = $tax_weight;
1283 foreach my $tax ( @items_tax ) {
1285 $taxtotal += $tax->{'amount'};
1287 my $description = &$escape_function( $tax->{'description'} );
1288 my $amount = sprintf( '%.2f', $tax->{'amount'} );
1290 if ( $multisection ) {
1292 push @detail_items, {
1293 ext_description => [],
1296 description => $description,
1297 amount => $money_char. $amount,
1299 section => $tax_section,
1304 push @total_items, {
1305 'total_item' => $description,
1306 'total_amount' => $other_money_char. $amount,
1311 push @buf,[ $description,
1312 $money_char. $amount,
1319 $total->{'total_item'} = $self->mt('Sub-total');
1320 $total->{'total_amount'} =
1321 $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
1323 if ( $multisection ) {
1324 if ( $taxtotal > 0 ) {
1325 # there are taxes, so prepare the section to be displayed.
1326 # $taxtotal already includes any line items that were already in the
1327 # section (fees, taxes that are charged as packages for some reason).
1328 # also set 'summarized' to false so that this isn't a summary-only
1330 $tax_section->{'subtotal'} = $other_money_char.
1331 sprintf('%.2f', $taxtotal);
1332 $tax_section->{'pretotal'} = 'New charges sub-total '.
1333 $total->{'total_amount'};
1334 $tax_section->{'description'} = $self->mt($tax_description);
1335 $tax_section->{'summarized'} = '';
1337 # append it if it's not already there
1338 if ( !grep $tax_section, @sections ) {
1339 push @sections, $tax_section;
1340 push @summary_subtotals, $tax_section;
1345 unshift @total_items, $total;
1348 $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
1354 my %embolden_functions = (
1355 'latex' => sub { return '\textbf{'. shift(). '}' },
1356 'html' => sub { return '<b>'. shift(). '</b>' },
1357 'template' => sub { shift },
1359 my $embolden_function = $embolden_functions{$format};
1361 if ( $multisection ) {
1363 if ( $adjust_section->{'sort_weight'} ) {
1364 $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
1365 $other_money_char. sprintf("%.2f", ($self->billing_balance || 0) );
1367 $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
1368 $other_money_char. sprintf('%.2f', $self->charged );
1373 if ( $self->can('_items_total') ) { # should always be true now
1375 # even for multisection, need plain text version
1377 my @new_total_items = $self->_items_total;
1379 push @buf,['','-----------'];
1381 foreach ( @new_total_items ) {
1382 my ($item, $amount) = ($_->{'total_item'}, $_->{'total_amount'});
1383 $_->{'total_item'} = &$embolden_function( $item );
1385 if ( ref($amount) ) {
1386 $_->{'total_amount'} = &$embolden_function(
1387 $other_money_char.$amount->[0]. ' to '.
1388 $other_money_char.$amount->[1]
1391 $_->{'total_amount'} = &$embolden_function( $other_money_char.$amount );
1394 # but if it's multisection, don't append to @total_items. the adjust
1395 # section has all this stuff
1396 push @total_items, $_ if !$multisection;
1397 push @buf, [ $item, $money_char.sprintf('%10.2f',$amount) ];
1400 push @buf, [ '', '' ];
1402 # if we're showing previous invoices, also show previous
1403 # credits and payments
1404 if ( $self->enable_previous
1405 and $self->can('_items_credits')
1406 and $self->can('_items_payments') )
1410 my $credittotal = 0;
1411 foreach my $credit (
1412 $self->_items_credits( 'template' => $template, 'trim_len' => 40 )
1416 $total->{'total_item'} = &$escape_function($credit->{'description'});
1417 $credittotal += $credit->{'amount'};
1418 $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
1419 if ( $multisection ) {
1420 push @detail_items, {
1421 ext_description => [],
1424 description => &$escape_function($credit->{'description'}),
1425 amount => $money_char . $credit->{'amount'},
1427 section => $adjust_section,
1430 push @total_items, $total;
1434 $invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
1437 foreach my $credit (
1438 $self->_items_credits( 'template' => $template, 'trim_len'=>32 )
1440 push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
1444 my $paymenttotal = 0;
1445 foreach my $payment (
1446 $self->_items_payments( 'template' => $template )
1449 $total->{'total_item'} = &$escape_function($payment->{'description'});
1450 $paymenttotal += $payment->{'amount'};
1451 $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
1452 if ( $multisection ) {
1453 push @detail_items, {
1454 ext_description => [],
1457 description => &$escape_function($payment->{'description'}),
1458 amount => $money_char . $payment->{'amount'},
1460 section => $adjust_section,
1463 push @total_items, $total;
1465 push @buf, [ $payment->{'description'},
1466 $money_char. sprintf("%10.2f", $payment->{'amount'}),
1469 $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
1471 if ( $multisection ) {
1472 $adjust_section->{'subtotal'} = $other_money_char.
1473 sprintf('%.2f', $credittotal + $paymenttotal);
1475 #why this? because {sort_weight} forces the adjust_section to appear
1476 #in @extra_sections instead of @sections. obviously.
1477 push @sections, $adjust_section
1478 unless $adjust_section->{sort_weight};
1479 # do not summarize; adjustments there are shown according to
1483 # create Balance Due message
1486 $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1487 $total->{'total_amount'} =
1488 &$embolden_function(
1489 $other_money_char. sprintf('%.2f', #why? $summarypage
1490 # ? $self->charged +
1491 # $self->billing_balance
1496 if ( $multisection && !$adjust_section->{sort_weight} ) {
1497 $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
1498 $total->{'total_amount'};
1500 push @total_items, $total;
1502 push @buf,['','-----------'];
1503 push @buf,[$self->balance_due_msg, $money_char.
1504 sprintf("%10.2f", $balance_due ) ];
1507 if ( $conf->exists('previous_balance-show_credit')
1508 and $cust_main->balance < 0 ) {
1509 my $credit_total = {
1510 'total_item' => &$embolden_function($self->credit_balance_msg),
1511 'total_amount' => &$embolden_function(
1512 $other_money_char. sprintf('%.2f', -$cust_main->balance)
1515 if ( $multisection ) {
1516 $adjust_section->{'posttotal'} .= $newline_token .
1517 $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'};
1520 push @total_items, $credit_total;
1522 push @buf,['','-----------'];
1523 push @buf,[$self->credit_balance_msg, $money_char.
1524 sprintf("%10.2f", -$cust_main->balance ) ];
1528 } #end of default total adding ! can('_items_total')
1530 if ( $multisection ) {
1531 if ( $conf->exists('svc_phone_sections')
1532 && $self->can('_items_svc_phone_sections')
1536 $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
1537 $total->{'total_amount'} =
1538 &$embolden_function(
1539 $other_money_char. sprintf('%.2f', $balance_due)
1541 my $last_section = pop @sections;
1542 $last_section->{'posttotal'} = $total->{'total_item'}. ' '.
1543 $total->{'total_amount'};
1544 push @sections, $last_section;
1546 push @sections, @$late_sections
1550 # make a discounts-available section, even without multisection
1551 if ( $conf->exists('discount-show_available')
1552 and my @discounts_avail = $self->_items_discounts_avail ) {
1553 my $discount_section = {
1554 'description' => $self->mt('Discounts Available'),
1559 push @sections, $discount_section; # do not summarize
1560 push @detail_items, map { +{
1561 'ref' => '', #should this be something else?
1562 'section' => $discount_section,
1563 'description' => &$escape_function( $_->{description} ),
1564 'amount' => $money_char . &$escape_function( $_->{amount} ),
1565 'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
1566 } } @discounts_avail;
1569 # not adding any more sections after this
1570 $invoice_data{summary_subtotals} = \@summary_subtotals;
1573 if ( $conf->exists('usage_class_summary')
1574 and $self->can('_items_usage_class_summary') ) {
1575 my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function, 'money_char' => $other_money_char);
1576 if ( @usage_subtotals ) {
1577 unshift @sections, $usage_subtotals[0]->{section}; # do not summarize
1578 unshift @detail_items, @usage_subtotals;
1582 # invoice history "section" (not really a section)
1583 # not to be included in any subtotals, completely independent of
1585 if ( $conf->exists('previous_invoice_history') and $cust_main->isa('FS::cust_main') ) {
1588 foreach my $cust_bill ( $cust_main->cust_bill ) {
1589 # XXX hardcoded format, and currently only 'charged'; add other fields
1590 # if they become necessary
1591 my $date = $self->time2str_local('%b %Y', $cust_bill->_date);
1592 $history{$date} ||= 0;
1593 $history{$date} += $cust_bill->charged;
1594 # just so we have a numeric sort key
1595 $monthorder{$date} ||= $cust_bill->_date;
1597 my @sorted_months = sort { $monthorder{$a} <=> $monthorder{$b} }
1599 my @sorted_amounts = map { sprintf('%.2f', $history{$_}) } @sorted_months;
1600 $invoice_data{monthly_history} = [ \@sorted_months, \@sorted_amounts ];
1603 # service locations: another option for template customization
1605 foreach my $item (@detail_items) {
1606 if ( $item->{locationnum} ) {
1607 $location_info{ $item->{locationnum} } ||= {
1608 FS::cust_location->by_key( $item->{locationnum} )->location_hash
1612 $invoice_data{location_info} = \%location_info;
1614 # debugging hook: call this with 'diag' => 1 to just get a hash of
1615 # the invoice variables
1616 return \%invoice_data if ( $params{'diag'} );
1618 # All sections and items are built; now fill in templates.
1619 my @includelist = ();
1620 push @includelist, 'summary' if $summarypage;
1621 foreach my $include ( @includelist ) {
1623 my $inc_file = $conf->key_orbase("invoice_${format}$include", $template);
1626 if ( length( $conf->config($inc_file, $agentnum) ) ) {
1628 @inc_src = $conf->config($inc_file, $agentnum);
1632 $inc_file = $conf->key_orbase("invoice_latex$include", $template);
1634 my $convert_map = $convert_maps{$format}{$include};
1636 @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
1637 s/--\@\]/$delimiters{$format}[1]/g;
1640 &$convert_map( $conf->config($inc_file, $agentnum) );
1644 my $inc_tt = new Text::Template (
1646 SOURCE => [ map "$_\n", @inc_src ],
1647 DELIMITERS => $delimiters{$format},
1648 ) or die "Can't create new Text::Template object: $Text::Template::ERROR";
1650 unless ( $inc_tt->compile() ) {
1651 my $error = "Can't compile $inc_file template: $Text::Template::ERROR\n";
1652 warn $error. "Template:\n". join('', map "$_\n", @inc_src);
1656 $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
1658 $invoice_data{$include} =~ s/\n+$//
1659 if ($format eq 'latex');
1664 foreach ( grep /invoice_lines\(\d*\)/, @invoice_template ) { #kludgy
1665 /invoice_lines\((\d*)\)/;
1666 $invoice_lines += $1 || scalar(@buf);
1669 die "no invoice_lines() functions in template?"
1670 if ( $format eq 'template' && !$wasfunc );
1672 if ( $invoice_lines ) {
1673 $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
1674 $invoice_data{'total_pages'}++
1675 if scalar(@buf) % $invoice_lines;
1678 #setup subroutine for the template
1679 $invoice_data{invoice_lines} = sub {
1680 my $lines = shift || scalar(@buf);
1689 if ($format eq 'template') {
1694 push @collect, split("\n",
1695 $text_template->fill_in( HASH => \%invoice_data )
1697 $invoice_data{'page'}++;
1699 map "$_\n", @collect;
1701 } else { # this is where we actually create the invoice
1703 if ( $params{no_addresses} ) {
1704 delete $invoice_data{$_} foreach qw(
1705 payname company address1 address2 city state zip country
1707 $invoice_data{returnaddress} = '~';
1710 warn "filling in template for invoice ". $self->invnum. "\n"
1712 warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
1715 $text_template->fill_in(HASH => \%invoice_data);
1719 sub notice_name { '('.shift->table.')'; }
1721 # this is not supposed to happen
1722 sub template_conf { warn "bare FS::Template_Mixin::template_conf";
1726 =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
1728 Returns an postscript invoice, as a scalar.
1730 Options can be passed as a hashref (recommended) or as a list of time, template
1731 and then any key/value pairs for any other options.
1733 I<time> an optional value used to control the printing of overdue messages. The
1734 default is now. It isn't the date of the invoice; that's the `_date' field.
1735 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1736 L<Time::Local> and L<Date::Parse> for conversion functions.
1738 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1745 my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1746 my $ps = generate_ps($file);
1748 unlink($barcodefile) if $barcodefile;
1753 =item print_pdf HASHREF | [ TIME [ , TEMPLATE ] ]
1755 Returns an PDF invoice, as a scalar.
1757 Options can be passed as a hashref (recommended) or as a list of time, template
1758 and then any key/value pairs for any other options.
1760 I<time> an optional value used to control the printing of overdue messages. The
1761 default is now. It isn't the date of the invoice; that's the `_date' field.
1762 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1763 L<Time::Local> and L<Date::Parse> for conversion functions.
1765 I<template>, if specified, is the name of a suffix for alternate invoices.
1767 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1774 my ($file, $logofile, $barcodefile) = $self->print_latex(@_);
1775 my $pdf = generate_pdf($file);
1777 unlink($barcodefile) if $barcodefile;
1782 =item print_html HASHREF | [ TIME [ , TEMPLATE [ , CID ] ] ]
1784 Returns an HTML invoice, as a scalar.
1786 I<time> an optional value used to control the printing of overdue messages. The
1787 default is now. It isn't the date of the invoice; that's the `_date' field.
1788 It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1789 L<Time::Local> and L<Date::Parse> for conversion functions.
1791 I<template>, if specified, is the name of a suffix for alternate invoices.
1793 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
1795 I<cid> is a MIME Content-ID used to create a "cid:" URL for the logo image, used
1796 when emailing the invoice as part of a multipart/related MIME email.
1804 %params = %{ shift() };
1808 $params{'format'} = 'html';
1810 $self->print_generic( %params );
1813 # quick subroutine for print_latex
1815 # There are ten characters that LaTeX treats as special characters, which
1816 # means that they do not simply typeset themselves:
1817 # # $ % & ~ _ ^ \ { }
1819 # TeX ignores blanks following an escaped character; if you want a blank (as
1820 # in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
1824 $value =~ s/([#\$%&~_\^{}])( )?/"\\$1". ( ( defined($2) && length($2) ) ? "\\$2" : '' )/ge;
1825 $value =~ s/([<>])/\$$1\$/g;
1831 encode_entities($value);
1835 sub _html_escape_nbsp {
1836 my $value = _html_escape(shift);
1837 $value =~ s/ +/ /g;
1841 #utility methods for print_*
1843 sub _translate_old_latex_format {
1844 warn "_translate_old_latex_format called\n"
1851 if ( $line =~ /^%%Detail\s*$/ ) {
1853 push @template, q![@--!,
1854 q! foreach my $_tr_line (@detail_items) {!,
1855 q! if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
1856 q! $_tr_line->{'description'} .= !,
1857 q! "\\tabularnewline\n~~".!,
1858 q! join( "\\tabularnewline\n~~",!,
1859 q! @{$_tr_line->{'ext_description'}}!,
1863 while ( ( my $line_item_line = shift )
1864 !~ /^%%EndDetail\s*$/ ) {
1865 $line_item_line =~ s/'/\\'/g; # nice LTS
1866 $line_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
1867 $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1868 push @template, " \$OUT .= '$line_item_line';";
1871 push @template, '}',
1874 } elsif ( $line =~ /^%%TotalDetails\s*$/ ) {
1876 push @template, '[@--',
1877 ' foreach my $_tr_line (@total_items) {';
1879 while ( ( my $total_item_line = shift )
1880 !~ /^%%EndTotalDetails\s*$/ ) {
1881 $total_item_line =~ s/'/\\'/g; # nice LTS
1882 $total_item_line =~ s/\\/\\\\/g; # escape quotes and backslashes
1883 $total_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g;
1884 push @template, " \$OUT .= '$total_item_line';";
1887 push @template, '}',
1891 $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
1892 push @template, $line;
1898 warn "$_\n" foreach @template;
1910 my $conf = $self->conf;
1912 #check for an invoice-specific override
1913 return $self->invoice_terms if $self->invoice_terms;
1915 #check for a customer- specific override
1916 my $cust_main = $self->cust_main;
1917 return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
1921 $agentnum = $cust_main->agentnum;
1922 } elsif ( my $prospect_main = $self->prospect_main ) {
1923 $agentnum = $prospect_main->agentnum;
1926 #use configured default
1927 $conf->config('invoice_default_terms', $agentnum) || '';
1937 if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
1938 $duedate = $self->_date() + ( $1 * 86400 );
1939 } elsif ( $self->terms =~ /^End of Month$/ ) {
1940 my ($mon,$year) = (localtime($self->_date) )[4,5];
1942 until ( $mon < 12 ) { $mon -= 12; $year++; }
1943 my $nextmonth_first = timelocal(0,0,0,1,$mon,$year);
1944 $duedate = $nextmonth_first - 86400;
1955 $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
1958 =item balance_due_msg
1962 sub balance_due_msg {
1964 my $msg = $self->mt('Balance Due');
1965 return $msg unless $self->terms; # huh?
1966 if ( !$self->conf->exists('invoice_show_prior_due_date')
1967 or $self->conf->exists('invoice_sections') ) {
1968 # if enabled, the due date is shown with Total New Charges (see
1969 # _items_total) and not here
1970 # (yes, or if invoice_sections is enabled; this is just for compatibility)
1971 if ( $self->due_date ) {
1973 $self->conf->config('invoice_pay_by_msg', $self->agentnum)
1974 || 'Please pay by [_1]';
1975 $msg .= ' - ' . $self->mt($please_pay_by, $self->due_date2str('short')).
1977 unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum);
1978 } elsif ( $self->terms ) {
1979 $msg .= ' - '. $self->mt($self->terms);
1985 =item balance_due_date
1989 sub balance_due_date {
1991 my $conf = $self->conf;
1993 my $terms = $self->terms;
1994 if ( $terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
1995 $duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
2000 sub credit_balance_msg {
2002 $self->mt('Credit Balance Remaining')
2007 Returns a string with the date, for example: "3/20/2008", localized for the
2008 customer. Use _date_pretty_unlocalized for non-end-customer display use.
2014 $self->time2str_local('short', $self->_date);
2017 =item _date_pretty_unlocalized
2019 Returns a string with the date, for example: "3/20/2008", in the format
2020 configured for the back-office. Use _date_pretty for end-customer display use.
2024 sub _date_pretty_unlocalized {
2026 time2str($date_format, $self->_date);
2031 Emails this template.
2033 Options are passed as a hashref. Available options:
2039 If specified, overrides the default From: address.
2043 If specified, overrides the name of the sent document ("Invoice" or "Quotation")
2047 (Deprecated) If specified, is the name of a suffix for alternate template files.
2051 Options accepted by generate_email can also be used.
2057 my $opt = shift || {};
2058 if ($opt and !ref($opt)) {
2059 die ref($self). '->email called with positional parameters';
2062 return if $self->hide;
2064 my $error = send_email(
2065 $self->generate_email(
2066 'subject' => $self->email_subject($opt->{template}),
2067 %$opt, # template, etc.
2071 die "can't email: $error\n" if $error;
2074 =item generate_email OPTION => VALUE ...
2082 sender address, required
2086 alternate template name, optional
2090 email subject, optional
2094 notice name instead of "Invoice", optional
2098 Returns an argument list to be passed to L<FS::Misc::send_email>.
2105 sub generate_email {
2109 my $conf = $self->conf;
2111 my $me = '[FS::Template_Mixin::generate_email]';
2114 'from' => $args{'from'},
2115 'subject' => ($args{'subject'} || $self->email_subject),
2116 'custnum' => $self->custnum,
2117 'msgtype' => 'invoice',
2120 $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
2122 my $cust_main = $self->cust_main;
2124 if (ref($args{'to'}) eq 'ARRAY') {
2125 $return{'to'} = $args{'to'};
2126 } elsif ( $cust_main ) {
2127 $return{'to'} = [ $cust_main->invoicing_list_emailonly ];
2130 my $tc = $self->template_conf;
2132 my @text; # array of lines
2133 my $html; # a big string
2134 my @related_parts; # will contain the text/HTML alternative, and images
2135 my $related; # will contain the multipart/related object
2137 if ( $conf->exists($tc. 'email_pdf') ) {
2138 if ( my $msgnum = $conf->config($tc.'email_pdf_msgnum') ) {
2140 warn "$me using '${tc}email_pdf_msgnum' in multipart message"
2143 my $msg_template = FS::msg_template->by_key($msgnum)
2144 or die "${tc}email_pdf_msgnum $msgnum not found\n";
2145 my $cust_msg = $msg_template->prepare(
2146 cust_main => $self->cust_main,
2148 msgtype => 'invoice',
2151 # XXX hack to make this work in the new cust_msg era; consider replacing
2152 # with cust_bill_send_with_notice events.
2153 my @parts = $cust_msg->parts;
2154 foreach my $part (@parts) { # will only have two parts, normally
2155 if ( $part->mime_type eq 'text/plain' ) {
2156 @text = @{ $part->body };
2157 } elsif ( $part->mime_type eq 'text/html' ) {
2158 $html = $part->bodyhandle->as_string;
2162 } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) {
2164 warn "$me using '${tc}email_pdf_note' in multipart message"
2166 @text = $conf->config($tc.'email_pdf_note');
2167 $html = join('<BR>', @text);
2169 } # else use the plain text invoice
2174 if ( $conf->config($tc.'template') ) {
2176 warn "$me generating plain text invoice"
2179 # 'print_text' argument is no longer used
2180 @text = map Encode::encode_utf8($_), $self->print_text(\%args);
2184 warn "$me no plain text version exists; sending empty message body"
2191 my $text_part = build MIME::Entity (
2192 'Type' => 'text/plain',
2193 'Encoding' => 'quoted-printable',
2194 'Charset' => 'UTF-8',
2195 #'Encoding' => '7bit',
2197 'Disposition' => 'inline',
2202 if ( $conf->exists($tc.'html') ) {
2203 warn "$me generating HTML invoice"
2206 $args{'from'} =~ /\@([\w\.\-]+)/;
2207 my $from = $1 || 'example.com';
2208 my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
2211 my $agentnum = $cust_main ? $cust_main->agentnum
2212 : $self->prospect_main->agentnum;
2213 if ( defined($args{'template'}) && length($args{'template'})
2214 && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
2217 $logo = 'logo_'. $args{'template'}. '.png';
2221 my $image_data = $conf->config_binary( $logo, $agentnum);
2223 push @related_parts, build MIME::Entity
2224 'Type' => 'image/png',
2225 'Encoding' => 'base64',
2226 'Data' => $image_data,
2227 'Filename' => 'logo.png',
2228 'Content-ID' => "<$content_id>",
2231 if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) {
2232 my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
2233 push @related_parts, build MIME::Entity
2234 'Type' => 'image/png',
2235 'Encoding' => 'base64',
2236 'Data' => $self->invoice_barcode(0),
2237 'Filename' => 'barcode.png',
2238 'Content-ID' => "<$barcode_content_id>",
2240 $args{'barcode_cid'} = $barcode_content_id;
2243 $html = $self->print_html({ 'cid'=>$content_id, %args });
2250 warn "$me creating HTML/text multipart message"
2253 $return{'nobody'} = 1;
2255 my $alternative = build MIME::Entity
2256 'Type' => 'multipart/alternative',
2257 #'Encoding' => '7bit',
2258 'Disposition' => 'inline'
2262 $alternative->add_part($text_part);
2265 $alternative->attach(
2266 'Type' => 'text/html',
2267 'Encoding' => 'quoted-printable',
2268 'Data' => [ '<html>',
2271 ' '. encode_entities($return{'subject'}),
2274 ' <body bgcolor="#e8e8e8">',
2275 Encode::encode_utf8($html),
2279 'Disposition' => 'inline',
2280 #'Filename' => 'invoice.pdf',
2283 unshift @related_parts, $alternative;
2285 $related = build MIME::Entity 'Type' => 'multipart/related',
2286 'Encoding' => '7bit';
2288 #false laziness w/Misc::send_email
2289 $related->head->replace('Content-type',
2290 $related->mime_type.
2291 '; boundary="'. $related->head->multipart_boundary. '"'.
2292 '; type=multipart/alternative'
2295 $related->add_part($_) foreach @related_parts;
2299 my @otherparts = ();
2300 if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
2302 if ( $conf->config('voip-cdr_email_attach') eq 'zip' ) {
2304 my $data = join('', map "$_\n",
2305 $self->call_details(prepend_billed_number=>1)
2308 my $zip = new Archive::Zip;
2309 my $file = $zip->addString( $data, 'usage-'.$self->invnum.'.csv' );
2310 $file->desiredCompressionMethod( COMPRESSION_DEFLATED );
2313 my $SH = IO::Scalar->new(\$zipdata);
2314 my $status = $zip->writeToFileHandle($SH);
2315 die "Error zipping CDR attachment: $!" unless $status == AZ_OK;
2317 push @otherparts, build MIME::Entity
2318 'Type' => 'application/zip',
2319 'Encoding' => 'base64',
2321 'Disposition' => 'attachment',
2322 'Filename' => 'usage-'. $self->invnum. '.zip',
2325 } else { # } elsif ( $conf->config('voip-cdr_email_attach') eq 'csv' ) {
2327 push @otherparts, build MIME::Entity
2328 'Type' => 'text/csv',
2329 'Encoding' => '7bit',
2330 'Data' => [ map { "$_\n" }
2331 $self->call_details('prepend_billed_number' => 1)
2333 'Disposition' => 'attachment',
2334 'Filename' => 'usage-'. $self->invnum. '.csv',
2341 if ( $conf->exists($tc.'email_pdf') ) {
2346 # multipart/alternative
2352 my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
2353 push @otherparts, $pdf;
2357 $return{'content-type'} = 'multipart/mixed'; # of the outer container
2359 $return{'mimeparts'} = [ $related, @otherparts ];
2360 $return{'type'} = 'multipart/related'; # of the first part
2362 $return{'mimeparts'} = [ $text_part, @otherparts ];
2363 $return{'type'} = 'text/plain';
2365 } elsif ( $html ) { # no PDF or CSV, strip the outer container
2366 $return{'mimeparts'} = \@related_parts;
2367 $return{'content-type'} = 'multipart/related';
2368 $return{'type'} = 'multipart/alternative';
2369 } else { # no HTML either
2370 $return{'body'} = \@text;
2371 $return{'content-type'} = 'text/plain';
2380 Returns a list suitable for passing to MIME::Entity->build(), representing
2381 this quotation or invoice as PDF attachment.
2388 'Type' => 'application/pdf',
2389 'Encoding' => 'base64',
2390 'Data' => [ $self->print_pdf(@_) ],
2391 'Disposition' => 'attachment',
2392 'Filename' => $self->pdf_filename,
2396 =item postal_mail_fsinc
2398 Sends this invoice to the Freeside Internet Services, Inc. print and mail
2404 use IO::Socket::SSL;
2406 use HTTP::Request::Common qw( POST );
2407 use Cpanel::JSON::XS;
2409 sub postal_mail_fsinc {
2410 my ( $self, %opt ) = @_;
2412 my $url = 'https://ws.freeside.biz/print';
2414 my $cust_main = $self->cust_main;
2415 my $agentnum = $cust_main->agentnum;
2416 my $bill_location = $cust_main->bill_location;
2418 die "Extra charges for international mailing; contact support\@freeside.biz to enable\n"
2419 if $bill_location->country ne 'US';
2421 my $conf = new FS::Conf;
2423 my @company_address = $conf->config('company_address', $agentnum);
2424 my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip );
2425 if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
2426 $company_address1 = $company_address[0];
2427 $company_address2 = $company_address[1];
2429 $company_state = $2;
2431 } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
2432 $company_address1 = $company_address[0];
2433 $company_address2 = '';
2435 $company_state = $2;
2438 die "Unparsable company_address; contact support\@freeside.biz\n";
2440 $company_city =~ s/,$//;
2442 my $file = $self->print_pdf(%opt, 'no_addresses' => 1);
2443 my $pages = CAM::PDF->new($file)->numPages;
2445 my $ua = LWP::UserAgent->new(
2447 verify_hostname => 0,
2448 SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
2449 SSL_version => 'SSLv3',
2452 my $response = $ua->request( POST $url, [
2453 'support-key' => scalar($conf->config('support-key')),
2454 'file' => encode_base64($file),
2458 'company_name' => scalar( $conf->config('company_name', $agentnum) ),
2459 'company_address1' => $company_address1,
2460 'company_address2' => $company_address2,
2461 'company_city' => $company_city,
2462 'company_state' => $company_state,
2463 'company_zip' => $company_zip,
2464 'company_country' => 'US',
2465 'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)),
2466 'company_email' => scalar($conf->config('invoice_from', $agentnum)),
2469 'name' => $cust_main->invoice_attn
2470 || $cust_main->contact_firstlast,
2471 'company' => $cust_main->company,
2472 'address1' => $bill_location->address1,
2473 'address2' => $bill_location->address2,
2474 'city' => $bill_location->city,
2475 'state' => $bill_location->state,
2476 'zip' => $bill_location->zip,
2477 'country' => $bill_location->country,
2480 die "Print connection error: ". $response->message.
2481 ' ('. $response->as_string. ")\n"
2482 unless $response->is_success;
2485 my $content = eval { decode_json($response->content) };
2486 die "Print JSON error : $@\n" if $@;
2488 die $content->{error}."\n"
2489 if $content->{error};
2491 #TODO: store this so we can query for a status later
2492 warn "Invoice printed, ID ". $content->{id}. "\n";
2498 =item _items_sections OPTIONS
2500 Generate section information for all items appearing on this invoice.
2501 This will only be called for multi-section invoices.
2503 For each line item (L<FS::cust_bill_pkg> record), this will fetch all
2504 related display records (L<FS::cust_bill_pkg_display>) and organize
2505 them into two groups ("early" and "late" according to whether they come
2506 before or after the total), then into sections. A subtotal is calculated
2509 Section descriptions are returned in sort weight order. Each consists
2510 of a hash containing:
2512 description: the package category name, escaped
2513 subtotal: the total charges in that section
2514 tax_section: a flag indicating that the section contains only tax charges
2515 summarized: same as tax_section, for some reason
2516 sort_weight: the package category's sort weight
2518 If 'condense' is set on the display record, it also contains everything
2519 returned from C<_condense_section()>, i.e. C<_condensed_foo_generator>
2520 coderefs to generate parts of the invoice. This is not advised.
2522 The method returns two arrayrefs, one of "early" sections and one of "late"
2525 OPTIONS may include:
2527 by_location: a flag to divide the invoice into sections by location.
2528 Each section hash will have a 'location' element containing a hashref of
2529 the location fields (see L<FS::cust_location>). The section description
2530 will be the location label, but the template can use any of the location
2531 fields to create a suitable label.
2533 by_category: a flag to divide the invoice into sections using display
2534 records (see L<FS::cust_bill_pkg_display>). This is the "traditional"
2535 behavior. Each section hash will have a 'category' element containing
2536 the section name from the display record (which probably equals the
2537 category name of the package, but may not in some cases).
2539 summary: a flag indicating that this is a summary-format invoice.
2540 Turning this on has the following effects:
2541 - Ignores display items with the 'summary' flag.
2542 - Places all sections in the "early" group even if they have post_total.
2543 - Creates sections for all non-disabled package categories, even if they
2544 have no charges on this invoice, as well as a section with no name.
2546 escape: an escape function to use for section titles.
2548 extra_sections: an arrayref of additional sections to return after the
2549 sorted list. If there are any of these, section subtotals exclude
2552 format: 'latex', 'html', or 'template' (i.e. text). Not used, but
2553 passed through to C<_condense_section()>.
2557 use vars qw(%pkg_category_cache);
2558 sub _items_sections {
2562 my $escape = $opt{escape};
2563 my @extra_sections = @{ $opt{extra_sections} || [] };
2565 # $subtotal{$locationnum}{$categoryname} = amount.
2566 # if we're not using by_location, $locationnum is undef.
2567 # if we're not using by_category, you guessed it, $categoryname is undef.
2568 # if we're not using either one, we shouldn't be here in the first place...
2570 my %late_subtotal = ();
2573 # About tax items + multisection invoices:
2574 # If either invoice_*summary option is enabled, AND there is a
2575 # package category with the name of the tax, then there will be
2576 # a display record assigning the tax item to that category.
2578 # However, the taxes are always placed in the "Taxes, Surcharges,
2579 # and Fees" section regardless of that. The only effect of the
2580 # display record is to create a subtotal for the summary page.
2583 my $pkg_hash = $self->cust_pkg_hash;
2585 foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
2588 my $usage = $cust_bill_pkg->usage;
2591 if ( $opt{by_location} ) {
2592 if ( $cust_bill_pkg->pkgnum ) {
2593 $locationnum = $pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum;
2598 $locationnum = undef;
2601 # as in _items_cust_pkg, if a line item has no display records,
2602 # cust_bill_pkg_display() returns a default record for it
2604 foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
2605 next if ( $display->summary && $opt{summary} );
2607 my $section = $display->section;
2608 my $type = $display->type;
2609 # Set $section = undef if we're sectioning by location and this
2610 # line item _has_ a location (i.e. isn't a fee).
2611 $section = undef if $locationnum;
2613 # set this flag if the section is not tax-only
2614 $not_tax{$locationnum}{$section} = 1
2615 if $cust_bill_pkg->pkgnum or $cust_bill_pkg->feepart;
2617 # there's actually a very important piece of logic buried in here:
2618 # incrementing $late_subtotal{$section} CREATES
2619 # $late_subtotal{$section}. keys(%late_subtotal) is later used
2620 # to define the list of late sections, and likewise keys(%subtotal).
2621 # When _items_cust_bill_pkg is called to generate line items for
2622 # real, it will be called with 'section' => $section for each
2624 if ( $display->post_total && !$opt{summary} ) {
2625 if (! $type || $type eq 'S') {
2626 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
2627 if $cust_bill_pkg->setup != 0
2628 || $cust_bill_pkg->setup_show_zero;
2632 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
2633 if $cust_bill_pkg->recur != 0
2634 || $cust_bill_pkg->recur_show_zero;
2637 if ($type && $type eq 'R') {
2638 $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
2639 if $cust_bill_pkg->recur != 0
2640 || $cust_bill_pkg->recur_show_zero;
2643 if ($type && $type eq 'U') {
2644 $late_subtotal{$locationnum}{$section} += $usage
2645 unless scalar(@extra_sections);
2648 } else { # it's a pre-total (normal) section
2650 # skip tax items unless they're explicitly included in a section
2651 next if $cust_bill_pkg->pkgnum == 0 and
2652 ! $cust_bill_pkg->feepart and
2655 if ( $type eq 'S' ) {
2656 $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
2657 if $cust_bill_pkg->setup != 0
2658 || $cust_bill_pkg->setup_show_zero;
2659 } elsif ( $type eq 'R' ) {
2660 $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
2661 if $cust_bill_pkg->recur != 0
2662 || $cust_bill_pkg->recur_show_zero;
2663 } elsif ( $type eq 'U' ) {
2664 $subtotal{$locationnum}{$section} += $usage
2665 unless scalar(@extra_sections);
2666 } elsif ( !$type ) {
2667 $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
2668 + $cust_bill_pkg->recur;
2677 %pkg_category_cache = ();
2679 # summary invoices need subtotals for all non-disabled package categories,
2680 # even if they're zero
2681 # but currently assume that there are no location sections, or at least
2682 # that the summary page doesn't care about them
2683 if ( $opt{summary} ) {
2684 foreach my $category (qsearch('pkg_category', {disabled => ''})) {
2685 $subtotal{''}{$category->categoryname} ||= 0;
2687 $subtotal{''}{''} ||= 0;
2691 foreach my $post_total (0,1) {
2693 my $s = $post_total ? \%late_subtotal : \%subtotal;
2694 foreach my $locationnum (keys %$s) {
2695 foreach my $sectionname (keys %{ $s->{$locationnum} }) {
2697 'subtotal' => $s->{$locationnum}{$sectionname},
2700 if ( $locationnum ) {
2701 $section->{'locationnum'} = $locationnum;
2702 my $location = FS::cust_location->by_key($locationnum);
2703 $section->{'description'} = &{ $escape }($location->location_label);
2704 # Better ideas? This will roughly group them by proximity,
2705 # which alpha sorting on any of the address fields won't.
2706 # Sorting by locationnum is meaningless.
2707 # We have to sort on _something_ or the order may change
2708 # randomly from one invoice to the next, which will confuse
2710 $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
2712 $section->{'location'} = {
2713 label_prefix => &{ $escape }($location->label_prefix),
2714 map { $_ => &{ $escape }($location->get($_)) }
2718 $section->{'category'} = $sectionname;
2719 $section->{'description'} = &{ $escape }($sectionname);
2720 if ( _pkg_category($sectionname) ) {
2721 $section->{'sort_weight'} = _pkg_category($sectionname)->weight;
2722 if ( _pkg_category($sectionname)->condense ) {
2723 $section = { %$section, $self->_condense_section($opt{format}) };
2727 if ( !$post_total and !$not_tax{$locationnum}{$sectionname} ) {
2728 # then it's a tax-only section
2729 $section->{'summarized'} = 'Y';
2730 $section->{'tax_section'} = 'Y';
2732 push @these, $section;
2733 } # foreach $sectionname
2734 } #foreach $locationnum
2735 push @these, @extra_sections if $post_total == 0;
2736 # need an alpha sort for location sections, because postal codes can
2738 $sections[ $post_total ] = [ sort {
2739 $opt{'by_location'} ?
2740 ($a->{sort_weight} cmp $b->{sort_weight}) :
2741 ($a->{sort_weight} <=> $b->{sort_weight})
2743 } #foreach $post_total
2745 return @sections; # early, late
2748 #helper subs for above
2752 $self->{cust_pkg} ||= { map { $_->pkgnum => $_ } $self->cust_pkg };
2756 my $categoryname = shift;
2757 $pkg_category_cache{$categoryname} ||=
2758 qsearchs( 'pkg_category', { 'categoryname' => $categoryname } );
2761 my %condensed_format = (
2762 'label' => [ qw( Description Qty Amount ) ],
2764 sub { shift->{description} },
2765 sub { shift->{quantity} },
2766 sub { my($href, %opt) = @_;
2767 ($opt{dollar} || ''). $href->{amount};
2770 'align' => [ qw( l r r ) ],
2771 'span' => [ qw( 5 1 1 ) ], # unitprices?
2772 'width' => [ qw( 10.7cm 1.4cm 1.6cm ) ], # don't like this
2775 sub _condense_section {
2776 my ( $self, $format ) = ( shift, shift );
2778 map { my $method = "_condensed_$_"; $_ => $self->$method($format) }
2779 qw( description_generator
2782 total_line_generator
2787 sub _condensed_generator_defaults {
2788 my ( $self, $format ) = ( shift, shift );
2789 return ( \%condensed_format, ' ', ' ', ' ', sub { shift } );
2798 sub _condensed_header_generator {
2799 my ( $self, $format ) = ( shift, shift );
2801 my ( $f, $prefix, $suffix, $separator, $column ) =
2802 _condensed_generator_defaults($format);
2804 if ($format eq 'latex') {
2805 $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n";
2806 $suffix = "\\\\\n\\hline";
2809 sub { my ($d,$a,$s,$w) = @_;
2810 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2812 } elsif ( $format eq 'html' ) {
2813 $prefix = '<th></th>';
2817 sub { my ($d,$a,$s,$w) = @_;
2818 return qq!<th align="$html_align{$a}">$d</th>!;
2826 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2828 &{$column}( map { $f->{$_}->[$i] } qw(label align span width) );
2831 $prefix. join($separator, @result). $suffix;
2836 sub _condensed_description_generator {
2837 my ( $self, $format ) = ( shift, shift );
2839 my ( $f, $prefix, $suffix, $separator, $column ) =
2840 _condensed_generator_defaults($format);
2842 my $money_char = '$';
2843 if ($format eq 'latex') {
2844 $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n";
2846 $separator = " & \n";
2848 sub { my ($d,$a,$s,$w) = @_;
2849 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}";
2851 $money_char = '\\dollar';
2852 }elsif ( $format eq 'html' ) {
2853 $prefix = '"><td align="center"></td>';
2857 sub { my ($d,$a,$s,$w) = @_;
2858 return qq!<td align="$html_align{$a}">$d</td>!;
2860 #$money_char = $conf->config('money_char') || '$';
2861 $money_char = ''; # this is madness
2869 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2871 $dollar = $money_char if $i == scalar(@{$f->{label}})-1;
2873 &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar),
2874 map { $f->{$_}->[$i] } qw(align span width)
2878 $prefix. join( $separator, @result ). $suffix;
2883 sub _condensed_total_generator {
2884 my ( $self, $format ) = ( shift, shift );
2886 my ( $f, $prefix, $suffix, $separator, $column ) =
2887 _condensed_generator_defaults($format);
2890 if ($format eq 'latex') {
2893 $separator = " & \n";
2895 sub { my ($d,$a,$s,$w) = @_;
2896 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2898 }elsif ( $format eq 'html' ) {
2902 $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2904 sub { my ($d,$a,$s,$w) = @_;
2905 return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2914 # my $r = &{$f->{fields}->[$i]}(@args);
2915 # $r .= ' Total' unless $i;
2917 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2919 &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'),
2920 map { $f->{$_}->[$i] } qw(align span width)
2924 $prefix. join( $separator, @result ). $suffix;
2929 =item total_line_generator FORMAT
2931 Returns a coderef used for generation of invoice total line items for this
2932 usage_class. FORMAT is either html or latex
2936 # should not be used: will have issues with hash element names (description vs
2937 # total_item and amount vs total_amount -- another array of functions?
2939 sub _condensed_total_line_generator {
2940 my ( $self, $format ) = ( shift, shift );
2942 my ( $f, $prefix, $suffix, $separator, $column ) =
2943 _condensed_generator_defaults($format);
2946 if ($format eq 'latex') {
2949 $separator = " & \n";
2951 sub { my ($d,$a,$s,$w) = @_;
2952 return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}";
2954 }elsif ( $format eq 'html' ) {
2958 $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;';
2960 sub { my ($d,$a,$s,$w) = @_;
2961 return qq!<td align="$html_align{$a}" style="$style">$d</td>!;
2970 foreach (my $i = 0; $f->{label}->[$i]; $i++) {
2972 &{$column}( &{$f->{fields}->[$i]}(@args),
2973 map { $f->{$_}->[$i] } qw(align span width)
2977 $prefix. join( $separator, @result ). $suffix;
2982 =item _items_pkg [ OPTIONS ]
2984 Return line item hashes for each package item on this invoice. Nearly
2987 $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ])
2989 OPTIONS are passed through to _items_cust_bill_pkg, and should include
2990 'format' and 'escape_function' at minimum.
2992 To produce items for a specific invoice section, OPTIONS should include
2993 'section', a hashref containing 'category' and/or 'locationnum' keys.
2995 'section' may also contain a key named 'condensed'. If this is present
2996 and has a true value, _items_pkg will try to merge identical items into items
2997 with 'quantity' equal to the number of items (not the sum of their separate
2998 quantities, for some reason).
3004 # The order of these is important. Bundled line items will be merged into
3005 # the most recent non-hidden item, so it needs to be the one with:
3007 # - the same start date
3008 # - no pkgpart_override
3010 # So: sort by pkgnum,
3012 # then sort the base line item before any overrides
3013 # then sort hidden before non-hidden add-ons
3014 # then sort by override pkgpart (for consistency)
3015 sort { $a->pkgnum <=> $b->pkgnum or
3016 $a->sdate <=> $b->sdate or
3017 ($a->pkgpart_override ? 0 : -1) or
3018 ($b->pkgpart_override ? 0 : 1) or
3019 $b->hidden cmp $a->hidden or
3020 $a->pkgpart_override <=> $b->pkgpart_override
3022 # and of course exclude taxes and fees
3023 grep { $_->pkgnum > 0 } $self->cust_bill_pkg;
3029 my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
3030 my $escape_function = $options{escape_function};
3033 foreach my $cust_bill_pkg (@cust_bill_pkg) {
3034 # cache this, so we don't look it up again in every section
3035 my $part_fee = $cust_bill_pkg->get('part_fee')
3036 || $cust_bill_pkg->part_fee;
3037 $cust_bill_pkg->set('part_fee', $part_fee);
3039 #die "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; # might make more sense
3040 warn "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n";
3043 if ( exists($options{section}) and exists($options{section}{category}) )
3045 my $categoryname = $options{section}{category};
3046 # then filter for items that have that section
3047 if ( $part_fee->categoryname ne $categoryname ) {
3048 warn "skipping fee '".$part_fee->itemdesc."'--not in section $categoryname\n" if $DEBUG;
3051 } # otherwise include them all in the main section
3052 # XXX what to do when sectioning by location?
3055 my %base_invnums; # invnum => invoice date
3056 foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
3057 if ($_->base_invnum) {
3058 my $base_bill = FS::cust_bill->by_key($_->base_invnum);
3059 my $base_date = $self->time2str_local('short', $base_bill->_date)
3061 $base_invnums{$_->base_invnum} = $base_date || '';
3064 foreach (sort keys(%base_invnums)) {
3065 next if $_ == $self->invnum;
3066 # per convention, we must escape ext_description lines
3068 &{$escape_function}(
3069 $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
3072 my $desc = $part_fee->itemdesc_locale($self->cust_main->locale);
3073 # but not escape the base description line
3076 { feepart => $cust_bill_pkg->feepart,
3077 amount => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
3078 description => $desc,
3079 ext_description => \@ext_desc
3090 warn "$me _items_pkg searching for all package line items\n"
3093 my @cust_bill_pkg = $self->_items_nontax;
3095 warn "$me _items_pkg filtering line items\n"
3097 my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
3099 if ($options{section} && $options{section}->{condensed}) {
3101 warn "$me _items_pkg condensing section\n"
3105 local $Storable::canonical = 1;
3106 foreach ( @items ) {
3108 delete $item->{ref};
3109 delete $item->{ext_description};
3110 my $key = freeze($item);
3111 $itemshash{$key} ||= 0;
3112 $itemshash{$key} ++; # += $item->{quantity};
3114 @items = sort { $a->{description} cmp $b->{description} }
3115 map { my $i = thaw($_);
3116 $i->{quantity} = $itemshash{$_};
3118 sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount
3124 warn "$me _items_pkg returning ". scalar(@items). " items\n"
3131 return 0 unless $a->itemdesc cmp $b->itemdesc;
3132 return -1 if $b->itemdesc eq 'Tax';
3133 return 1 if $a->itemdesc eq 'Tax';
3134 return -1 if $b->itemdesc eq 'Other surcharges';
3135 return 1 if $a->itemdesc eq 'Other surcharges';
3136 $a->itemdesc cmp $b->itemdesc;
3141 my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart }
3142 $self->cust_bill_pkg;
3143 my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
3145 if ( $self->conf->exists('always_show_tax') ) {
3146 my $itemdesc = $self->conf->config('always_show_tax') || 'Tax';
3147 if (0 == grep { $_->{description} eq $itemdesc } @items) {
3149 { 'description' => $itemdesc,
3156 =item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
3158 Takes an arrayref of L<FS::cust_bill_pkg> objects, and returns a
3159 list of hashrefs describing the line items they generate on the invoice.
3161 OPTIONS may include:
3163 format: the invoice format.
3165 escape_function: the function used to escape strings.
3167 DEPRECATED? (expensive, mostly unused?)
3168 format_function: the function used to format CDRs.
3170 section: a hashref containing 'category' and/or 'locationnum'; if this
3171 is present, only returns line items that belong to that category and/or
3172 location (whichever is defined).
3174 multisection: a flag indicating that this is a multisection invoice,
3175 which does something complicated.
3177 Returns a list of hashrefs, each of which may contain:
3179 pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and
3180 ext_description, which is an arrayref of detail lines to show below
3185 sub _items_cust_bill_pkg {
3187 my $conf = $self->conf;
3188 my $cust_bill_pkgs = shift;
3191 my $format = $opt{format} || '';
3192 my $escape_function = $opt{escape_function} || sub { shift };
3193 my $format_function = $opt{format_function} || '';
3194 my $no_usage = $opt{no_usage} || '';
3195 my $unsquelched = $opt{unsquelched} || ''; #unused
3196 my ($section, $locationnum, $category);
3197 if ( $opt{section} ) {
3198 $category = $opt{section}->{category};
3199 $locationnum = $opt{section}->{locationnum};
3201 my $summary_page = $opt{summary_page} || ''; #unused
3202 my $multisection = defined($category) || defined($locationnum);
3203 # this variable is the value of the config setting, not whether it applies
3204 # to this particular line item.
3205 my $discount_show_always = $conf->exists('discount-show-always');
3207 my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
3209 my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
3211 # for location labels: use default location on the invoice date
3212 my $default_locationnum;
3213 if ( $conf->exists('invoice-all_pkg_addresses') ) {
3214 $default_locationnum = 0; # treat them all as non-default
3215 } elsif ( $self->custnum ) {
3217 my @h_search = FS::h_cust_main->sql_h_search($self->_date);
3218 $h_cust_main = qsearchs({
3219 'table' => 'h_cust_main',
3220 'hashref' => { custnum => $self->custnum },
3221 'extra_sql' => $h_search[1],
3222 'addl_from' => $h_search[3],
3224 $default_locationnum = $h_cust_main->ship_locationnum;
3225 } elsif ( $self->prospectnum ) {
3226 my $cust_location = qsearchs('cust_location',
3227 { prospectnum => $self->prospectnum,
3229 $default_locationnum = $cust_location->locationnum if $cust_location;
3232 my @b = (); # accumulator for the line item hashes that we'll return
3233 my ($s, $r, $u, $d) = ( undef, undef, undef, undef );
3234 # the 'current' line item hashes for setup, recur, usage, discount
3235 foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
3237 # if the current line item is waiting to go out, and the one we're about
3238 # to start is not bundled, then push out the current one and start a new
3241 $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
3243 foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
3244 if ( $_ && !$cust_bill_pkg->hidden ) {
3245 $_->{amount} = sprintf( "%.2f", $_->{amount} );
3246 $_->{amount} =~ s/^\-0\.00$/0.00/;
3247 if (exists($_->{unit_amount})) {
3248 $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
3251 # we already decided to create this display line; don't reconsider it
3253 # if $_->{amount} != 0
3254 # || $discount_show_always
3255 # || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
3256 # || ( $_->{_is_setup} && $_->{setup_show_zero} )
3262 if ( $locationnum ) {
3263 # this is a location section; skip packages that aren't at this
3265 next if $cust_bill_pkg->pkgnum == 0; # skips fees...
3266 next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum
3270 # Consider display records for this item to determine if it belongs
3271 # in this section. Note that if there are no display records, there
3272 # will be a default pseudo-record that includes all charge types
3273 # and has no section name.
3274 my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
3275 ? $cust_bill_pkg->cust_bill_pkg_display
3276 : ( $cust_bill_pkg );
3278 warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
3279 $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
3282 if ( defined($category) ) {
3283 # then this is a package category section; process all display records
3284 # that belong to this section.
3285 @cust_bill_pkg_display = grep { $_->section eq $category }
3286 @cust_bill_pkg_display;
3288 # otherwise, process all display records that aren't usage summaries
3289 # (I don't think there should be usage summaries if you aren't using
3290 # category sections, but this is the historical behavior)
3291 @cust_bill_pkg_display = grep { !$_->summary }
3292 @cust_bill_pkg_display;
3295 my $classname = ''; # package class name, will fill in later
3297 foreach my $display (@cust_bill_pkg_display) {
3299 warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
3300 $display->billpkgdisplaynum. "\n"
3303 my $type = $display->type;
3305 my $desc = $cust_bill_pkg->desc( $cust_main ? $cust_main->locale : '' );
3306 $desc = substr($desc, 0, $maxlength). '...'
3307 if $format eq 'latex' && length($desc) > $maxlength;
3309 my %details_opt = ( 'format' => $format,
3310 'escape_function' => $escape_function,
3311 'format_function' => $format_function,
3312 'no_usage' => $opt{'no_usage'},
3315 if ( $cust_bill_pkg->pkgnum > 0 ) {
3316 # a "normal" package line item (not a quotation, not a fee, not a tax)
3318 warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
3321 my $cust_pkg = $cust_bill_pkg->cust_pkg;
3322 my $part_pkg = $cust_pkg->part_pkg;
3324 # which pkgpart to show for display purposes?
3325 my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
3327 # start/end dates for invoice formats that do nonstandard
3329 my %item_dates = ();
3330 %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
3331 unless $part_pkg->option('disable_line_item_date_ranges',1);
3333 # not normally used, but pass this to the template anyway
3334 $classname = $part_pkg->classname;
3336 if ( (!$type || $type eq 'S')
3337 && ( $cust_bill_pkg->setup != 0
3338 || $cust_bill_pkg->setup_show_zero
3339 || ($discount_show_always and $cust_bill_pkg->unitsetup > 0)
3344 warn "$me _items_cust_bill_pkg adding setup\n"
3347 # append the word 'Setup' to the setup line if there's going to be
3348 # a recur line for the same package (i.e. not a one-time charge)
3350 my $description = $desc;
3351 $description .= ' Setup'
3352 if $cust_bill_pkg->recur != 0
3353 || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
3354 || $cust_bill_pkg->recur_show_zero;
3356 $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
3358 if $part_pkg->is_prepaid #for prepaid, "display the validity period
3359 # triggered by the recurring charge freq
3361 && $cust_bill_pkg->recur == 0
3362 && ! $cust_bill_pkg->recur_show_zero;
3365 my @svc_labels = ();
3368 unless ( $part_pkg->hide_svc_detail ) {
3370 # still pass the svc_label through to the template, even if
3371 # not displaying it as an ext_description
3372 @svc_labels = map &{$escape_function}($_),
3373 $cust_pkg->h_labels_short($self->_date,
3376 $self->conf->{locale},
3378 $svc_label = $svc_labels[0];
3382 unless ( $part_pkg->hide_svc_detail
3383 || $cust_bill_pkg->hidden )
3386 push @d, @svc_labels
3387 unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
3388 # show the location label if it's not the customer's default
3389 # location, and we're not grouping items by location already
3390 if ( $cust_pkg->locationnum != $default_locationnum
3391 and !defined($locationnum) ) {
3392 my $loc = $cust_pkg->location_label;
3393 $loc = substr($loc, 0, $maxlength). '...'
3394 if $format eq 'latex' && length($loc) > $maxlength;
3395 push @d, &{$escape_function}($loc);
3398 } #unless hiding service details
3400 push @d, $cust_bill_pkg->details(%details_opt)
3401 if $cust_bill_pkg->recur == 0;
3403 if ( $cust_bill_pkg->hidden ) {
3404 $s->{amount} += $cust_bill_pkg->setup;
3405 $s->{unit_amount} += $cust_bill_pkg->unitsetup;
3406 push @{ $s->{ext_description} }, @d;
3410 description => $description,
3411 pkgpart => $pkgpart,
3412 pkgnum => $cust_bill_pkg->pkgnum,
3413 amount => $cust_bill_pkg->setup,
3414 setup_show_zero => $cust_bill_pkg->setup_show_zero,
3415 unit_amount => $cust_bill_pkg->unitsetup,
3416 quantity => $cust_bill_pkg->quantity,
3417 ext_description => \@d,
3418 svc_label => ($svc_label || ''),
3419 locationnum => $cust_pkg->locationnum, # sure, why not?
3425 # should we show a recur line?
3426 # if type eq 'S', then NO, because we've been told not to.
3427 # otherwise, show the recur line if:
3428 # - there's a recurring charge
3429 # - or recur_show_zero is on
3430 # - or there's a positive unitrecur (so it's been discounted to zero)
3431 # and discount-show-always is on
3432 if ( ( !$type || $type eq 'R' || $type eq 'U' )
3434 $cust_bill_pkg->recur != 0
3436 || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
3437 || $cust_bill_pkg->recur_show_zero
3442 warn "$me _items_cust_bill_pkg adding recur/usage\n"
3445 my $is_summary = $display->summary;
3446 my $description = $desc;
3447 if ( $type eq 'U' and defined($r) ) {
3448 # don't just show the same description as the recur line
3449 $description = $self->mt('Usage charges');
3452 my $part_pkg = $cust_pkg->part_pkg;
3454 $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
3458 my @seconds = (); # for display of usage info
3459 my @svc_labels = ();
3462 #at least until cust_bill_pkg has "past" ranges in addition to
3463 #the "future" sdate/edate ones... see #3032
3464 my @dates = ( $self->_date );
3465 my $prev = $cust_bill_pkg->previous_cust_bill_pkg;
3466 push @dates, $prev->sdate if $prev;
3467 push @dates, undef if !$prev;
3469 unless ( $part_pkg->hide_svc_detail ) {
3470 @svc_labels = map &{$escape_function}($_),
3471 $cust_pkg->h_labels_short(@dates,
3473 $self->conf->{locale});
3474 $svc_label = $svc_labels[0];
3477 # show service labels, unless...
3478 # the package is set not to display them
3479 unless ( $part_pkg->hide_svc_detail
3480 # or this is a tax-like line item
3481 || $cust_bill_pkg->itemdesc
3482 # or this is a hidden (bundled) line item
3483 || $cust_bill_pkg->hidden
3484 # or this is a usage summary line
3485 || $is_summary && $type && $type eq 'U'
3486 # or this is a usage line and there's a recurring line
3487 # for the package in the same section (which will
3488 # have service labels already)
3489 || ($type eq 'U' and defined($r))
3493 warn "$me _items_cust_bill_pkg adding service details\n"
3496 push @d, @svc_labels
3497 unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
3498 warn "$me _items_cust_bill_pkg done adding service details\n"
3501 # show the location label if it's not the customer's default
3502 # location, and we're not grouping items by location already
3503 if ( $cust_pkg->locationnum != $default_locationnum
3504 and !defined($locationnum) ) {
3505 my $loc = $cust_pkg->location_label;
3506 $loc = substr($loc, 0, $maxlength). '...'
3507 if $format eq 'latex' && length($loc) > $maxlength;
3508 push @d, &{$escape_function}($loc);
3511 # Display of seconds_since_sqlradacct:
3512 # On the invoice, when processing @detail_items, look for a field
3513 # named 'seconds'. This will contain total seconds for each
3514 # service, in the same order as @ext_description. For services
3515 # that don't support this it will show undef.
3516 if ( $conf->exists('svc_acct-usage_seconds')
3517 and ! $cust_bill_pkg->pkgpart_override ) {
3518 foreach my $cust_svc (
3519 $cust_pkg->h_cust_svc(@dates, 'I')
3522 # eval because not having any part_export_usage exports
3523 # is a fatal error, last_bill/_date because that's how
3524 # sqlradius_hour billing does it
3526 $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]);
3528 push @seconds, $sec;
3530 } #if svc_acct-usage_seconds
3532 } # if we are showing service labels
3534 unless ( $is_summary ) {
3535 warn "$me _items_cust_bill_pkg adding details\n"
3538 #instead of omitting details entirely in this case (unwanted side
3539 # effects), just omit CDRs
3540 $details_opt{'no_usage'} = 1
3541 if $type && $type eq 'R';
3543 push @d, $cust_bill_pkg->details(%details_opt);
3546 warn "$me _items_cust_bill_pkg calculating amount\n"
3551 $amount = $cust_bill_pkg->recur;
3552 } elsif ($type eq 'R') {
3553 $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage;
3554 } elsif ($type eq 'U') {
3555 $amount = $cust_bill_pkg->usage;
3558 if ( !$type || $type eq 'R' ) {
3560 warn "$me _items_cust_bill_pkg adding recur\n"
3564 ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
3567 if ( $cust_bill_pkg->hidden ) {
3568 $r->{amount} += $amount;
3569 $r->{unit_amount} += $unit_amount;
3570 push @{ $r->{ext_description} }, @d;
3573 description => $description,
3574 pkgpart => $pkgpart,
3575 pkgnum => $cust_bill_pkg->pkgnum,
3577 recur_show_zero => $cust_bill_pkg->recur_show_zero,
3578 unit_amount => $unit_amount,
3579 quantity => $cust_bill_pkg->quantity,
3581 ext_description => \@d,
3582 svc_label => ($svc_label || ''),
3583 locationnum => $cust_pkg->locationnum,
3585 $r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
3588 } else { # $type eq 'U'
3590 warn "$me _items_cust_bill_pkg adding usage\n"
3593 if ( $cust_bill_pkg->hidden and defined($u) ) {
3594 # if this is a hidden package and there's already a usage
3595 # line for the bundle, add this package's total amount and
3596 # usage details to it
3597 $u->{amount} += $amount;
3598 push @{ $u->{ext_description} }, @d;
3599 } elsif ( $amount ) {
3600 # create a new usage line
3602 description => $description,
3603 pkgpart => $pkgpart,
3604 pkgnum => $cust_bill_pkg->pkgnum,
3607 recur_show_zero => $cust_bill_pkg->recur_show_zero,
3609 ext_description => \@d,
3610 locationnum => $cust_pkg->locationnum,
3612 } # else this has no usage, so don't create a usage section
3615 } # recurring or usage with recurring charge
3617 } else { # taxes and fees
3619 warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
3622 # items of this kind should normally not have sdate/edate.
3624 'description' => $desc,
3625 'amount' => sprintf('%.2f', $cust_bill_pkg->setup
3626 + $cust_bill_pkg->recur)
3629 } # if package line item / other line item
3631 # decide whether to show active discounts here
3633 # case 1: we are showing a single line for the package
3635 # case 2: we are showing a setup line for a package that has
3636 # no base recurring fee
3637 or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
3638 # case 3: we are showing a recur line for a package that has
3639 # a base recurring fee
3640 or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
3643 my $item_discount = $cust_bill_pkg->_item_discount;
3644 if ( $item_discount ) {
3645 # $item_discount->{amount} is negative
3647 if ( $d and $cust_bill_pkg->hidden ) {
3648 $d->{setup_amount} += $item_discount->{setup_amount};
3649 $d->{recur_amount} += $item_discount->{recur_amount};
3651 $d = $item_discount;
3652 $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} };
3655 # update the active line (before the discount) to show the
3656 # original price (whether this is a hidden line or not)
3658 $s->{amount} -= $item_discount->{setup_amount} if $s;
3659 $r->{amount} -= $item_discount->{recur_amount} if $r;
3661 } # if there are any discounts
3662 } # if this is an appropriate place to show discounts
3664 } # foreach $display
3668 # discount amount is internally split up
3670 $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
3673 foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
3675 $_->{amount} = sprintf( "%.2f", $_->{amount} ),
3676 if exists($_->{amount});
3677 $_->{amount} =~ s/^\-0\.00$/0.00/;
3678 if (exists($_->{unit_amount})) {
3679 $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
3683 #if $_->{amount} != 0
3684 # || $discount_show_always
3685 # || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
3686 # || ( $_->{_is_setup} && $_->{setup_show_zero} )
3690 warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n"
3697 =item _items_discounts_avail
3699 Returns an array of line item hashrefs representing available term discounts
3700 for this invoice. This makes the same assumptions that apply to term
3701 discounts in general: that the package is billed monthly, at a flat rate,
3702 with no usage charges. A prorated first month will be handled, as will
3703 a setup fee if the discount is allowed to apply to setup fees.
3707 sub _items_discounts_avail {
3710 #maybe move this method from cust_bill when quotations support discount_plans
3711 return () unless $self->can('discount_plans');
3712 my %plans = $self->discount_plans;
3714 my $list_pkgnums = 0; # if any packages are not eligible for all discounts
3715 $list_pkgnums = grep { $_->list_pkgnums } values %plans;
3719 my $plan = $plans{$months};
3721 my $term_total = sprintf('%.2f', $plan->discounted_total);
3722 my $percent = sprintf('%.0f',
3723 100 * (1 - $term_total / $plan->base_total) );
3724 my $permonth = sprintf('%.2f', $term_total / $months);
3725 my $detail = $self->mt('discount on item'). ' '.
3726 join(', ', map { "#$_" } $plan->pkgnums)
3729 # discounts for non-integer months don't work anyway
3730 $months = sprintf("%d", $months);
3733 description => $self->mt('Save [_1]% by paying for [_2] months',
3735 amount => $self->mt('[_1] ([_2] per month)',
3736 $term_total, $money_char.$permonth),
3737 ext_description => ($detail || ''),
3740 sort { $b <=> $a } keys %plans;