X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2FTemplate_Mixin.pm;h=2fae4643d86124ead6b03426048aff12701a767e;hp=131a23643419a7bda689f0f2918eb7a79dc13d07;hb=abc56e03d9cff03757f9a2f8a3d8f35e97cbb7c5;hpb=5e78b358502a9506b60b5e1f888feb83a1f8fa86 diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 131a23643..2fae4643d 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -2,7 +2,9 @@ package FS::Template_Mixin; use strict; use vars qw( $DEBUG $me - $money_char ); + $money_char + $date_format + ); # but NOT $conf use vars qw( $invoice_lines @buf ); #yuck use List::Util qw(sum); @@ -26,7 +28,8 @@ $DEBUG = 0; $me = '[FS::Template_Mixin]'; FS::UID->install_callback( sub { my $conf = new FS::Conf; #global - $money_char = $conf->config('money_char') || '$'; + $money_char = $conf->config('money_char') || '$'; + $date_format = $conf->config('date_format') || '%x'; #/YY } ); =item conf [ MODE ] @@ -217,26 +220,82 @@ Internal method - returns a filled-in template for this invoice as a scalar. See print_ps and print_pdf for methods that return PostScript and PDF output. -Non optional options include - format - latex, html, template +Required options -Optional options include +=over 4 -template - a value used as a suffix for a configuration template. Please -don't use this. +=item format -time - a value used to control the printing of overdue messages. The +The B option is required and should be set to html, latex (print and PDF) or template (plaintext). + +=back + +Additional options + +=over 4 + +=item notice_name + +Overrides "Invoice" as the name of the sent document. + +=item today + +Used to control the printing of overdue messages. The default is now. It isn't the date of the invoice; that's the `_date' field. It is specified as a UNIX timestamp; see L. Also see L and L for conversion functions. -cid - +=item logo_file + +Logo file (path to temporary EPS file on the local filesystem) + +=item cid + +CID for inline (emailed) images (logo) + +=item locale + +Override customer's locale + +=item unsquelch_cdr + +Overrides any per customer cdr squelching when true + +=item no_number + +Supress the (invoice, quotation, statement, etc.) number + +=item no_date + +Supress the date -unsquelch_cdr - overrides any per customer cdr squelching when true +=item no_coupon -notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required) +Supress the payment coupon -locale - override customer's locale +=item barcode_file + +Barcode file (path to temporary EPS file on the local filesystem) + +=item barcode_img + +Flag indicating the barcode image should be a link (normal HTML dipaly) + +=item barcode_cid + +Barcode CID for inline (emailed) images + +=item preref_callback + +Coderef run for each line item, code should return HTML to be displayed +before that line item (quotations only) + +=item template + +Dprecated. Used as a suffix for a configuration template. Please +don't use this, it deprecated in favor of more flexible alternatives. + +=back =cut @@ -285,13 +344,13 @@ sub print_generic { my @invoice_template = map "$_\n", $conf->config($templatefile) or die "cannot load config data $templatefile"; - my $old_latex = ''; if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) { #change this to a die when the old code is removed - warn "old-style invoice template $templatefile; ". + # it's been almost ten years, changing it to a die. + die "old-style invoice template $templatefile; ". "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n"; - $old_latex = 'true'; - @invoice_template = _translate_old_latex_format(@invoice_template); + #$old_latex = 'true'; + #@invoice_template = _translate_old_latex_format(@invoice_template); } warn "$me print_generic creating T:T object\n" @@ -485,9 +544,14 @@ sub print_generic { 'quotationnum' => $self->quotationnum, 'no_date' => $params{'no_date'}, '_date' => ( $params{'no_date'} ? '' : $self->_date ), + # workaround for inconsistent behavior in the early plain text + # templates; see RT#28271 'date' => ( $params{'no_date'} ? '' - : $self->time2str_local('long', $self->_date, $format) + : ($format eq 'template' + ? $self->_date + : $self->time2str_local('long', $self->_date, $format) + ) ), 'today' => $self->time2str_local('long', $today, $format), 'terms' => $self->terms, @@ -622,18 +686,6 @@ sub print_generic { #my $balance_due = $self->owed + $pr_total - $cr_total; my $balance_due = $self->owed + $pr_total; - #these are used on the summary page only - - # the customer's current balance as shown on the invoice before this one - $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) ); - - # the change in balance from that invoice to this one - $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) ); - - # the sum of amount owed on all previous invoices - # ($pr_total is used elsewhere but not as $previous_balance) - $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); - # the sum of amount owed on all invoices # (this is used in the summary & on the payment coupon) $invoice_data{'balance'} = sprintf("%.2f", $balance_due); @@ -644,8 +696,69 @@ sub print_generic { if ( $self->custnum && $self->invnum ) { - if ( $self->previous_bill ) { - my $last_bill = $self->previous_bill; + my $last_bill = $self->previous_bill; + if ( $last_bill ) { + + # "balance_date_range" unfortunately is unsuitable for this, since it + # cares about application dates. We want to know the sum of all + # _top-level transactions_ dated before the last invoice. + my @sql = ( + 'SELECT SUM(charged) FROM cust_bill WHERE _date <= ? AND custnum = ?', + 'SELECT -1*SUM(amount) FROM cust_credit WHERE _date <= ? AND custnum = ?', + 'SELECT -1*SUM(paid) FROM cust_pay WHERE _date <= ? AND custnum = ?', + 'SELECT SUM(refund) FROM cust_refund WHERE _date <= ? AND custnum = ?', + ); + + # the customer's current balance immediately after generating the last + # bill + + my $last_bill_balance = $last_bill->charged; + foreach (@sql) { + #warn "$_\n"; + my $delta = FS::Record->scalar_sql( + $_, + $last_bill->_date - 1, + $self->custnum, + ); + #warn "$delta\n"; + $last_bill_balance += $delta; + } + + $last_bill_balance = sprintf("%.2f", $last_bill_balance); + + warn sprintf("LAST BILL: INVNUM %d, DATE %s, BALANCE %.2f\n\n", + $last_bill->invnum, + $self->time2str_local('%D', $last_bill->_date), + $last_bill_balance + ) if $DEBUG > 0; + # ("true_previous_balance" is a terrible name, but at least it's no + # longer stored in the database) + $invoice_data{'true_previous_balance'} = $last_bill_balance; + + # the change in balance from immediately after that invoice + # to immediately before this one + my $before_this_bill_balance = 0; + foreach (@sql) { + #warn "$_\n"; + my $delta = FS::Record->scalar_sql( + $_, + $self->_date - 1, + $self->custnum, + ); + #warn "$delta\n"; + $before_this_bill_balance += $delta; + } + $invoice_data{'balance_adjustments'} = + sprintf("%.2f", $last_bill_balance - $before_this_bill_balance); + + warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n", + $invoice_data{'balance_adjustments'} + ) if $DEBUG > 0; + + # the sum of amount owed on all previous invoices + # ($pr_total is used elsewhere but not as $previous_balance) + $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); + $invoice_data{'last_bill'} = { '_date' => $last_bill->_date, #unformatted }; @@ -682,9 +795,15 @@ sub print_generic { } $invoice_data{'previous_payments'} = \@payments; $invoice_data{'previous_credits'} = \@credits; + } else { + # there is no $last_bill + $invoice_data{'true_previous_balance'} = + $invoice_data{'balance_adjustments'} = + $invoice_data{'previous_balance'} = '0.00'; + $invoice_data{'previous_payments'} = []; + $invoice_data{'previous_credits'} = []; } - - } + } # if this is an invoice my $summarypage = ''; if ( $conf->exists('invoice_usesummary', $agentnum) ) { @@ -761,6 +880,7 @@ sub print_generic { ); my $money_char = $money_chars{$format}; + # extremely dubious my %other_money_chars = ( 'latex' => '\dollar ',#XXX should be a config too 'html' => $conf->config('money_char') || '$', 'template' => '', @@ -863,7 +983,7 @@ sub print_generic { # we haven't yet changed the template to take advantage of that, so for # now, treat them as mutually exclusive. my %section_method = ( by_category => 1 ); - if ( $conf->exists($tc.'sections_by_location') ) { + if ( $conf->config($tc.'sections_method') eq 'location' ) { %section_method = ( by_location => 1 ); } my ($early, $late) = @@ -921,6 +1041,36 @@ sub print_generic { sprintf('%.2f', sum( @charges ) || 0); } + # start setting up summary subtotals + my @summary_subtotals; + my $method = $conf->config('summary_subtotals_method'); + if ( $method and $method ne $conf->config($tc.'sections_method') ) { + # then re-section them by the correct method + my %section_method = ( by_category => 1 ); + if ( $conf->config('summary_subtotals_method') eq 'location' ) { + %section_method = ( by_location => 1 ); + } + my ($early, $late) = + $self->_items_sections( 'summary' => $summarypage, + 'escape' => $escape_function_nonbsp, + 'extra_sections' => $extra_sections, + 'format' => $format, + %section_method + ); + foreach ( @$early ) { + next if $_->{subtotal} == 0; + $_->{subtotal} = $other_money_char.sprintf('%.2f', $_->{subtotal}); + push @summary_subtotals, $_; + } + } else { + # subtotal sectioning is the same as for the actual invoice sections + @summary_subtotals = @sections; + } + + # Hereafter, push sections to both @sections and @summary_subtotals + # if they belong in both places (e.g. tax section). Late sections are + # never in @summary_subtotals. + # previous invoice balances in the Previous Charges section if there # is one, otherwise in the main detail section # (except if summary_only is enabled, don't show them at all) @@ -942,8 +1092,7 @@ sub print_generic { ext_description => [ map { &$escape_function($_) } @{ $line_item->{'ext_description'} || [] } ], - amount => ( $old_latex ? '' : $money_char). - $line_item->{'amount'}, + amount => $money_char . $line_item->{'amount'}, product_code => $line_item->{'pkgpart'} || 'N/A', }; @@ -1013,44 +1162,34 @@ sub print_generic { $options{'summary_page'} = $summarypage; $options{'skip_usage'} = scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections; + $options{'preref_callback'} = $params{'preref_callback'}; warn "$me searching for line items\n" if $DEBUG > 1; - foreach my $line_item ( $self->_items_pkg(%options) ) { + foreach my $line_item ( $self->_items_pkg(%options), + $self->_items_fee(%options) ) { - warn "$me adding line item $line_item\n" + warn "$me adding line item ". + join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n" if $DEBUG > 1; - my $detail = { - ext_description => [], - }; - $detail->{'ref'} = $line_item->{'pkgnum'}; - $detail->{'pkgpart'} = $line_item->{'pkgpart'}; - $detail->{'quantity'} = $line_item->{'quantity'}; - $detail->{'section'} = $section; - $detail->{'description'} = &$escape_function($line_item->{'description'}); - if ( exists $line_item->{'ext_description'} ) { - @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}}; - } - $detail->{'amount'} = ( $old_latex ? '' : $money_char ). - $line_item->{'amount'}; - if ( exists $line_item->{'unit_amount'} ) { - $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ). - $line_item->{'unit_amount'}; - } - $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; + $line_item->{'ref'} = $line_item->{'pkgnum'}; + $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()? + $line_item->{'section'} = $section; + $line_item->{'description'} = &$escape_function($line_item->{'description'}); + $line_item->{'amount'} = $money_char.$line_item->{'amount'}; - $detail->{'sdate'} = $line_item->{'sdate'}; - $detail->{'edate'} = $line_item->{'edate'}; - $detail->{'seconds'} = $line_item->{'seconds'}; - $detail->{'svc_label'} = $line_item->{'svc_label'}; - - push @detail_items, $detail; - push @buf, ( [ $detail->{'description'}, + if ( length($line_item->{'unit_amount'}) ) { + $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'}; + } + $line_item->{'ext_description'} ||= []; + + push @detail_items, $line_item; + push @buf, ( [ $line_item->{'description'}, $money_char. sprintf("%10.2f", $line_item->{'amount'}), ], - map { [ " ". $_, '' ] } @{$detail->{'ext_description'}}, + map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}}, ); } @@ -1072,6 +1211,7 @@ sub print_generic { # if there's anything in the Previous Charges section, prepend it to the list if ( $pr_total and $previous_section ne $default_section ) { unshift @sections, $previous_section; + # but not @summary_subtotals } warn "$me adding taxes\n" @@ -1087,13 +1227,12 @@ sub print_generic { if ( $multisection ) { - my $money = $old_latex ? '' : $money_char; push @detail_items, { ext_description => [], ref => '', quantity => '', description => $description, - amount => $money. $amount, + amount => $money_char. $amount, product_code => '', section => $tax_section, }; @@ -1124,8 +1263,11 @@ sub print_generic { sprintf('%.2f', $taxtotal); $tax_section->{'pretotal'} = 'New charges sub-total '. $total->{'total_amount'}; - push @sections, $tax_section if $taxtotal; - }else{ + if ( $taxtotal ) { + push @sections, $tax_section; + push @summary_subtotals, $tax_section; + } + } else { unshift @total_items, $total; } } @@ -1218,13 +1360,12 @@ sub print_generic { $total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'}; $adjusttotal += $credit->{'amount'}; if ( $multisection ) { - my $money = $old_latex ? '' : $money_char; push @detail_items, { ext_description => [], ref => '', quantity => '', description => &$escape_function($credit->{'description'}), - amount => $money. $credit->{'amount'}, + amount => $money_char . $credit->{'amount'}, product_code => '', section => $adjust_section, }; @@ -1253,13 +1394,12 @@ sub print_generic { $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'}; $adjusttotal += $payment->{'amount'}; if ( $multisection ) { - my $money = $old_latex ? '' : $money_char; push @detail_items, { ext_description => [], ref => '', quantity => '', description => &$escape_function($payment->{'description'}), - amount => $money. $payment->{'amount'}, + amount => $money_char . $payment->{'amount'}, product_code => '', section => $adjust_section, }; @@ -1277,6 +1417,8 @@ sub print_generic { sprintf('%.2f', $adjusttotal); push @sections, $adjust_section unless $adjust_section->{sort_weight}; + # do not summarize; adjustments there are shown according to + # different rules } # create Balance Due message @@ -1355,7 +1497,7 @@ sub print_generic { 'no_subtotal' => 1, }; - push @sections, $discount_section; + push @sections, $discount_section; # do not summarize push @detail_items, map { +{ 'ref' => '', #should this be something else? 'section' => $discount_section, @@ -1365,24 +1507,50 @@ sub print_generic { } } @discounts_avail; } - my @summary_subtotals; - # the templates say "$_->{tax_section} || !$_->{summarized}" - # except 'summarized' is only true when tax_section is true, so this - # is always true, so what's the deal? - foreach my $s (@sections) { - # not to include in the "summary of new charges" block: - # finance charges, adjustments, previous charges, - # and itemized phone usage sections - if ( $s eq $adjust_section or - ($s eq $previous_section and $s ne $default_section) or - ($invoice_data{'finance_section'} and - $invoice_data{'finance_section'} eq $s->{description}) or - $s->{'description'} =~ /^\d+ $/ ) { - next; + # not adding any more sections after this + $invoice_data{summary_subtotals} = \@summary_subtotals; + + # usage subtotals + if ( $conf->exists('usage_class_summary') + and $self->can('_items_usage_class_summary') ) { + my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function); + if ( @usage_subtotals ) { + unshift @sections, $usage_subtotals[0]->{section}; # do not summarize + unshift @detail_items, @usage_subtotals; } - push @summary_subtotals, $s; } - $invoice_data{summary_subtotals} = \@summary_subtotals; + + # invoice history "section" (not really a section) + # not to be included in any subtotals, completely independent of + # everything... + if ( $conf->exists('previous_invoice_history') ) { + my %history; + my %monthorder; + foreach my $cust_bill ( $cust_main->cust_bill ) { + # XXX hardcoded format, and currently only 'charged'; add other fields + # if they become necessary + my $date = $self->time2str_local('%b %Y', $cust_bill->_date); + $history{$date} ||= 0; + $history{$date} += $cust_bill->charged; + # just so we have a numeric sort key + $monthorder{$date} ||= $cust_bill->_date; + } + my @sorted_months = sort { $monthorder{$a} <=> $monthorder{$b} } + keys %history; + my @sorted_amounts = map { sprintf('%.2f', $history{$_}) } @sorted_months; + $invoice_data{monthly_history} = [ \@sorted_months, \@sorted_amounts ]; + } + + # service locations: another option for template customization + my %location_info; + foreach my $item (@detail_items) { + if ( $item->{locationnum} ) { + $location_info{ $item->{locationnum} } ||= { + FS::cust_location->by_key( $item->{locationnum} )->location_hash + }; + } + } + $invoice_data{location_info} = \%location_info; # debugging hook: call this with 'diag' => 1 to just get a hash of # the invoice variables @@ -1740,7 +1908,8 @@ sub credit_balance_msg { =item _date_pretty -Returns a string with the date, for example: "3/20/2008" +Returns a string with the date, for example: "3/20/2008", localized for the +customer. Use _date_pretty_unlocalized for non-end-customer display use. =cut @@ -1749,6 +1918,18 @@ sub _date_pretty { $self->time2str_local('short', $self->_date); } +=item _date_pretty_unlocalized + +Returns a string with the date, for example: "3/20/2008", in the format +configured for the back-office. Use _date_pretty for end-customer display use. + +=cut + +sub _date_pretty_unlocalized { + my $self = shift; + time2str($date_format, $self->_date); +} + =item _items_sections OPTIONS Generate section information for all items appearing on this invoice. @@ -1860,10 +2041,13 @@ sub _items_sections { my $section = $display->section; my $type = $display->type; - $section = undef unless $opt{by_category}; + # Set $section = undef if we're sectioning by location and this + # line item _has_ a location (i.e. isn't a fee). + $section = undef if $locationnum; + # set this flag if the section is not tax-only $not_tax{$locationnum}{$section} = 1 - unless $cust_bill_pkg->pkgnum == 0; + if $cust_bill_pkg->pkgnum or $cust_bill_pkg->feepart; # there's actually a very important piece of logic buried in here: # incrementing $late_subtotal{$section} CREATES @@ -1899,29 +2083,24 @@ sub _items_sections { } else { # it's a pre-total (normal) section # skip tax items unless they're explicitly included in a section - next if $cust_bill_pkg->pkgnum == 0 && ! $section; + next if $cust_bill_pkg->pkgnum == 0 and + ! $cust_bill_pkg->feepart and + ! $section; - if (! $type || $type eq 'S') { + if ( $type eq 'S' ) { $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup if $cust_bill_pkg->setup != 0 || $cust_bill_pkg->setup_show_zero; - } - - if (! $type) { - $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - if $cust_bill_pkg->recur != 0 - || $cust_bill_pkg->recur_show_zero; - } - - if ($type && $type eq 'R') { + } elsif ( $type eq 'R' ) { $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage if $cust_bill_pkg->recur != 0 || $cust_bill_pkg->recur_show_zero; - } - - if ($type && $type eq 'U') { + } elsif ( $type eq 'U' ) { $subtotal{$locationnum}{$section} += $usage unless scalar(@extra_sections); + } elsif ( !$type ) { + $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup + + $cust_bill_pkg->recur; } } @@ -1967,8 +2146,9 @@ sub _items_sections { $section->{'sort_weight'} = sprintf('%012s',$location->zip) . $locationnum; $section->{'location'} = { + label_prefix => &{ $escape }($location->label_prefix), map { $_ => &{ $escape }($location->get($_)) } - $location->fields + $location->fields }; } else { $section->{'category'} = $sectionname; @@ -2252,7 +2432,78 @@ separate quantities, for some reason). sub _items_nontax { my $self = shift; - grep { $_->pkgnum } $self->cust_bill_pkg; + # The order of these is important. Bundled line items will be merged into + # the most recent non-hidden item, so it needs to be the one with: + # - the same pkgnum + # - the same start date + # - no pkgpart_override + # + # So: sort by pkgnum, + # then by sdate + # then sort the base line item before any overrides + # then sort hidden before non-hidden add-ons + # then sort by override pkgpart (for consistency) + sort { $a->pkgnum <=> $b->pkgnum or + $a->sdate <=> $b->sdate or + ($a->pkgpart_override ? 0 : -1) or + ($b->pkgpart_override ? 0 : 1) or + $b->hidden cmp $a->hidden or + $a->pkgpart_override <=> $b->pkgpart_override + } + # and of course exclude taxes and fees + grep { $_->pkgnum > 0 } $self->cust_bill_pkg; +} + +sub _items_fee { + my $self = shift; + my %options = @_; + my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg; + my @items; + foreach my $cust_bill_pkg (@cust_bill_pkg) { + # cache this, so we don't look it up again in every section + my $part_fee = $cust_bill_pkg->get('part_fee') + || $cust_bill_pkg->part_fee; + $cust_bill_pkg->set('part_fee', $part_fee); + if (!$part_fee) { + #die "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; # might make more sense + warn "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; + next; + } + if ( exists($options{section}) and exists($options{section}{category}) ) + { + my $categoryname = $options{section}{category}; + # then filter for items that have that section + if ( $part_fee->categoryname ne $categoryname ) { + warn "skipping fee '".$part_fee->itemdesc."'--not in section $categoryname\n" if $DEBUG; + next; + } + } # otherwise include them all in the main section + # XXX what to do when sectioning by location? + + my @ext_desc; + my %base_invnums; # invnum => invoice date + foreach ($cust_bill_pkg->cust_bill_pkg_fee) { + if ($_->base_invnum) { + my $base_bill = FS::cust_bill->by_key($_->base_invnum); + my $base_date = $self->time2str_local('short', $base_bill->_date) + if $base_bill; + $base_invnums{$_->base_invnum} = $base_date || ''; + } + } + foreach (sort keys(%base_invnums)) { + next if $_ == $self->invnum; + push @ext_desc, + $self->mt('from invoice \\#[_1] on [_2]', $_, $base_invnums{$_}); + } + push @items, + { feepart => $cust_bill_pkg->feepart, + amount => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur), + description => $part_fee->itemdesc_locale($self->cust_main->locale), + ext_description => \@ext_desc + # sdate/edate? + }; + } + @items; } sub _items_pkg { @@ -2310,7 +2561,8 @@ sub _taxsort { sub _items_tax { my $self = shift; - my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg; + my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart } + $self->cust_bill_pkg; my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); if ( $self->conf->exists('always_show_tax') ) { @@ -2345,6 +2597,9 @@ location (whichever is defined). multisection: a flag indicating that this is a multisection invoice, which does something complicated. +preref_callback: coderef run for each line item, code should return HTML to be +displayed before that line item (quotations only) + Returns a list of hashrefs, each of which may contain: pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and @@ -2378,16 +2633,21 @@ sub _items_cust_bill_pkg { my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style # and location labels - my @b = (); - my ($s, $r, $u) = ( undef, undef, undef ); + my @b = (); # accumulator for the line item hashes that we'll return + my ($s, $r, $u, $d) = ( undef, undef, undef ); + # the 'current' line item hashes for setup, recur, usage, discount foreach my $cust_bill_pkg ( @$cust_bill_pkgs ) { - - foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) { + # if the current line item is waiting to go out, and the one we're about + # to start is not bundled, then push out the current one and start a new + # one. + foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) , $d ) { if ( $_ && !$cust_bill_pkg->hidden ) { - $_->{amount} = sprintf( "%.2f", $_->{amount} ), + $_->{amount} = sprintf( "%.2f", $_->{amount} ); $_->{amount} =~ s/^\-0\.00$/0.00/; - $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ), + if (exists($_->{unit_amount})) { + $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ); + } push @b, { %$_ } if $_->{amount} != 0 || $discount_show_always @@ -2401,7 +2661,7 @@ sub _items_cust_bill_pkg { if ( $locationnum ) { # this is a location section; skip packages that aren't at this # service location. - next if $cust_bill_pkg->pkgnum == 0; + next if $cust_bill_pkg->pkgnum == 0; # skips fees... next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum != $locationnum; } @@ -2430,6 +2690,9 @@ sub _items_cust_bill_pkg { @cust_bill_pkg_display = grep { !$_->summary } @cust_bill_pkg_display; } + + my $classname = ''; # package class name, will fill in later + foreach my $display (@cust_bill_pkg_display) { warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ". @@ -2455,6 +2718,7 @@ sub _items_cust_bill_pkg { # quotation_pkgs are never fees, so don't worry about the case where # part_pkg is undefined + # and I guess they're never bundled either? if ( $cust_bill_pkg->setup != 0 ) { my $description = $desc; $description .= ' Setup' @@ -2462,18 +2726,33 @@ sub _items_cust_bill_pkg { || $discount_show_always || $cust_bill_pkg->recur_show_zero; push @b, { + 'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref 'description' => $description, 'amount' => sprintf("%.2f", $cust_bill_pkg->setup), + 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup), + 'quantity' => $cust_bill_pkg->quantity, + 'preref_html' => ( $opt{preref_callback} + ? &{ $opt{preref_callback} }( $cust_bill_pkg ) + : '' + ), }; } if ( $cust_bill_pkg->recur != 0 ) { push @b, { + 'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref 'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")", 'amount' => sprintf("%.2f", $cust_bill_pkg->recur), + 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur), + 'quantity' => $cust_bill_pkg->quantity, + 'preref_html' => ( $opt{preref_callback} + ? &{ $opt{preref_callback} }( $cust_bill_pkg ) + : '' + ), }; } - } elsif ( $cust_bill_pkg->pkgnum > 0 ) { # and it's not a quotation_pkg + } elsif ( $cust_bill_pkg->pkgnum > 0 ) { + # a "normal" package line item (not a quotation, not a fee, not a tax) warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n" if $DEBUG > 1; @@ -2490,6 +2769,9 @@ sub _items_cust_bill_pkg { %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate') unless $part_pkg->option('disable_line_item_date_ranges',1); + # not normally used, but pass this to the template anyway + $classname = $part_pkg->classname; + if ( (!$type || $type eq 'S') && ( $cust_bill_pkg->setup != 0 || $cust_bill_pkg->setup_show_zero @@ -2516,16 +2798,20 @@ sub _items_cust_bill_pkg { my @d = (); my $svc_label; + + # always pass the svc_label through to the template, even if + # not displaying it as an ext_description + my @svc_labels = map &{$escape_function}($_), + $cust_pkg->h_labels_short($self->_date, undef, 'I'); + + $svc_label = $svc_labels[0]; + unless ( $cust_pkg->part_pkg->hide_svc_detail || $cust_bill_pkg->hidden ) { - my @svc_labels = map &{$escape_function}($_), - $cust_pkg->h_labels_short($self->_date, undef, 'I'); push @d, @svc_labels unless $cust_bill_pkg->pkgpart_override; #don't redisplay services - $svc_label = $svc_labels[0]; - my $lnum = $cust_main ? $cust_main->ship_locationnum : $self->prospect_main->locationnum; # show the location label if it's not the customer's default @@ -2558,6 +2844,7 @@ sub _items_cust_bill_pkg { quantity => $cust_bill_pkg->quantity, ext_description => \@d, svc_label => ($svc_label || ''), + locationnum => $cust_pkg->locationnum, # sure, why not? }; }; @@ -2599,6 +2886,10 @@ sub _items_cust_bill_pkg { push @dates, $prev->sdate if $prev; push @dates, undef if !$prev; + my @svc_labels = map &{$escape_function}($_), + $cust_pkg->h_labels_short(@dates, 'I'); + $svc_label = $svc_labels[0]; + # show service labels, unless... # the package is set not to display them unless ( $part_pkg->hide_svc_detail @@ -2618,12 +2909,8 @@ sub _items_cust_bill_pkg { warn "$me _items_cust_bill_pkg adding service details\n" if $DEBUG > 1; - my @svc_labels = map &{$escape_function}($_), - $cust_pkg->h_labels_short(@dates, 'I'); push @d, @svc_labels unless $cust_bill_pkg->pkgpart_override; #don't redisplay services - $svc_label = $svc_labels[0]; - warn "$me _items_cust_bill_pkg done adding service details\n" if $DEBUG > 1; @@ -2710,6 +2997,7 @@ sub _items_cust_bill_pkg { %item_dates, ext_description => \@d, svc_label => ($svc_label || ''), + locationnum => $cust_pkg->locationnum, }; $r->{'seconds'} = \@seconds if grep {defined $_} @seconds; } @@ -2732,15 +3020,77 @@ sub _items_cust_bill_pkg { pkgpart => $pkgpart, pkgnum => $cust_bill_pkg->pkgnum, amount => $amount, + usage_item => 1, recur_show_zero => $cust_bill_pkg->recur_show_zero, %item_dates, ext_description => \@d, + locationnum => $cust_pkg->locationnum, }; } # else this has no usage, so don't create a usage section } } # recurring or usage with recurring charge + # decide whether to show active discounts here + if ( + # case 1: we are showing a single line for the package + ( !$type ) + # case 2: we are showing a setup line for a package that has + # no base recurring fee + or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 ) + # case 3: we are showing a recur line for a package that has + # a base recurring fee + or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 ) + ) { + + my @discounts = $cust_bill_pkg->cust_bill_pkg_discount; + # special case: if there are old "discount details" on this line + # item, don't show discount line items + if ( FS::cust_bill_pkg_detail->count( + "detail LIKE 'Includes discount%' AND billpkgnum = " . + $cust_bill_pkg->billpkgnum + ) > 0 ) { + @discounts = (); + } + if( @discounts ) { + warn "$me _items_cust_bill_pkg including discounts for ". + $cust_bill_pkg->billpkgnum."\n" + if $DEBUG; + my $discount_amount = sum( map {$_->amount} @discounts ); + my $orig_amount = $cust_bill_pkg->setup + $cust_bill_pkg->recur + + $discount_amount; + # if multiple discounts apply to the same package, how to display + # them? ext_description lines, apparently + if ( $d and $cust_bill_pkg->hidden ) { + $d->{amount} += $discount_amount; + $d->{orig_amount} += $orig_amount; + } else { + my @ext; + # make a placeholder for the original price, if necessary + # (if unit prices are enabled, it won't be necessary) + push @ext, '' if !$conf->exists('invoice-unitprice'); + $d = { + _is_discount => 1, + description => $self->mt('Discount included'), + amount => $discount_amount, + orig_amount => $orig_amount, + ext_description => \@ext, + }; + foreach my $cust_bill_pkg_discount (@discounts) { + my $def = $cust_bill_pkg_discount->cust_pkg_discount->discount; + push @ext, &{$escape_function}( $def->description ); + } + } + + # update the placeholder to show the original price in the + # first ext_description line + if ( !$conf->exists('invoice-unitprice') ) { + $d->{ext_description}->[0] = + sprintf('Original price: %.2f', $d->{orig_amount}); + } + } # if there are any discounts + } # if this is an appropriate place to show discounts + } else { # taxes and fees warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n" @@ -2762,13 +3112,14 @@ sub _items_cust_bill_pkg { } - foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) { + foreach ( $s, $r, ($opt{skip_usage} ? () : $u, $d ) ) { if ( $_ ) { $_->{amount} = sprintf( "%.2f", $_->{amount} ), if exists($_->{amount}); $_->{amount} =~ s/^\-0\.00$/0.00/; - $_->{unit_amount} = sprintf('%.2f', $_->{unit_amount}) - if exists($_->{unit_amount}); + if (exists($_->{unit_amount})) { + $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ); + } push @b, { %$_ } if $_->{amount} != 0