X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=0a1577f63ad02bb51a4f02d88806896c493ec600;hb=b79a8cb932946c849328a3c117c35821d9d21e66;hp=6a604e01c5acc47fc9fbfb100bc33118762b7e94;hpb=9c866ccad0f187f29d21f12b93f15f2787aa9843;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 6a604e01c..0a1577f63 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -7,8 +7,9 @@ use vars qw( @ISA $DEBUG $me use vars qw( $invoice_lines @buf ); #yuck use Fcntl qw(:flock); #for spool_csv use Cwd; -use List::Util qw(min max); +use List::Util qw(min max sum); use Date::Format; +use Date::Language; use Text::Template 1.20; use File::Temp 0.14; use String::ShellQuote; @@ -242,7 +243,6 @@ sub delete { cust_event cust_credit_bill cust_bill_pay - cust_bill_pay cust_credit_bill cust_pay_batch cust_bill_pay_batch @@ -2657,6 +2657,13 @@ sub print_generic { #localization my $lh = FS::L10N->get_handle($cust_main->locale); $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) }; + my %info = FS::Locales->locale_info($cust_main->locale || 'en_US'); + # eval to avoid death for unimplemented languages + my $dh = eval { Date::Language->new($info{'name'}) } || + Date::Language->new(); # fall back to English + # prototype here to silence warnings + $invoice_data{'time2str'} = sub ($;$$) { $dh->time2str(@_) }; + # eventually use this date handle everywhere in here, too my $min_sdate = 999999999999; my $max_edate = 0; @@ -2734,11 +2741,30 @@ sub print_generic { # my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits #my $balance_due = $self->owed + $pr_total - $cr_total; my $balance_due = $self->owed + $pr_total; + + # 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 $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); + + # the sum of amount owed on all invoices $invoice_data{'balance'} = sprintf("%.2f", $balance_due); + # info from customer's last invoice before this one, for some + # summary formats + $invoice_data{'last_bill'} = {}; + my $last_bill = $pr_cust_bill[-1]; + if ( $last_bill ) { + $invoice_data{'last_bill'} = { + '_date' => $last_bill->_date, #unformatted + # all we need for now + }; + } + my $summarypage = ''; if ( $conf->exists('invoice_usesummary', $agentnum) ) { $summarypage = 1; @@ -2792,6 +2818,9 @@ sub print_generic { if ($format eq 'latex'); } + # let invoices use either of these as needed + $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') + ? $cust_main->payinfo : ''; $invoice_data{'po_line'} = ( $cust_main->payby eq 'BILL' && $cust_main->payinfo ) ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo) @@ -2826,7 +2855,7 @@ sub print_generic { my $previous_section = { 'description' => $self->mt('Previous Charges'), 'subtotal' => $other_money_char. sprintf('%.2f', $pr_total), - 'summarized' => $summarypage ? 'Y' : '', + 'summarized' => '', #why? $summarypage ? 'Y' : '', }; $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. join(' / ', map { $cust_main->balance_date_range(@$_) } @@ -2837,12 +2866,11 @@ sub print_generic { my $taxtotal = 0; my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'), 'subtotal' => $taxtotal, # adjusted below - 'summarized' => $summarypage ? 'Y' : '', }; my $tax_weight = _pkg_category($tax_section->{description}) ? _pkg_category($tax_section->{description})->weight : 0; - $tax_section->{'summarized'} = $summarypage && !$tax_weight ? 'Y' : ''; + $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : ''; $tax_section->{'sort_weight'} = $tax_weight; @@ -2850,12 +2878,11 @@ sub print_generic { my $adjust_section = { 'description' => $self->mt('Credits, Payments, and Adjustments'), 'subtotal' => 0, # adjusted below - 'summarized' => $summarypage ? 'Y' : '', }; my $adjust_weight = _pkg_category($adjust_section->{description}) ? _pkg_category($adjust_section->{description})->weight : 0; - $adjust_section->{'summarized'} = $summarypage && !$adjust_weight ? 'Y' : ''; + $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : ''; $adjust_section->{'sort_weight'} = $adjust_weight; my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y'; @@ -2893,8 +2920,25 @@ sub print_generic { push @detail_items, @$accountcode_lines; } } - }else{ - push @sections, { 'description' => '', 'subtotal' => '' }; + } else {# not multisection + # make a default section + push @sections, { 'description' => '', 'subtotal' => '', + 'no_subtotal' => 1 }; + # and calculate the finance charge total, since it won't get done otherwise. + # XXX possibly other totals? + # XXX possibly finance_pkgclass should not be used in this manner? + if ( $conf->exists('finance_pkgclass') ) { + my @finance_charges; + foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + if ( grep { $_->section eq $invoice_data{finance_section} } + $cust_bill_pkg->cust_bill_pkg_display ) { + # I think these are always setup fees, but just to be sure... + push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup; + } + } + $invoice_data{finance_amount} = + sprintf('%.2f', sum( @finance_charges ) || 0); + } } unless ( $conf->exists('disable_previous_balance') @@ -2945,9 +2989,9 @@ sub print_generic { my ($didsummary,$minutes) = $self->_did_summary; my $didsummary_desc = 'DID Activity Summary (since last invoice)'; push @detail_items, - { 'description' => $didsummary_desc, - 'ext_description' => [ $didsummary, $minutes ], - }; + { 'description' => $didsummary_desc, + 'ext_description' => [ $didsummary, $minutes ], + }; } foreach my $section (@sections, @$late_sections) { @@ -3019,6 +3063,10 @@ sub print_generic { $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ). $line_item->{'unit_amount'}; $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; + + $detail->{'sdate'} = $line_item->{'sdate'}; + $detail->{'edate'} = $line_item->{'edate'}; + $detail->{'seconds'} = $line_item->{'seconds'}; push @detail_items, $detail; push @buf, ( [ $detail->{'description'}, @@ -3031,7 +3079,7 @@ sub print_generic { if ( $section->{'description'} ) { push @buf, ( ['','-----------'], [ $section->{'description'}. ' sub-total', - $money_char. sprintf("%10.2f", $section->{'subtotal'}) + $section->{'subtotal'} # already formatted this ], [ '', '' ], [ '', '' ], @@ -3039,7 +3087,7 @@ sub print_generic { } } - + $invoice_data{current_less_finance} = sprintf('%.2f', $self->charged - $invoice_data{finance_amount} ); @@ -3116,7 +3164,7 @@ sub print_generic { { my $total = {}; - my $item = $self->mt('Total'); + my $item = 'Total'; $item = $conf->config('previous_balance-exclude_from_total') || 'Total New Charges' if $conf->exists('previous_balance-exclude_from_total'); @@ -3126,7 +3174,7 @@ sub print_generic { ? 0 : $pr_total ); - $total->{'total_item'} = &$embolden_function($item); + $total->{'total_item'} = &$embolden_function($self->mt($item)); $total->{'total_amount'} = &$embolden_function( $other_money_char. sprintf( '%.2f', $amount ) ); if ( $multisection ) { @@ -3218,6 +3266,7 @@ sub print_generic { unless $adjust_section->{sort_weight}; } + # create Balance Due message { my $total; $total->{'total_item'} = &$embolden_function($self->balance_due_msg); @@ -3278,6 +3327,26 @@ sub print_generic { if $unsquelched; } + # make a discounts-available section, even without multisection + if ( $conf->exists('discount-show_available') + and my @discounts_avail = $self->_items_discounts_avail ) { + my $discount_section = { + 'description' => $self->mt('Discounts Available'), + 'subtotal' => '', + 'no_subtotal' => 1, + }; + + push @sections, $discount_section; + push @detail_items, map { +{ + 'ref' => '', #should this be something else? + 'section' => $discount_section, + 'description' => &$escape_function( $_->{description} ), + 'amount' => $money_char . &$escape_function( $_->{amount} ), + 'ext_description' => [ &$escape_function($_->{ext_description}) || () ], + } } @discounts_avail; + } + + # All sections and items are built; now fill in templates. my @includelist = (); push @includelist, 'summary' if $summarypage; foreach my $include ( @includelist ) { @@ -3340,8 +3409,7 @@ sub print_generic { } #setup subroutine for the template - #sub FS::cust_bill::_template::invoice_lines { # good god, no - $invoice_data{invoice_lines} = sub { # much better + $invoice_data{invoice_lines} = sub { my $lines = shift || scalar(@buf); map { scalar(@buf) @@ -3361,6 +3429,7 @@ sub print_generic { } map "$_\n", @collect; }else{ + # this is where we actually create the invoice warn "filling in template for invoice ". $self->invnum. "\n" if $DEBUG; warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n" @@ -3648,6 +3717,53 @@ sub _date_pretty { time2str($date_format, $self->_date); } +=item _items_sections LATE SUMMARYPAGE ESCAPE EXTRA_SECTIONS FORMAT + +Generate section information for all items appearing on this invoice. +This will only be called for multi-section invoices. + +For each line item (L record), this will fetch all +related display records (L) and organize +them into two groups ("early" and "late" according to whether they come +before or after the total), then into sections. A subtotal is calculated +for each section. + +Section descriptions are returned in sort weight order. Each consists +of a hash containing: + +description: the package category name, escaped +subtotal: the total charges in that section +tax_section: a flag indicating that the section contains only tax charges +summarized: same as tax_section, for some reason +sort_weight: the package category's sort weight + +If 'condense' is set on the display record, it also contains everything +returned from C<_condense_section()>, i.e. C<_condensed_foo_generator> +coderefs to generate parts of the invoice. This is not advised. + +Arguments: + +LATE: an arrayref to push the "late" section hashes onto. The "early" +group is simply returned from the method. + +SUMMARYPAGE: a flag indicating whether this is a summary-format invoice. +Turning this on has the following effects: +- Ignores display items with the 'summary' flag. +- Combines all items into the "early" group. +- Creates sections for all non-disabled package categories, even if they +have no charges on this invoice, as well as a section with no name. + +ESCAPE: an escape function to use for section titles. + +EXTRA_SECTIONS: an arrayref of additional sections to return after the +sorted list. If there are any of these, section subtotals exclude +usage charges. + +FORMAT: 'latex', 'html', or 'template' (i.e. text). Not used, but +passed through to C<_condense_section()>. + +=cut + use vars qw(%pkg_category_cache); sub _items_sections { my $self = shift; @@ -4509,7 +4625,7 @@ sub _items_svc_phone_sections { } -sub _items { +sub _items { # seems to be unused my $self = shift; #my @display = scalar(@_) @@ -4559,6 +4675,21 @@ sub _items_previous { #}; } +=item _items_pkg [ OPTIONS ] + +Return line item hashes for each package item on this invoice. Nearly +equivalent to + +$self->_items_cust_bill_pkg([ $self->cust_bill_pkg ]) + +The only OPTIONS accepted is 'section', which may point to a hashref +with a key named 'condensed', which may have a true value. If it +does, this method tries to merge identical items into items with +'quantity' equal to the number of items (not the sum of their +separate quantities, for some reason). + +=cut + sub _items_pkg { my $self = shift; my %options = @_; @@ -4618,6 +4749,36 @@ sub _items_tax { $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); } +=item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS + +Takes an arrayref of L objects, and returns a +list of hashrefs describing the line items they generate on the invoice. + +OPTIONS may include: + +format: the invoice format. + +escape_function: the function used to escape strings. + +format_function: the function used to format CDRs. + +section: a hashref containing 'description'; if this is present, +cust_bill_pkg_display records not belonging to this section are +ignored. + +multisection: a flag indicating that this is a multisection invoice, +which does something complicated. + +multilocation: a flag to display the location label for the package. + +Returns a list of hashrefs, each of which may contain: + +pkgnum, description, amount, unit_amount, quantity, _is_setup, and +ext_description, which is an arrayref of detail lines to show below +the package line. + +=cut + sub _items_cust_bill_pkg { my $self = shift; my $conf = $self->conf; @@ -4627,9 +4788,9 @@ sub _items_cust_bill_pkg { my $format = $opt{format} || ''; my $escape_function = $opt{escape_function} || sub { shift }; my $format_function = $opt{format_function} || ''; - my $unsquelched = $opt{unsquelched} || ''; + my $unsquelched = $opt{unsquelched} || ''; #unused my $section = $opt{section}->{description} if $opt{section}; - my $summary_page = $opt{summary_page} || ''; + my $summary_page = $opt{summary_page} || ''; #unused my $multilocation = $opt{multilocation} || ''; my $multisection = $opt{multisection} || ''; my $discount_show_always = 0; @@ -4692,6 +4853,10 @@ sub _items_cust_bill_pkg { my $cust_pkg = $cust_bill_pkg->cust_pkg; + # start/end dates for invoice formats that do nonstandard + # things with them + my %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate'); + if ( (!$type || $type eq 'S') && ( $cust_bill_pkg->setup != 0 || $cust_bill_pkg->setup_show_zero @@ -4724,7 +4889,7 @@ sub _items_cust_bill_pkg { push @d, &{$escape_function}($loc); } - } + } #unless hiding service details push @d, $cust_bill_pkg->details(%details_opt) if $cust_bill_pkg->recur == 0; @@ -4773,6 +4938,7 @@ sub _items_cust_bill_pkg { || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1); my @d = (); + my @seconds = (); # for display of usage info #at least until cust_bill_pkg has "past" ranges in addition to #the "future" sdate/edate ones... see #3032 @@ -4806,6 +4972,27 @@ sub _items_cust_bill_pkg { push @d, &{$escape_function}($loc); } + # Display of seconds_since_sqlradacct: + # On the invoice, when processing @detail_items, look for a field + # named 'seconds'. This will contain total seconds for each + # service, in the same order as @ext_description. For services + # that don't support this it will show undef. + if ( $conf->exists('svc_acct-usage_seconds') + and ! $cust_bill_pkg->pkgpart_override ) { + foreach my $cust_svc ( + $cust_pkg->h_cust_svc(@dates, 'I') + ) { + + # eval because not having any part_export_usage exports + # is a fatal error, last_bill/_date because that's how + # sqlradius_hour billing does it + my $sec = eval { + $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]); + }; + push @seconds, $sec; + } + } #if svc_acct-usage_seconds + } unless ( $is_summary ) { @@ -4850,8 +5037,10 @@ sub _items_cust_bill_pkg { recur_show_zero => $cust_bill_pkg->recur_show_zero, unit_amount => $cust_bill_pkg->unitrecur, quantity => $cust_bill_pkg->quantity, + %item_dates, ext_description => \@d, }; + $r->{'seconds'} = \@seconds if grep {defined $_} @seconds; } } else { # $type eq 'U' @@ -4872,6 +5061,7 @@ sub _items_cust_bill_pkg { recur_show_zero => $cust_bill_pkg->recur_show_zero, unit_amount => $cust_bill_pkg->unitrecur, quantity => $cust_bill_pkg->quantity, + %item_dates, ext_description => \@d, }; } @@ -4976,6 +5166,114 @@ sub _items_payments { } +=item _items_discounts_avail + +Returns an array of line item hashrefs representing available term discounts +for this invoice. This makes the same assumptions that apply to term +discounts in general: that the package is billed monthly, at a flat rate, +with no usage charges. A prorated first month will be handled, as will +a setup fee if the discount is allowed to apply to setup fees. + +=cut + +sub _items_discounts_avail { + my $self = shift; + my %terms; + my $list_pkgnums = 0; # if any packages are not eligible for all discounts + + my ($previous_balance) = $self->previous; + + foreach (qsearch('discount',{ 'months' => { op => '>', value => 1} })) { + $terms{$_->months} = { + pkgnums => [], + base => $previous_balance || 0, # pre-discount sum of charges + discounted => $previous_balance || 0, # post-discount sum + list_pkgnums => 0, # whether any packages are not discounted + } + } + foreach my $months (keys %terms) { + my $hash = $terms{$months}; + + # tricky, because packages may not all be eligible for the same discounts + foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + my $cust_pkg = $cust_bill_pkg->cust_pkg or next; + my $part_pkg = $cust_pkg->part_pkg or next; + + next if $part_pkg->freq ne '1'; + my $setup = $cust_bill_pkg->setup || 0; + my $recur = $cust_bill_pkg->recur || 0; + my $permonth = $part_pkg->base_recur_permonth || 0; + + my ($discount) = grep { $_->months == $months } + map { $_->discount } $part_pkg->part_pkg_discount; + + $hash->{base} += $setup + $recur + ($months - 1) * $permonth; + if ($discount) { + + my $discountable; + if ( $discount->setup ) { + $discountable += $setup; + } + else { + $hash->{discounted} += $setup; + } + + if ( $discount->percent ) { + $discountable += $months * $permonth; + $discountable -= ($discountable * $discount->percent / 100); + $discountable -= ($permonth - $recur); # correct for prorate + $hash->{discounted} += $discountable; + } + else { + $discountable += $recur; + $discountable -= $discount->amount * $recur/$permonth; + + $discountable += ($months - 1) * max($permonth - $discount->amount,0); + } + + $hash->{discounted} += $discountable; + push @{ $hash->{pkgnums} }, $cust_pkg->pkgnum; + } + else { #no discount + $hash->{discounted} += $setup + $recur + ($months - 1) * $permonth; + $hash->{list_pkgnums} = 1; + } + } #foreach $cust_bill_pkg + + # don't show this line if no packages have discounts at this term + # or if there are no new charges to apply the discount to + delete $terms{$months} if $hash->{base} == $hash->{discounted} + or $hash->{base} == 0; + + } + + $list_pkgnums = grep { $_->{list_pkgnums} > 0 } values %terms; + + foreach my $months (keys %terms) { + my $hash = $terms{$months}; + my $term_total = sprintf('%.2f', $hash->{discounted}); + # possibly shouldn't include previous balance in these? + my $percent = sprintf('%.0f', 100 * (1 - $term_total / $hash->{base}) ); + my $permonth = sprintf('%.2f', $term_total / $months); + + $hash->{description} = $self->mt('Save [_1]% by paying for [_2] months', + $percent, $months + ); + $hash->{amount} = $self->mt('[_1] ([_2] per month)', + $term_total, $money_char.$permonth + ); + + my @detail; + if ( $list_pkgnums ) { + push @detail, $self->mt('discount on item'). ' '. + join(', ', map { "#$_" } @{ $hash->{pkgnums} }); + } + $hash->{ext_description} = join ', ', @detail; + } + + map { $terms{$_} } sort {$b <=> $a} keys %terms; +} + =item call_details [ OPTION => VALUE ... ] Returns an array of CSV strings representing the call details for this invoice