diff options
-rw-r--r-- | FS/FS/Template_Mixin.pm | 527 | ||||
-rw-r--r-- | FS/FS/cust_bill.pm | 1042 | ||||
-rw-r--r-- | conf/invoice_htmlsummary | 39 |
3 files changed, 1111 insertions, 497 deletions
diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 7dc813993..7d92d21af 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -29,9 +29,9 @@ use FS::L10N; $DEBUG = 0; $me = '[FS::Template_Mixin]'; -FS::UID->install_callback( sub { +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 } ); @@ -121,7 +121,7 @@ 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<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion functions. -I<template>, if specified, is the name of a suffix for alternate invoices. +I<template>, if specified, is the name of a suffix for alternate invoices. This is strongly deprecated; see L<FS::invoice_conf> for the right way to customize invoice templates for different purposes. @@ -175,7 +175,7 @@ sub print_latex { close $lh; $params{'logo_file'} = $lh->filename; - if( $conf->exists('invoice-barcode') + if( $conf->exists('invoice-barcode') && $self->can('invoice_barcode') && $self->invnum ) { # don't try to barcode statements my $png_file = $self->invoice_barcode($dir); @@ -187,7 +187,7 @@ sub print_latex { $eps_file = $1; my $curr_dir = cwd(); - chdir($dir); + chdir($dir); # after painfuly long experimentation, it was determined that sam2p won't # accept : and other chars in the path, no matter how hard I tried to # escape them, hence the chdir (and chdir back, just to be safe) @@ -200,7 +200,7 @@ sub print_latex { } my @filled_in = $self->print_generic( %params ); - + my $fh = new File::Temp( TEMPLATE => $tmp_template, DIR => $dir, SUFFIX => '.tex', @@ -299,7 +299,7 @@ before that line item (quotations only) =item template -Dprecated. Used as a suffix for a configuration template. Please +Dprecated. Used as a suffix for a configuration template. Please don't use this, it deprecated in favor of more flexible alternatives. =back @@ -344,6 +344,8 @@ sub print_generic { $templatefile .= "_$template" if length($template) && $conf->exists($templatefile."_$template"); + $self->set('_template',$template); + # the base template my @invoice_template = map "$_\n", $conf->config($templatefile) or die "cannot load config data $templatefile"; @@ -355,7 +357,7 @@ sub print_generic { "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n"; #$old_latex = 'true'; #@invoice_template = _translate_old_latex_format(@invoice_template); - } + } warn "$me print_generic creating T:T object\n" if $DEBUG > 1; @@ -374,7 +376,7 @@ sub print_generic { # additional substitution could possibly cause breakage in existing templates - my %convert_maps = ( + my %convert_maps = ( 'latex' => { 'notes' => sub { map "$_", @_ }, 'footer' => sub { map "$_", @_ }, @@ -386,7 +388,7 @@ sub print_generic { 'html' => { 'notes' => sub { - map { + map { s/%%(.*)$/<!-- $1 -->/g; s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g; s/\\begin\{enumerate\}/<ol>/g; @@ -406,7 +408,7 @@ sub print_generic { sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ }, 'returnaddress' => sub { - map { + map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; s/\\hyphenation\{[\w\s\-]+}//; @@ -420,7 +422,7 @@ sub print_generic { 'template' => { 'notes' => sub { - map { + map { s/%%.*$//g; s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g; s/\\begin\{enumerate\}//g; @@ -438,7 +440,7 @@ sub print_generic { sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ }, 'returnaddress' => sub { - map { + map { s/~/ /g; s/\\\\\*?\s*$/\n/; # dubious s/\\hyphenation\{[\w\s\-]+}//; @@ -548,7 +550,7 @@ 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 + # workaround for inconsistent behavior in the early plain text # templates; see RT#28271 'date' => ( $params{'no_date'} ? '' @@ -581,7 +583,7 @@ sub print_generic { 'smallernotes' => $conf->exists('invoice-smallernotes'), 'smallerfooter' => $conf->exists('invoice-smallerfooter'), 'balance_due_below_line' => $conf->exists('balance_due_below_line'), - + #layout info -- would be fancy to calc some of this and bury the template # here in the code 'topmargin' => scalar($conf->config('invoice_latextopmargin', $agentnum)), @@ -606,7 +608,7 @@ sub print_generic { #quotations have $name $invoice_data{'name'} = $invoice_data{'payname'}; - + #localization $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) }; # prototype here to silence warnings @@ -624,7 +626,7 @@ sub print_generic { $invoice_data{'bill_period'} = ''; $invoice_data{'bill_period'} = - $self->time2str_local('%e %h', $min_sdate, $format) + $self->time2str_local('%e %h', $min_sdate, $format) . " to " . $self->time2str_local('%e %h', $max_edate, $format) if ($max_edate != 0 && $min_sdate != 999999999999); @@ -634,7 +636,7 @@ sub print_generic { my $pkg_class = qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') }); $invoice_data{finance_section} = $pkg_class->categoryname; - } + } $invoice_data{finance_amount} = '0.00'; $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion @@ -651,7 +653,7 @@ sub print_generic { $invoice_data{'ship_contact'} = $escape_function->($cust_main->contact); $invoice_data{'ship_country'} = '' if ( $invoice_data{'ship_country'} eq $countrydefault ); - + $invoice_data{'cid'} = $params{'cid'} if $params{'cid'}; @@ -691,18 +693,34 @@ sub print_generic { $invoice_data{'barcode_cid'} = $params{'barcode_cid'} if $params{'barcode_cid'}; - my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance -# my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits - #my $balance_due = $self->owed + $pr_total - $cr_total; - my $balance_due = $self->owed; - if ( $self->enable_previous ) { - $balance_due += $pr_total; - } - # otherwise the previous balance is not shown, so including it in the - # balance due is just confusing - # the sum of amount owed on all invoices - # (this is used in the summary & on the payment coupon) + # re: rt:78190 + # using owed_on_invoice() instead of owed() here for $balance_due + # using _items_previous_total() instead of ->previous() for $pr_total + # + # owed_on_invoice() is aware of configuration flags that affect how an + # invoice is rendered. May not return actual current balance. Will + # return balance appropriate for the invoice being rendered, based + # on which past due items, current charges, and future payments are + # displayed. + # + # Going forward, usage of owed(), or bypassing cust_bill helper methods + # when generating invoice lines may lead to incorrect or misleading + # math on invoices. + # + # Helper methods that are aware of invoicing conf flags: + # - owed_on_invoice # use instead of owed() + # - _items_previous() # use instead of previous() + # - _items_credits() # use instead of cust_credit() + # - _items_payments() + # - _items_total() + # - _items_previous_total() # use instead of previous() + # - _items_payments_total() + # - _items_credits_total() # use instead of cust_credit() + + my $pr_total = $self->_items_previous_total(); + + my $balance_due = $self->owed_on_invoice(); $invoice_data{'balance'} = sprintf("%.2f", $balance_due); # flag telling this invoice to have a first-page summary @@ -712,127 +730,100 @@ sub print_generic { # XXX should be an FS::cust_bill method to set the defaults, instead # of checking the type here - # info from customer's last invoice before this one, for some + # info from customer's last invoice before this one, for some # summary formats $invoice_data{'last_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. - # - # still do this for the "Previous Balance" line of the summary block - my @sql = - map "$_ WHERE _date <= ? AND custnum = ?", ( - "SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill", - "SELECT -1 * COALESCE( SUM(amount), 0 ) FROM cust_credit", - "SELECT -1 * COALESCE( SUM(paid), 0 ) FROM cust_pay", - "SELECT COALESCE( SUM(refund), 0 ) FROM cust_refund", - ); - - # the customer's current balance immediately after generating the last - # bill - - my $last_bill_balance = $last_bill->charged; - foreach (@sql) { - my $delta = FS::Record->scalar_sql( - $_, - $last_bill->_date - 1, - $self->custnum, - ); - $last_bill_balance += $delta; - } + # my $last_bill = $self->previous_bill; + # if ( $last_bill ) { - $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; - - # Now, get all applications of credits/payments dated on or after the - # previous bill, to invoices before the current bill. (The - # credit/payment date restriction prevents these from intersecting - # the "Previous Balance" set.) - # These are "adjustments". The past due balance will be shown as - # Previous Balance - Adjustments. - my $adjustments = 0; - @sql = map { - "SELECT COALESCE(SUM(y.amount),0) FROM $_ JOIN cust_bill USING (invnum) - WHERE cust_bill._date < ? - AND x._date >= ? - AND cust_bill.custnum = ?" - } "cust_credit AS x JOIN cust_credit_bill y USING (crednum)", - "cust_pay AS x JOIN cust_bill_pay y USING (paynum)" - ; - foreach (@sql) { - my $delta = FS::Record->scalar_sql( - $_, - $self->_date, - $last_bill->_date, - $self->custnum, - ); - $adjustments += $delta; - } - $invoice_data{'balance_adjustments'} = sprintf("%.2f", $adjustments); + # Populate template stash for previous balance and payments + if ($pr_total) { + # Used on summary page as "Previous Balance" + $invoice_data{'true_previous_balance'} = sprintf("%.2f", $pr_total); - warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n", - $invoice_data{'balance_adjustments'} - ) if $DEBUG > 0; + # Used on summary page as "Payments" + $invoice_data{'balance_adjustments'} = sprintf("%.2f", + $self->_items_payments_total() + $self->_items_credits_total() + ); - # the sum of amount owed on all previous invoices - # ($pr_total is used elsewhere but not as $previous_balance) + # Used in invoice template as "Previous Balance" $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); - $invoice_data{'last_bill'}{'_date'} = $last_bill->_date; #unformatted - my (@payments, @credits); - # for formats that itemize previous payments - foreach my $cust_pay ( qsearch('cust_pay', { - 'custnum' => $self->custnum, - '_date' => { op => '>=', - value => $last_bill->_date } - } ) ) - { - next if $cust_pay->_date > $self->_date; - push @payments, { - '_date' => $cust_pay->_date, - 'date' => $self->time2str_local('long', $cust_pay->_date, $format), - 'payinfo' => $cust_pay->payby_payinfo_pretty, - 'amount' => sprintf('%.2f', $cust_pay->paid), - }; - # not concerned about applications - } - foreach my $cust_credit ( qsearch('cust_credit', { - 'custnum' => $self->custnum, - '_date' => { op => '>=', - value => $last_bill->_date } - } ) ) - { - next if $cust_credit->_date > $self->_date; - push @credits, { - '_date' => $cust_credit->_date, - 'date' => $self->time2str_local('long', $cust_credit->_date, $format), - 'creditreason'=> $cust_credit->reason, - 'amount' => sprintf('%.2f', $cust_credit->amount), - }; + # $invoice_data{last_bill}{_date}: + # Not used in default templates, but may be in use by someone + # + # ! May be a problem field if they are using it... this field + # stores the date of the previous invoice... it is possible to + # carry a balance, but have the immediately previous invoice paid off. + # In this case, this field might be presenting bad data? Not + # altering the problematic behavior, because someone might be + # expecting this bad behavior in their templates for some other + # purpose, such as a "your last bill was dated %_date%" + my $last_bill = $self->previous_bill; + $invoice_data{'last_bill'}{'_date'} + = ref $last_bill + ? $last_bill->_date() + : undef; + + # $invoice_data{previous_payments} + # Not used in default templates, but may be in use by someone + # + # Returns an array of hrefs representing payments, each with keys: + # - _date: epoch timestamp + # - date: text formatted date + # - amount: money formatted amount string + # - payinfo: string from payby_payinfo_pretty() + # - paynum: id for cust_pay + # - description: Text description for bill line item + # + my @payments = $self->_items_payments(); + $invoice_data{previous_payments} = \@payments; + + # $invoice_data{previous_credits} + # Not used in default templates, but may be in use by someone + # + # Returns an array of hrefs representing credits, each with keys: + # - _date: epoch timestamp + # - date: text formatted date + # - amount: money formatted amount string + # - crednum: id for cust_credit + # - description: Text description for bill line item + # - creditreason: reason() from cust_credit + # + my @credits = $self->_items_credits(); + $invoice_data{previous_credits} = \@credits; + + # Populate formatted date field + for my $pmt_href (@payments, @credits) { + $pmt_href->{date} = $self->time2str_local( + 'long', + $pmt_href->{_date}, + $format + ); } - $invoice_data{'previous_payments'} = \@payments; - $invoice_data{'previous_credits'} = \@credits; + } else { - # there is no $last_bill + # There are no outstanding invoices = YAPH $invoice_data{'true_previous_balance'} = $invoice_data{'balance_adjustments'} = $invoice_data{'previous_balance'} = '0.00'; - $invoice_data{'previous_payments'} = []; - $invoice_data{'previous_credits'} = []; + $invoice_data{'previous_payments'} = + $invoice_data{'previous_credits'} = []; + } + + # Condencing a lot of debug staements here + if ($DEBUG) { + warn "\$invoice_data{$_}: $invoice_data{$_}" + for qw( + true_previous_balance + balance_adjustments + previous_balance + previous_payments + previous_credits + ); } - + if ( $conf->exists('invoice_usesummary', $agentnum) ) { $invoice_data{'summarypage'} = $summarypage = 1; } @@ -900,9 +891,9 @@ sub print_generic { # if (well, probably when) we still need PO numbers in the brave new world of # 4.x, then we'll have to add them back as their own customer fields # # let invoices use either of these as needed -# $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') +# $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') # ? $cust_main->payinfo : ''; -# $invoice_data{'po_line'} = +# $invoice_data{'po_line'} = # ( $cust_main->payby eq 'BILL' && $cust_main->payinfo ) # ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo) # : $nbsp; @@ -950,30 +941,40 @@ sub print_generic { # default section ('Charges') my $default_section = { 'description' => '', - 'subtotal' => '', + 'subtotal' => '', 'no_subtotal' => 1, }; # Previous Charges section # subtotal is the first return value from $self->previous my $previous_section; - # if the invoice has major sections, or if we're summarizing previous + # if the invoice has major sections, or if we're summarizing previous # charges with a single line, or if we've been specifically told to put them # in a section, create a section for previous charges: if ( $multisection or $conf->exists('previous_balance-summary_only') or $conf->exists('previous_balance-section') ) { - + $previous_section = { 'description' => $self->mt('Previous Charges'), 'subtotal' => $other_money_char. sprintf('%.2f', $pr_total), 'summarized' => '', #why? $summarypage ? 'Y' : '', }; - $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. - join(' / ', map { $cust_main->balance_date_range(@$_) } - $self->_prior_month30s - ) - if $conf->exists('invoice_include_aging'); + + # Include balance aging line and template variables + my @aged_balances = $self->_items_aging_balances(); + ( $invoice_data{aged_balance_current}, + $invoice_data{aged_balance_30d}, + $invoice_data{aged_balance_60d}, + $invoice_data{aged_balance_90d} + ) = @aged_balances; + + if ($conf->exists('invoice_include_aging')) { + $previous_section->{posttotal} = sprintf( + '0 / 30 / 60 / 90 days overdue %.2f / %.2f / %.2f / %.2f', + @aged_balances, + ); + } } else { # otherwise put them in the main section @@ -1006,7 +1007,7 @@ sub print_generic { push @detail_items, @$extra_lines if $extra_lines; # the code is written so that both methods can be used together, but - # we haven't yet changed the template to take advantage of that, so for + # 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->config($tc.'sections_method') eq 'location' ) { @@ -1052,7 +1053,7 @@ sub print_generic { my @finance_charges; my @charges; foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { - if ( $invoice_data{finance_section} and + if ( $invoice_data{finance_section} and 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... @@ -1061,7 +1062,7 @@ sub print_generic { push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup; } } - $invoice_data{finance_amount} = + $invoice_data{finance_amount} = sprintf('%.2f', sum( @finance_charges ) || 0); $default_section->{subtotal} = $other_money_char. sprintf('%.2f', sum( @charges ) || 0); @@ -1115,7 +1116,7 @@ sub print_generic { #quantity => 1, # not really correct section => $previous_section, # which might be $default_section description => &$escape_function($line_item->{'description'}), - ext_description => [ map { &$escape_function($_) } + ext_description => [ map { &$escape_function($_) } @{ $line_item->{'ext_description'} || [] } ], amount => $money_char . $line_item->{'amount'}, @@ -1130,20 +1131,20 @@ sub print_generic { } - if ( @pr_cust_bill && $self->enable_previous ) { + if ( $pr_total && $self->enable_previous ) { push @buf, ['','-----------']; push @buf, [ $self->mt('Total Previous Balance'), $money_char. sprintf("%10.2f", $pr_total) ]; push @buf, ['','']; } - + if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) { warn "$me adding DID summary\n" if $DEBUG > 1; my ($didsummary,$minutes) = $self->_did_summary; my $didsummary_desc = 'DID Activity Summary (since last invoice)'; - push @detail_items, + push @detail_items, { 'description' => $didsummary_desc, 'ext_description' => [ $didsummary, $minutes ], }; @@ -1229,20 +1230,20 @@ sub print_generic { $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'}; } $line_item->{'ext_description'} ||= []; - + push @detail_items, $line_item; } if ( $section->{'description'} ) { push @buf, ( ['','-----------'], [ $section->{'description'}. ' sub-total', - $section->{'subtotal'} # already formatted this + $section->{'subtotal'} # already formatted this ], [ '', '' ], [ '', '' ], ); } - + } $invoice_data{current_less_finance} = @@ -1312,7 +1313,7 @@ sub print_generic { ]; } - + if ( @items_tax ) { my $total = {}; $total->{'total_item'} = $self->mt('Sub-total'); @@ -1368,7 +1369,7 @@ sub print_generic { } } - + if ( $self->can('_items_total') ) { # should always be true now # even for multisection, need plain text version @@ -1399,12 +1400,12 @@ sub print_generic { push @buf, [ '', '' ]; # if we're showing previous invoices, also show previous - # credits and payments - if ( $self->enable_previous + # credits and payments + if ( $self->enable_previous and $self->can('_items_credits') and $self->can('_items_payments') ) { - + # credits my $credittotal = 0; foreach my $credit ( @@ -1466,7 +1467,7 @@ sub print_generic { ]; } $invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal); - + if ( $multisection ) { $adjust_section->{'subtotal'} = $other_money_char. sprintf('%.2f', $credittotal + $paymenttotal); @@ -1475,21 +1476,21 @@ sub print_generic { #in @extra_sections instead of @sections. obviously. push @sections, $adjust_section unless $adjust_section->{sort_weight}; - # do not summarize; adjustments there are shown according to + # do not summarize; adjustments there are shown according to # different rules } # create Balance Due message - { + { my $total; $total->{'total_item'} = &$embolden_function($self->balance_due_msg); $total->{'total_amount'} = &$embolden_function( - $other_money_char. sprintf('%.2f', #why? $summarypage + $other_money_char. sprintf('%.2f', #why? $summarypage # ? $self->charged + # $self->billing_balance # : - $self->owed + $pr_total + $balance_due ) ); if ( $multisection && !$adjust_section->{sort_weight} ) { @@ -1499,7 +1500,7 @@ sub print_generic { push @total_items, $total; } push @buf,['','-----------']; - push @buf,[$self->balance_due_msg, $money_char. + push @buf,[$self->balance_due_msg, $money_char. sprintf("%10.2f", $balance_due ) ]; } @@ -1519,7 +1520,7 @@ sub print_generic { push @total_items, $credit_total; } push @buf,['','-----------']; - push @buf,[$self->credit_balance_msg, $money_char. + push @buf,[$self->credit_balance_msg, $money_char. sprintf("%10.2f", -$cust_main->balance ) ]; } } @@ -1535,7 +1536,7 @@ sub print_generic { $total->{'total_item'} = &$embolden_function($self->balance_due_msg); $total->{'total_amount'} = &$embolden_function( - $other_money_char. sprintf('%.2f', $self->owed + $pr_total) + $other_money_char. sprintf('%.2f', $balance_due) ); my $last_section = pop @sections; $last_section->{'posttotal'} = $total->{'total_item'}. ' '. @@ -1547,7 +1548,7 @@ sub print_generic { } # make a discounts-available section, even without multisection - if ( $conf->exists('discount-show_available') + if ( $conf->exists('discount-show_available') and my @discounts_avail = $self->_items_discounts_avail ) { my $discount_section = { 'description' => $self->mt('Discounts Available'), @@ -1579,7 +1580,7 @@ sub print_generic { } # invoice history "section" (not really a section) - # not to be included in any subtotals, completely independent of + # not to be included in any subtotals, completely independent of # everything... if ( $conf->exists('previous_invoice_history') and $cust_main->isa('FS::cust_main') ) { my %history; @@ -1610,7 +1611,7 @@ sub print_generic { } $invoice_data{location_info} = \%location_info; - # debugging hook: call this with 'diag' => 1 to just get a hash of + # debugging hook: call this with 'diag' => 1 to just get a hash of # the invoice variables return \%invoice_data if ( $params{'diag'} ); @@ -1635,7 +1636,7 @@ sub print_generic { @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g; s/--\@\]/$delimiters{$format}[1]/g; $_; - } + } &$convert_map( $conf->config($inc_file, $agentnum) ); } @@ -1677,7 +1678,7 @@ sub print_generic { #setup subroutine for the template $invoice_data{invoice_lines} = sub { my $lines = shift || scalar(@buf); - map { + map { scalar(@buf) ? shift @buf : [ '', '' ]; @@ -1722,22 +1723,6 @@ sub template_conf { warn "bare FS::Template_Mixin::template_conf"; 'invoice_'; } -# helper routine for generating date ranges -sub _prior_month30s { - my $self = shift; - my @ranges = ( - [ 1, 2592000 ], # 0-30 days ago - [ 2592000, 5184000 ], # 30-60 days ago - [ 5184000, 7776000 ], # 60-90 days ago - [ 7776000, 0 ], # 90+ days ago - ); - - map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '', - $_->[1] ? $self->_date - $_->[1] - 1 : '', - ] } - @ranges; -} - =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ] Returns an postscript invoice, as a scalar. @@ -1816,23 +1801,23 @@ sub print_html { my $self = shift; my %params; if ( ref($_[0]) ) { - %params = %{ shift() }; + %params = %{ shift() }; } else { %params = @_; } $params{'format'} = 'html'; - + $self->print_generic( %params ); } # quick subroutine for print_latex # # There are ten characters that LaTeX treats as special characters, which -# means that they do not simply typeset themselves: +# means that they do not simply typeset themselves: # # $ % & ~ _ ^ \ { } # # TeX ignores blanks following an escaped character; if you want a blank (as -# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). +# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ..."). sub _latex_escape { my $value = shift; @@ -1857,18 +1842,18 @@ sub _html_escape_nbsp { sub _translate_old_latex_format { warn "_translate_old_latex_format called\n" - if $DEBUG; + if $DEBUG; my @template = (); while ( @_ ) { my $line = shift; - + if ( $line =~ /^%%Detail\s*$/ ) { - + push @template, q![@--!, q! foreach my $_tr_line (@detail_items) {!, q! if ( scalar ($_tr_item->{'ext_description'} ) ) {!, - q! $_tr_line->{'description'} .= !, + q! $_tr_line->{'description'} .= !, q! "\\tabularnewline\n~~".!, q! join( "\\tabularnewline\n~~",!, q! @{$_tr_line->{'ext_description'}}!, @@ -1904,9 +1889,9 @@ sub _translate_old_latex_format { } else { $line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g; - push @template, $line; + push @template, $line; } - + } if ($DEBUG) { @@ -1926,7 +1911,7 @@ sub terms { #check for an invoice-specific override return $self->invoice_terms if $self->invoice_terms; - + #check for a customer- specific override my $cust_main = $self->cust_main; return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms; @@ -1980,7 +1965,7 @@ sub balance_due_msg { return $msg unless $self->terms; # huh? if ( !$self->conf->exists('invoice_show_prior_due_date') or $self->conf->exists('invoice_sections') ) { - # if enabled, the due date is shown with Total New Charges (see + # if enabled, the due date is shown with Total New Charges (see # _items_total) and not here # (yes, or if invoice_sections is enabled; this is just for compatibility) if ( $self->due_date ) { @@ -2012,7 +1997,7 @@ sub balance_due_date { $duedate; } -sub credit_balance_msg { +sub credit_balance_msg { my $self = shift; $self->mt('Credit Balance Remaining') } @@ -2180,7 +2165,7 @@ sub generate_email { if $DEBUG; @text = $conf->config($tc.'email_pdf_note'); $html = join('<BR>', @text); - + } # else use the plain text invoice } @@ -2242,7 +2227,7 @@ sub generate_email { 'Filename' => 'logo.png', 'Content-ID' => "<$content_id>", ; - + if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) { my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from"; push @related_parts, build MIME::Entity @@ -2283,7 +2268,7 @@ sub generate_email { 'Data' => [ '<html>', ' <head>', ' <title>', - ' '. encode_entities($return{'subject'}), + ' '. encode_entities($return{'subject'}), ' </title>', ' </head>', ' <body bgcolor="#e8e8e8">', @@ -2338,7 +2323,7 @@ sub generate_email { ; } else { # } elsif ( $conf->config('voip-cdr_email_attach') eq 'csv' ) { - + push @otherparts, build MIME::Entity 'Type' => 'text/csv', 'Encoding' => '7bit', @@ -2458,7 +2443,7 @@ sub postal_mail_fsinc { my $pages = CAM::PDF->new($file)->numPages; my $ua = LWP::UserAgent->new( - 'ssl_opts' => { + 'ssl_opts' => { verify_hostname => 0, SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, SSL_version => 'SSLv3', @@ -2515,13 +2500,13 @@ sub postal_mail_fsinc { Generate section information for all items appearing on this invoice. This will only be called for multi-section invoices. -For each line item (L<FS::cust_bill_pkg> record), this will fetch all -related display records (L<FS::cust_bill_pkg_display>) 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 line item (L<FS::cust_bill_pkg> record), this will fetch all +related display records (L<FS::cust_bill_pkg_display>) 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 +Section descriptions are returned in sort weight order. Each consists of a hash containing: description: the package category name, escaped @@ -2530,7 +2515,7 @@ 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 +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. @@ -2539,32 +2524,32 @@ sections. OPTIONS may include: -by_location: a flag to divide the invoice into sections by location. -Each section hash will have a 'location' element containing a hashref of +by_location: a flag to divide the invoice into sections by location. +Each section hash will have a 'location' element containing a hashref of the location fields (see L<FS::cust_location>). The section description -will be the location label, but the template can use any of the location +will be the location label, but the template can use any of the location fields to create a suitable label. -by_category: a flag to divide the invoice into sections using display -records (see L<FS::cust_bill_pkg_display>). This is the "traditional" +by_category: a flag to divide the invoice into sections using display +records (see L<FS::cust_bill_pkg_display>). This is the "traditional" behavior. Each section hash will have a 'category' element containing -the section name from the display record (which probably equals the +the section name from the display record (which probably equals the category name of the package, but may not in some cases). summary: a flag indicating that this is a summary-format invoice. Turning this on has the following effects: - Ignores display items with the 'summary' flag. - Places all sections in the "early" group even if they have post_total. -- Creates sections for all non-disabled package categories, even if they +- 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 +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 +format: 'latex', 'html', or 'template' (i.e. text). Not used, but passed through to C<_condense_section()>. =cut @@ -2573,7 +2558,7 @@ use vars qw(%pkg_category_cache); sub _items_sections { my $self = shift; my %opt = @_; - + my $escape = $opt{escape}; my @extra_sections = @{ $opt{extra_sections} || [] }; @@ -2586,12 +2571,12 @@ sub _items_sections { my %not_tax = (); # About tax items + multisection invoices: - # If either invoice_*summary option is enabled, AND there is a - # package category with the name of the tax, then there will be + # If either invoice_*summary option is enabled, AND there is a + # package category with the name of the tax, then there will be # a display record assigning the tax item to that category. # # However, the taxes are always placed in the "Taxes, Surcharges, - # and Fees" section regardless of that. The only effect of the + # and Fees" section regardless of that. The only effect of the # display record is to create a subtotal for the summary page. # cache these @@ -2630,11 +2615,11 @@ sub _items_sections { 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 - # $late_subtotal{$section}. keys(%late_subtotal) is later used + # incrementing $late_subtotal{$section} CREATES + # $late_subtotal{$section}. keys(%late_subtotal) is later used # to define the list of late sections, and likewise keys(%subtotal). - # When _items_cust_bill_pkg is called to generate line items for - # real, it will be called with 'section' => $section for each + # When _items_cust_bill_pkg is called to generate line items for + # real, it will be called with 'section' => $section for each # of these. if ( $display->post_total && !$opt{summary} ) { if (! $type || $type eq 'S') { @@ -2654,7 +2639,7 @@ sub _items_sections { if $cust_bill_pkg->recur != 0 || $cust_bill_pkg->recur_show_zero; } - + if ($type && $type eq 'U') { $late_subtotal{$locationnum}{$section} += $usage unless scalar(@extra_sections); @@ -2716,10 +2701,10 @@ sub _items_sections { $section->{'locationnum'} = $locationnum; my $location = FS::cust_location->by_key($locationnum); $section->{'description'} = &{ $escape }($location->location_label); - # Better ideas? This will roughly group them by proximity, + # Better ideas? This will roughly group them by proximity, # which alpha sorting on any of the address fields won't. # Sorting by locationnum is meaningless. - # We have to sort on _something_ or the order may change + # We have to sort on _something_ or the order may change # randomly from one invoice to the next, which will confuse # people. $section->{'sort_weight'} = sprintf('%012s',$location->zip) . @@ -2748,10 +2733,10 @@ sub _items_sections { } # foreach $sectionname } #foreach $locationnum push @these, @extra_sections if $post_total == 0; - # need an alpha sort for location sections, because postal codes can + # need an alpha sort for location sections, because postal codes can # be non-numeric $sections[ $post_total ] = [ sort { - $opt{'by_location'} ? + $opt{'by_location'} ? ($a->{sort_weight} cmp $b->{sort_weight}) : ($a->{sort_weight} <=> $b->{sort_weight}) } @these ]; @@ -2996,8 +2981,8 @@ sub _condensed_total_line_generator { =item _items_pkg [ OPTIONS ] -Return line item hashes for each package item on this invoice. Nearly -equivalent to +Return line item hashes for each package item on this invoice. Nearly +equivalent to $self->_items_cust_bill_pkg([ $self->cust_bill_pkg ]) @@ -3065,7 +3050,7 @@ sub _items_fee { } } # 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) { @@ -3153,7 +3138,7 @@ sub _taxsort { sub _items_tax { my $self = shift; - my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart } + my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart } $self->cust_bill_pkg; my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); @@ -3182,7 +3167,7 @@ escape_function: the function used to escape strings. DEPRECATED? (expensive, mostly unused?) format_function: the function used to format CDRs. -section: a hashref containing 'category' and/or 'locationnum'; if this +section: a hashref containing 'category' and/or 'locationnum'; if this is present, only returns line items that belong to that category and/or location (whichever is defined). @@ -3191,8 +3176,8 @@ which does something complicated. Returns a list of hashrefs, each of which may contain: -pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and -ext_description, which is an arrayref of detail lines to show below +pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and +ext_description, which is an arrayref of detail lines to show below the package line. =cut @@ -3278,13 +3263,13 @@ sub _items_cust_bill_pkg { # this is a location section; skip packages that aren't at this # service location. next if $cust_bill_pkg->pkgnum == 0; # skips fees... - next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum + next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum != $locationnum; } # Consider display records for this item to determine if it belongs # in this section. Note that if there are no display records, there - # will be a default pseudo-record that includes all charge types + # will be a default pseudo-record that includes all charge types # and has no section name. my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display') ? $cust_bill_pkg->cust_bill_pkg_display @@ -3301,7 +3286,7 @@ sub _items_cust_bill_pkg { @cust_bill_pkg_display; } else { # otherwise, process all display records that aren't usage summaries - # (I don't think there should be usage summaries if you aren't using + # (I don't think there should be usage summaries if you aren't using # category sections, but this is the historical behavior) @cust_bill_pkg_display = grep { !$_->summary } @cust_bill_pkg_display; @@ -3332,14 +3317,14 @@ sub _items_cust_bill_pkg { warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n" if $DEBUG > 1; - + my $cust_pkg = $cust_bill_pkg->cust_pkg; my $part_pkg = $cust_pkg->part_pkg; # which pkgpart to show for display purposes? my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart; - # start/end dates for invoice formats that do nonstandard + # start/end dates for invoice formats that do nonstandard # things with them my %item_dates = (); %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate') @@ -3360,7 +3345,7 @@ sub _items_cust_bill_pkg { if $DEBUG > 1; # append the word 'Setup' to the setup line if there's going to be - # a recur line for the same package (i.e. not a one-time charge) + # a recur line for the same package (i.e. not a one-time charge) # XXX localization my $description = $desc; $description .= ' Setup' @@ -3382,7 +3367,7 @@ sub _items_cust_bill_pkg { unless ( $part_pkg->hide_svc_detail ) { - # still pass the svc_label through to the template, even if + # still pass the svc_label through to the template, even if # not displaying it as an ext_description @svc_labels = map &{$escape_function}($_), $cust_pkg->h_labels_short($self->_date, @@ -3499,7 +3484,7 @@ sub _items_cust_bill_pkg { # or this is a usage summary line || $is_summary && $type && $type eq 'U' # or this is a usage line and there's a recurring line - # for the package in the same section (which will + # for the package in the same section (which will # have service labels already) || ($type eq 'U' and defined($r)) ) @@ -3525,17 +3510,17 @@ sub _items_cust_bill_pkg { # 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 + # 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') + if ( $conf->exists('svc_acct-usage_seconds') and ! $cust_bill_pkg->pkgpart_override ) { - foreach my $cust_svc ( - $cust_pkg->h_cust_svc(@dates, 'I') + 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 + # 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]); @@ -3560,7 +3545,7 @@ sub _items_cust_bill_pkg { warn "$me _items_cust_bill_pkg calculating amount\n" if $DEBUG > 1; - + my $amount = 0; if (!$type) { $amount = $cust_bill_pkg->recur; @@ -3569,7 +3554,7 @@ sub _items_cust_bill_pkg { } elsif ($type eq 'U') { $amount = $cust_bill_pkg->usage; } - + if ( !$type || $type eq 'R' ) { warn "$me _items_cust_bill_pkg adding recur\n" @@ -3637,7 +3622,7 @@ sub _items_cust_bill_pkg { # items of this kind should normally not have sdate/edate. push @b, { 'description' => $desc, - 'amount' => sprintf('%.2f', $cust_bill_pkg->setup + 'amount' => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur) }; @@ -3650,7 +3635,7 @@ sub _items_cust_bill_pkg { # 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 + # 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 ) ) { @@ -3667,7 +3652,7 @@ sub _items_cust_bill_pkg { $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} }; } - # update the active line (before the discount) to show the + # update the active line (before the discount) to show the # original price (whether this is a hidden line or not) $s->{amount} -= $item_discount->{setup_amount} if $s; @@ -3712,9 +3697,9 @@ sub _items_cust_bill_pkg { =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 +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 @@ -3722,7 +3707,7 @@ a setup fee if the discount is allowed to apply to setup fees. sub _items_discounts_avail { my $self = shift; - #maybe move this method from cust_bill when quotations support discount_plans + #maybe move this method from cust_bill when quotations support discount_plans return () unless $self->can('discount_plans'); my %plans = $self->discount_plans; @@ -3734,7 +3719,7 @@ sub _items_discounts_avail { my $plan = $plans{$months}; my $term_total = sprintf('%.2f', $plan->discounted_total); - my $percent = sprintf('%.0f', + my $percent = sprintf('%.0f', 100 * (1 - $term_total / $plan->base_total) ); my $permonth = sprintf('%.2f', $term_total / $months); my $detail = $self->mt('discount on item'). ' '. @@ -3747,7 +3732,7 @@ sub _items_discounts_avail { +{ description => $self->mt('Save [_1]% by paying for [_2] months', $percent, $months), - amount => $self->mt('[_1] ([_2] per month)', + amount => $self->mt('[_1] ([_2] per month)', $term_total, $money_char.$permonth), ext_description => ($detail || ''), } diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 36ecbea3a..5ae4f3686 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -11,6 +11,7 @@ use Fcntl qw(:flock); #for spool_csv use Cwd; use List::Util qw(min max sum); use Date::Format; +use DateTime; use File::Temp 0.14; use HTML::Entities; use Storable qw( freeze thaw ); @@ -105,13 +106,13 @@ Deprecated fields =over 4 =item billing_balance - the customer's balance immediately before generating -this invoice. DEPRECATED. Use the L<FS::cust_main/balance_date> method +this invoice. DEPRECATED. Use the L<FS::cust_main/balance_date> method to determine the customer's balance at a specific time. =item previous_balance - the customer's balance immediately after generating the invoice before this one. DEPRECATED. -=item printed - formerly used to track the number of times an invoice had +=item printed - formerly used to track the number of times an invoice had been printed; no longer used. =back @@ -163,7 +164,7 @@ sub notice_name { $self->conf->config('notice_name') || 'Invoice' } -sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } +sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } sub cust_unlinked_msg { my $self = shift; "WARNING: can't find cust_main.custnum ". $self->custnum. @@ -279,15 +280,15 @@ sub void { # internal-only and discourage use # # =item delete -# +# # DO NOT USE THIS METHOD. Instead, apply a credit against the invoice, or use # the B<void> method. -# +# # This is only for internal use by V<void>, which is what you should be using. -# +# # DO NOT USE THIS METHOD. Whatever reason you think you have is almost certainly # wrong. Use B<void>, that's what it is for. Really. This means you. -# +# # =cut sub _delete { @@ -460,9 +461,30 @@ sub previous_bill { $self->get('previous_bill'); } +=item following_bill + +Returns the customer's invoice that follows this one + +=cut + +sub following_bill { + my $self = shift; + if (!$self->get('following_bill')) { + $self->set('following_bill', qsearchs({ + table => 'cust_bill', + hashref => { + custnum => $self->custnum, + invnum => { op => '>', value => $self->invnum }, + }, + order_by => 'ORDER BY invnum ASC LIMIT 1', + })); + } + $self->get('following_bill'); +} + =item previous -Returns a list consisting of the total previous balance for this customer, +Returns a list consisting of the total previous balance for this customer, followed by the previous outstanding invoices (as FS::cust_bill objects also). =cut @@ -477,7 +499,7 @@ sub previous { qsearch( 'cust_bill', { 'custnum' => $self->custnum, #'_date' => { op=>'<', value=>$self->_date }, 'invnum' => { op=>'<', value=>$self->invnum }, - } ) + } ) ; foreach ( @cust_bill ) { $total += $_->owed; } $self->set('previous', [$total, @cust_bill]); @@ -618,7 +640,7 @@ sub num_cust_event { my $sql = "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ". " WHERE tablenum = ? AND eventtable = 'cust_bill'"; - my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql"; + my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql"; $sth->execute($self->invnum) or die $sth->errstr. " executing $sql"; $sth->fetchrow_arrayref->[0]; } @@ -638,8 +660,8 @@ Returns a list: an empty list on success or a list of errors. sub suspend { my $self = shift; - grep { $_->suspend(@_) } - grep {! $_->getfield('cancel') } + grep { $_->suspend(@_) } + grep {! $_->getfield('cancel') } $self->cust_pkg; } @@ -690,7 +712,7 @@ sub cancel { grep { $_ } map { $_->cancel(%opt) } - grep { ! $_->getfield('cancel') } + grep { ! $_->getfield('cancel') } @pkgs; } @@ -822,7 +844,7 @@ sub cust_bill_batch { =item discount_plans -Returns all discount plans (L<FS::discount_plan>) for this invoice, as a +Returns all discount plans (L<FS::discount_plan>) for this invoice, as a hash keyed by term length. =cut @@ -865,6 +887,35 @@ sub owed { $balance; } +=item owed_on_invoice + +Returns the amount to be displayed as the "Balance Due" on this +invoice. Amount returned depends on conf flags for invoicing + +See L<FS::cust_bill::owed> for the true amount currently owed + +=cut + +sub owed_on_invoice { + my $self = shift; + + #return $self->owed() + # unless $self->conf->exists('previous_balance-payments_since') + + # Add charges from this invoice + my $owed = $self->charged(); + + # Add carried balances from previous invoices + # If previous items aren't to be displayed on the invoice, + # _items_previous() is aware of this and responds appropriately. + $owed += $_->{amount} for $self->_items_previous(); + + # Subtract payments and credits displayed on this invoice + $owed -= $_->{amount} for $self->_items_payments(), $self->_items_credits(); + + return $owed; +} + sub owed_pkgnum { my( $self, $pkgnum ) = @_; @@ -927,7 +978,7 @@ sub apply_payments_and_credits { $self->select_for_update; #mutex - my @payments = grep { $_->unapplied > 0 } + my @payments = grep { $_->unapplied > 0 } grep { !$_->no_auto_apply } $self->cust_main->cust_pay; my @credits = grep { $_->credited > 0 } $self->cust_main->cust_credit; @@ -956,7 +1007,7 @@ sub apply_payments_and_credits { ); my $max_credit_weight = max( map { $_->part_pkg->credit_weight || 0 } - grep { $_ } + grep { $_ } map { $_->cust_pkg } @open_lineitems ); @@ -967,7 +1018,7 @@ sub apply_payments_and_credits { } else { $app = 'credit'; } - + } elsif ( @payments ) { $app = 'pay'; } elsif ( @credits ) { @@ -1041,7 +1092,7 @@ I<from> overrides the default email invoice From: address. I<amount>: obsolete, does nothing -I<notice_name> overrides "Invoice" as the name of the sent document +I<notice_name> overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required). I<lpr> overrides the system 'lpr' option as the command to print a document @@ -1118,7 +1169,7 @@ sub queueable_email { $self->set('mode', $opt{mode}) if $opt{mode}; - my %args = map {$_ => $opt{$_}} + my %args = map {$_ => $opt{$_}} grep { $opt{$_} } qw( from notice_name no_coupon template ); @@ -1155,7 +1206,7 @@ sub pdf_filename { Returns the postscript or plaintext for this invoice as an arrayref. -Options must be passed as a hashref. Positional parameters are no longer +Options must be passed as a hashref. Positional parameters are no longer allowed. I<template>, if specified, is the name of a suffix for alternate invoices. @@ -1248,7 +1299,7 @@ sub fax_invoice { =item batch_invoice [ HASHREF ] -Place this invoice into the open batch (see C<FS::bill_batch>). If there +Place this invoice into the open batch (see C<FS::bill_batch>). If there isn't an open batch, one will be created. HASHREF may contain any options to be passed to C<print_pdf>. @@ -1292,7 +1343,7 @@ sub get_open_bill_batch { return $batch; } -=item ftp_invoice [ TEMPLATENAME ] +=item ftp_invoice [ TEMPLATENAME ] Sends this invoice data via FTP. @@ -1315,7 +1366,7 @@ sub ftp_invoice { ); } -=item spool_invoice [ TEMPLATENAME ] +=item spool_invoice [ TEMPLATENAME ] Spools this invoice data (see L<FS::spool_csv>) @@ -1364,7 +1415,7 @@ sub send_csv { # don't localize dates here, they're a defined format my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time); my $file = "$spooldir/$tracctnum.csv"; - + my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum ); open(CSV, ">$file") or die "can't open $file: $!"; @@ -1469,7 +1520,7 @@ sub spool_csv { } $file = "$spooldir/$file.csv"; - + my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum); open(CSV, ">>$file") or die "can't open $file: $!"; @@ -1514,7 +1565,7 @@ detail information for this invoice. If I<format> is not specified or "default", the fields of the CSV file are as follows: -record_type, invnum, custnum, _date, charged, first, last, company, address1, +record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate =over 4 @@ -1620,7 +1671,7 @@ If I<format> is "billco", the fields of the detail CSV file are as follows: 9 | Grouping Code | GROUP | CHAR | 2 10 | User Defined | ACCT CODE | CHAR | 15 -If format is 'oneline', there is no detail file. Each invoice has a +If format is 'oneline', there is no detail file. Each invoice has a header line only, with the fields: Agent number, agent name, customer number, first name, last name, address @@ -1630,21 +1681,21 @@ amount charged, amount due, previous balance, due date. and then, for each line item, three columns containing the package number, description, and amount. -If format is 'bridgestone', there is no detail file. Each invoice has a +If format is 'bridgestone', there is no detail file. Each invoice has a header line with the following fields in a fixed-width format: Customer number (in display format), date, name (first last), company, address 1, address 2, city, state, zip. This is a mailing list format, and has no per-invoice fields. To avoid -sending redundant notices, the spooling event should have a "once" or +sending redundant notices, the spooling event should have a "once" or "once_percust_every" condition. =cut sub print_csv { my($self, %opt) = @_; - + eval "use Text::CSV_XS"; die $@ if $@; @@ -1655,6 +1706,9 @@ sub print_csv { my $time = $opt{'time'} || time; + $self->set('_template', $opt{template}) + if exists $opt{template}; + my $tracctnum = ''; #leaking out from billco-specific sections :/ if ( $format eq 'billco' ) { @@ -1717,8 +1771,8 @@ sub print_csv { ); } elsif ( $format eq 'oneline' ) { #name - - my ($previous_balance) = $self->previous; + + my ($previous_balance) = $self->previous; $previous_balance = sprintf('%.2f', $previous_balance); my $totaldue = sprintf('%.2f', $self->owed + $previous_balance); my @items = map { @@ -1855,7 +1909,7 @@ sub print_csv { my $classnum = $cust_svc->part_svc->classnum; my $part_svc_class = FS::part_svc_class->by_key($classnum) if $classnum; - $svc_class{$svcpart} = $part_svc_class ? + $svc_class{$svcpart} = $part_svc_class ? $part_svc_class->classname : ''; } @@ -1894,7 +1948,7 @@ sub print_csv { return join('', $header, @details, "\n"); } else { # default - + $csv->combine( 'cust_bill', $self->invnum, @@ -1954,7 +2008,7 @@ sub print_csv { my($pkg, $setup, $recur, $sdate, $edate); if ( $cust_bill_pkg->pkgnum ) { - + ($pkg, $setup, $recur, $sdate, $edate) = ( $cust_bill_pkg->part_pkg->pkg, ( $cust_bill_pkg->setup != 0 @@ -1963,21 +2017,21 @@ sub print_csv { ( $cust_bill_pkg->recur != 0 ? sprintf("%.2f", $cust_bill_pkg->recur ) : '' ), - ( $cust_bill_pkg->sdate + ( $cust_bill_pkg->sdate ? time2str("%x", $cust_bill_pkg->sdate) : '' ), - ($cust_bill_pkg->edate + ($cust_bill_pkg->edate ? time2str("%x", $cust_bill_pkg->edate) : '' ), ); - + } else { #pkgnum tax next unless $cust_bill_pkg->setup != 0; $pkg = $cust_bill_pkg->desc; $setup = sprintf('%10.2f', $cust_bill_pkg->setup ); ( $sdate, $edate ) = ( '', '' ); } - + $csv->combine( 'cust_bill_pkg', $self->invnum, @@ -2096,7 +2150,7 @@ sub batch_card { my $cust_main = $self->cust_main; $options{invnum} = $self->invnum; - + $cust_main->batch_card(%options); } @@ -2120,7 +2174,7 @@ PNG file name is returned. Otherwise, the PNG image itself is returned. sub invoice_barcode { my ($self, $dir) = (shift,shift); - + my $gdbar = new GD::Barcode('Code39',$self->invnum); die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar; my $gd = $gdbar->plot(Height => 30); @@ -2215,13 +2269,13 @@ sub _items_extra_usage_sections { foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) { my $amount = $detail->amount; next unless $amount && $amount > 0; - + $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 }; $sections{$section}{amount} += $amount; #subtotal $sections{$section}{calls}++; $sections{$section}{duration} += $detail->duration; - my $desc = $detail->regionname; + my $desc = $detail->regionname; my $description = $desc; $description = substr($desc, 0, $maxlength). '...' if $format eq 'latex' && length($desc) > $maxlength; @@ -2263,7 +2317,7 @@ sub _items_extra_usage_sections { qw( description_generator header_generator total_generator total_line_generator ) ) : () - ), + ), }; } @@ -2310,10 +2364,10 @@ sub _did_summary { my $phone_inserted = $h_cust_svc->h_svc_x($inserted+5); my $phone_deleted; $phone_deleted = $h_cust_svc->h_svc_x($deleted) if $deleted; - + # DID either activated or ported in; cannot be both for same DID simultaneously if ($inserted >= $start && $inserted <= $end && $phone_inserted - && (!$phone_inserted->lnp_status + && (!$phone_inserted->lnp_status || $phone_inserted->lnp_status eq '' || $phone_inserted->lnp_status eq 'native')) { $num_activated++; @@ -2321,21 +2375,21 @@ sub _did_summary { else { # this one not so clean, should probably move to (h_)svc_phone local($FS::Record::qsearch_qualify_columns) = 0; my $phone_portedin = qsearchs( 'h_svc_phone', - { 'svcnum' => $h_cust_svc->svcnum, - 'lnp_status' => 'portedin' }, - FS::h_svc_phone->sql_h_searchs($end), + { 'svcnum' => $h_cust_svc->svcnum, + 'lnp_status' => 'portedin' }, + FS::h_svc_phone->sql_h_searchs($end), ); $num_portedin++ if $phone_portedin; } # DID either deactivated or ported out; cannot be both for same DID simultaneously if($deleted >= $start && $deleted <= $end && $phone_deleted - && (!$phone_deleted->lnp_status + && (!$phone_deleted->lnp_status || $phone_deleted->lnp_status ne 'portingout')) { $num_deactivated++; - } - elsif($deleted >= $start && $deleted <= $end && $phone_deleted - && $phone_deleted->lnp_status + } + elsif($deleted >= $start && $deleted <= $end && $phone_deleted + && $phone_deleted->lnp_status && $phone_deleted->lnp_status eq 'portingout') { $num_portedout++; } @@ -2388,8 +2442,8 @@ sub _items_accountcode_cdr { foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) { $section->{'header'} = $detail->formatted('format' => $format) - if($detail->detail eq $section->{'header'}); - + if($detail->detail eq $section->{'header'}); + my $accountcode = $detail->accountcode; next unless $accountcode; @@ -2472,7 +2526,7 @@ sub _items_svc_phone_sections { $sections{$phonenum}{calls}++; $sections{$phonenum}{duration} += $detail->duration; - my $desc = $detail->regionname; + my $desc = $detail->regionname; my $description = $desc; $description = substr($desc, 0, $maxlength). '...' if $format eq 'latex' && length($desc) > $maxlength; @@ -2561,7 +2615,7 @@ sub _items_svc_phone_sections { total_line_generator ) ) - ), + ), }; } @@ -2580,8 +2634,8 @@ sub _items_svc_phone_sections { push @lines, $l; } } - - if($conf->exists('phone_usage_class_summary')) { + + if($conf->exists('phone_usage_class_summary')) { # this only works with Latex my @newlines; my @newsections; @@ -2607,7 +2661,7 @@ sub _items_svc_phone_sections { }; $calls_detail{'description'} = 'Calls Detail: ' . $section->{'phonenum'}; - push @newsections, \%calls_detail; + push @newsections, \%calls_detail; } } @@ -2616,7 +2670,7 @@ sub _items_svc_phone_sections { foreach my $newsection ( @newsections ) { if($newsection->{'post_total'}) { # this means Calls Summary foreach my $section ( @sections ) { - next unless ($section->{'phonenum'} eq $newsection->{'phonenum'} + next unless ($section->{'phonenum'} eq $newsection->{'phonenum'} && !$section->{'post_total'}); my $newdesc = $section->{'description'}; my $tn = $section->{'phonenum'}; @@ -2666,7 +2720,7 @@ sub _items_svc_phone_sections { =sub _items_usage_class_summary OPTIONS -Returns a list of detail items summarizing the usage charges on this +Returns a list of detail items summarizing the usage charges on this invoice. Each one will have 'amount', 'description' (the usage charge name), and 'usage_classnum'. @@ -2713,207 +2767,784 @@ sub _items_usage_class_summary { return @l; } +=sub _items_previous() + + Returns an array of hashrefs, each hashref representing a line-item on + the current bill for previous unpaid invoices. + + keys for each previous_item: + - amount (see notes) + - pkgnum + - description + - invnum + - _date + + Payments and credits shown on this invoice may vary based on configuraiton. + + when conf flag previous_balance-payments_since is set: + This method works backwards to rebuild the invoice as a snapshot in time. + The invoice displayed will have the balances owed, and payments made, + reflecting the state of the account at the time of invoice generation. + +=cut + sub _items_previous { + my $self = shift; - my $conf = $self->conf; - my $cust_main = $self->cust_main; - my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance - my @b = (); - foreach ( @pr_cust_bill ) { - my $date = $conf->exists('invoice_show_prior_due_date') - ? 'due '. $_->due_date2str('short') - : $self->time2str_local('short', $_->_date); - push @b, { - 'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)", - #'pkgpart' => 'N/A', - 'pkgnum' => 'N/A', - 'amount' => sprintf("%.2f", $_->owed), - }; + + # simple memoize + if ($self->get('_items_previous')) { + return sort { $a->{_date} <=> $b->{_date} } + values %{ $self->get('_items_previous') }; + } + + # Gets the customer's current balance and outstanding invoices. + my ($prev_balance, @open_invoices) = $self->previous; + + my %invoices = map { + $_->invnum => $self->__items_previous_map_invoice($_) + } @open_invoices; + + # Which credits and payments displayed on the bill will vary based on + # conf flag previous_balance-payments_since. + my @credits = $self->_items_credits(); + my @payments = $self->_items_payments(); + + + if ($self->conf->exists('previous_balance-payments_since')) { + # For each credit or payment, determine which invoices it was applied to. + # Manipulate data displayed so the invoice displayed appears as a + # snapshot in time... with previous balances and balance owed displayed + # as they were at the time of invoice creation. + + my @credits_postbill = $self->_items_credits_postbill(); + my @payments_postbill = $self->_items_payments_postbill(); + + my %pmnt_dupechk; + my %cred_dupechk; + + # Each section below follows this pattern on a payment/credit + # + # - Dupe check, avoid adjusting for the same item twice + # - If invoice being adjusted for isn't in our list, add it + # - Adjust the invoice balance to refelct balnace without the + # credit or payment applied + # + + # Working with payments displayed on this bill + for my $pmt_hash (@payments) { + my $pmt_obj = qsearchs('cust_pay', {paynum => $pmt_hash->{paynum}}); + for my $cust_bill_pay ($pmt_obj->cust_bill_pay) { + next if exists $pmnt_dupechk{$cust_bill_pay->billpaynum}; + $pmnt_dupechk{$cust_bill_pay->billpaynum} = 1; + + my $invnum = $cust_bill_pay->invnum; + + $invoices{$invnum} = $self->__items_previous_get_invoice($invnum) + unless exists $invoices{$invnum}; + + $invoices{$invnum}->{amount} += $cust_bill_pay->amount; + } + } + + # Working with credits displayed on this bill + for my $cred_hash (@credits) { + my $cred_obj = qsearchs('cust_credit', {crednum => $cred_hash->{crednum}}); + for my $cust_credit_bill ($cred_obj->cust_credit_bill) { + next if exists $cred_dupechk{$cust_credit_bill->creditbillnum}; + $cred_dupechk{$cust_credit_bill->creditbillnum} = 1; + + my $invnum = $cust_credit_bill->invnum; + + $invoices{$invnum} = $self->__items_previous_get_invoice($invnum) + unless exists $invoices{$invnum}; + + $invoices{$invnum}->{amount} += $cust_credit_bill->amount; + } + } + + # Working with both credits and payments which are not displayed + # on this bill, but which have affected this bill's balances + for my $postbill (@payments_postbill, @credits_postbill) { + + if ($postbill->{billpaynum}) { + next if exists $pmnt_dupechk{$postbill->{billpaynum}}; + $pmnt_dupechk{$postbill->{billpaynum}} = 1; + } elsif ($postbill->{creditbillnum}) { + next if exists $cred_dupechk{$postbill->{creditbillnum}}; + $cred_dupechk{$postbill->{creditbillnum}} = 1; + } else { + die "Missing creditbillnum or billpaynum"; + } + + my $invnum = $postbill->{invnum}; + + $invoices{$invnum} = $self->__items_previous_get_invoice($invnum) + unless exists $invoices{$invnum}; + + $invoices{$invnum}->{amount} += $postbill->{amount}; + } + + # Make sure current invoice doesn't appear in previous items + delete $invoices{$self->invnum} + if exists $invoices{$self->invnum}; + } - @b; - #{ - # 'description' => 'Previous Balance', - # #'pkgpart' => 'N/A', - # 'pkgnum' => 'N/A', - # 'amount' => sprintf("%10.2f", $pr_total ), - # 'ext_description' => [ map { - # "Invoice ". $_->invnum. - # " (". time2str("%x",$_->_date). ") ". - # sprintf("%10.2f", $_->owed) - # } @pr_cust_bill ], + # Make sure amount is formatted as a dollar string + # (Formatting should happen on the template side, but is not?) + $invoices{$_}->{amount} = sprintf('%.2f',$invoices{$_}->{amount}) + for keys %invoices; + + $self->set('_items_previous', \%invoices); + return sort { $a->{_date} <=> $b->{_date} } values %invoices; - #}; } +=sub _items_previous_total + + Return sum of amounts from all items returned by _items_previous + Results will vary based on invoicing conf flags + +=cut + +sub _items_previous_total { + my $self = shift; + my $tot = 0; + $tot += $_->{amount} for $self->_items_previous(); + return $tot; +} + +sub __items_previous_get_invoice { + # Helper function for _items_previous + # + # Read a record from cust_bill, return a hash of it's information + my ($self, $invnum) = @_; + die "Incorrect usage of __items_previous_get_invoice()" unless $invnum; + + my $cust_bill = qsearchs('cust_bill', {invnum => $invnum}); + return $self->__items_previous_map_invoice($cust_bill); +} + +sub __items_previous_map_invoice { + # Helper function for _items_previous + # + # Transform a cust_bill object into a simple hash reference of the type + # required by _items_previous + my ($self, $cust_bill) = @_; + die "Incorrect usage of __items_previous_map_invoice" unless ref $cust_bill; + + my $date = $self->conf->exists('invoice_show_prior_due_date') + ? 'due '.$cust_bill->due_date2str('short') + : $self->time2str_local('short', $cust_bill->_date); + + return { + invnum => $cust_bill->invnum, + amount => $cust_bill->owed, + pkgnum => 'N/A', + _date => $cust_bill->_date, + description => join(' ', + $self->mt('Previous Balance, Invoice #'), + $cust_bill->invnum, + "($date)" + ), + } +} + +=sub _items_credits() + + Return array of hashrefs containing credits to be shown as line-items + when rendering this bill. + + keys for each credit item: + - crednum: id of payment + - amount: payment amount + - description: line item to be displayed on the bill + + This method has three ways it selects which credits to display on + this bill: + + 1) Default Case: No Conf flag for 'previous_balance-payments_since' + + Returns credits that have been applied to this bill only + + 2) Case: + Conf flag set for 'previous_balance-payments_since' + + List all credits that have been recorded during the time period + between the timestamps of the last invoice and this invoice + + 3) Case: + Conf flag set for 'previous_balance-payments_since' + $opt{'template'} eq 'statement' + + List all payments that have been recorded between the timestamps + of the previous invoice and the following invoice. + + This is used to give the customer a receipt for a payment + in the form of their last bill with the payment amended. + + I am concerned with this implementation, but leaving in place as is + If this option is selected, while viewing an older bill, the old bill + will show ALL future credits for future bills, but no charges for + future bills. Somebody could be misled into believing they have a + large account credit when they don't. Also, interrupts the chain of + invoices as an account history... the customer could have two invoices + in their fileing cabinet, for two different dates, both with a line item + for the same duplicate credit. The accounting is technically accurate, + but somebody could easily become confused and think two credits were + made, when really those two line items on two different bills represent + only a single credit + +=cut + sub _items_credits { - my( $self, %opt ) = @_; - my $trim_len = $opt{'trim_len'} || 40; - - my @b; - #credits - my @objects; - if ( $self->conf->exists('previous_balance-payments_since') ) { - if ( $opt{'template'} eq 'statement' ) { - # then the current bill is a "statement" (i.e. an invoice sent as - # a payment receipt) - # and in that case we want to see payments on or after THIS invoice - @objects = qsearch('cust_credit', { - 'custnum' => $self->custnum, - '_date' => {op => '>=', value => $self->_date}, - }); + + my $self= shift; + + # Simple memoize + return @{$self->get('_items_credits')} if $self->get('_items_credits'); + + my %opt = @_; + my $template = $opt{template} || $self->get('_template'); + my $trim_len = $opt{template} || $self->get('trim_len') || 40; + + my @return; + my @cust_credit_objs; + + if ($self->conf->exists('previous_balance-payments_since')) { + if ($template eq 'statement') { + # Case 3 (see above) + # Return credits timestamped between the previous and following bills + + my $previous_bill = $self->previous_bill; + my $following_bill = $self->following_bill; + + my $date_start = ref $previous_bill ? $previous_bill->_date : 0; + my $date_end = ref $following_bill ? $following_bill->_date : undef; + + my %query = ( + table => 'cust_credit', + hashref => { + custnum => $self->custnum, + _date => { op => '>=', value => $date_start }, + }, + ); + $query{extra_sql} = " AND _date <= $date_end " if $date_end; + + @cust_credit_objs = qsearch(\%query); + } else { - my $date = 0; - $date = $self->previous_bill->_date if $self->previous_bill; - @objects = qsearch('cust_credit', { - 'custnum' => $self->custnum, - '_date' => {op => '>=', value => $date}, + # Case 2 (see above) + # Return credits timestamps between this and the previous bills + + my $date_start = 0; + my $date_end = $self->_date; + + my $previous_bill = $self->previous_bill; + if (ref $previous_bill) { + $date_start = $previous_bill->_date; + } + + @cust_credit_objs = qsearch({ + table => 'cust_credit', + hashref => { + custnum => $self->custnum, + _date => {op => '>=', value => $date_start}, + }, + extra_sql => " AND _date <= $date_end ", }); } + } else { - @objects = $self->cust_credited; + # Case 1 (see above) + # Return only credits that have been applied to this bill + + @cust_credit_objs = $self->cust_credited; + } - foreach my $obj ( @objects ) { + # Translate objects into hashrefs + foreach my $obj ( @cust_credit_objs ) { my $cust_credit = $obj->isa('FS::cust_credit') ? $obj : $obj->cust_credit; + my %r_obj = ( + amount => sprintf('%.2f',$cust_credit->amount), + crednum => $cust_credit->crednum, + _date => $cust_credit->_date, + creditreason => $cust_credit->reason, + ); my $reason = substr($cust_credit->reason, 0, $trim_len); $reason .= '...' if length($reason) < length($cust_credit->reason); - $reason = " ($reason) " if $reason; - - push @b, { - #'description' => 'Credit ref\#'. $_->crednum. - # " (". time2str("%x",$_->cust_credit->_date) .")". - # $reason, - 'description' => $self->mt('Credit applied').' '. - $self->time2str_local('short', $obj->_date). $reason, - 'amount' => sprintf("%.2f",$obj->amount), - }; + $reason = "($reason)" if $reason; + + $r_obj{description} = join(' ', + $self->mt('Credit applied'), + $self->time2str_local('short', $cust_credit->_date), + $reason, + ); + + push @return, \%r_obj; } + $self->set('_items_credits',\@return); + @return; +} + +=sub _items_credits_total + + Return the total of al items from _items_credits + Will vary based on invoice display conf flag - @b; +=cut + +sub _items_credits_total { + my $self = shift; + my $tot = 0; + $tot += $_->{amount} for $self->_items_credits(); + return $tot; +} + + + +=sub _items_credits_postbill() + + Returns an array of hashrefs for credits where + - Credit issued after this invoice + - Credit applied to an invoice before this invoice + + Returned hashrefs are of the format returned by _items_credits() + +=cut + +sub _items_credits_postbill { + my $self = shift; + + my @cust_credit_bill = qsearch({ + table => 'cust_credit_bill', + select => join(', ',qw( + cust_credit_bill.creditbillnum + cust_credit_bill._date + cust_credit_bill.invnum + cust_credit_bill.amount + )), + addl_from => ' LEFT JOIN cust_credit'. + ' ON (cust_credit_bill.crednum = cust_credit.crednum) ', + extra_sql => ' WHERE cust_credit.custnum = '.$self->custnum. + ' AND cust_credit_bill._date > '.$self->_date. + ' AND cust_credit_bill.invnum < '.$self->invnum.' ', +#! did not investigate why hashref doesn't work for this join query +# hashref => { +# 'cust_credit.custnum' => {op => '=', value => $self->custnum}, +# 'cust_credit_bill._date' => {op => '>', value => $self->_date}, +# 'cust_credit_bill.invnum' => {op => '<', value => $self->invnum}, +# }, + }); + return map {{ + _date => $_->_date, + invnum => $_->invnum, + amount => $_->amount, + creditbillnum => $_->creditbillnum, + }} @cust_credit_bill; } +=sub _items_payments_postbill() + + Returns an array of hashrefs for payments where + - Payment occured after this invoice + - Payment applied to an invoice before this invoice + + Returned hashrefs are of the format returned by _items_payments() + +=cut + +sub _items_payments_postbill { + my $self = shift; + + my @cust_bill_pay = qsearch({ + table => 'cust_bill_pay', + select => join(', ',qw( + cust_bill_pay.billpaynum + cust_bill_pay._date + cust_bill_pay.invnum + cust_bill_pay.amount + )), + addl_from => ' LEFT JOIN cust_bill'. + ' ON (cust_bill_pay.invnum = cust_bill.invnum) ', + extra_sql => ' WHERE cust_bill.custnum = '.$self->custnum. + ' AND cust_bill_pay._date > '.$self->_date. + ' AND cust_bill_pay.invnum < '.$self->invnum.' ', + }); + + return map {{ + _date => $_->_date, + invnum => $_->invnum, + amount => $_->amount, + billpaynum => $_->billpaynum, + }} @cust_bill_pay; +} + +=sub _items_payments() + + Return array of hashrefs containing payments to be shown as line-items + when rendering this bill. + + keys for each payment item: + - paynum: id of payment + - amount: payment amount + - description: line item to be displayed on the bill + + This method has three ways it selects which payments to display on + this bill: + + 1) Default Case: No Conf flag for 'previous_balance-payments_since' + + Returns payments that have been applied to this bill only + + 2) Case: + Conf flag set for 'previous_balance-payments_since' + + List all payments that have been recorded between the timestamps + of the previous invoice and this invoice + + 3) Case: + Conf flag set for 'previous_balance-payments_since' + $opt{'template'} eq 'statement' + + List all payments that have been recorded between the timestamps + of the previous invoice and the following invoice. + + I am concerned with this implementation, but leaving in place as is + If this option is selected, while viewing an older bill, the old bill + will show ALL future payments for future bills, but no charges for + future bills. Somebody could be misled into believing they have a + large account credit when they don't. Also, interrupts the chain of + invoices as an account history... the customer could have two invoices + in their fileing cabinet, for two different dates, both with a line item + for the same duplicate payment. The accounting is technically accurate, + but somebody could easily become confused and think two payments were + made, when really those two line items on two different bills represent + only a single payment. + +=cut + sub _items_payments { + my $self = shift; + + # Simple memoize + return @{$self->get('_items_payments')} if $self->get('_items_payments'); + my %opt = @_; + my $template = $opt{template} || $self->get('_template'); + + my @return; + my @cust_pay_objs; + + my $c_invoice_payment_details = $self->conf->exists('invoice_payment_details'); + + if ($self->conf->exists('previous_balance-payments_since')) { + if ($template eq 'statement') { +print "\nCASE 3\n"; + # Case 3 (see above) + # Return payments timestamped between the previous and following bills + + my $previous_bill = $self->previous_bill; + my $following_bill = $self->following_bill; + + my $date_start = ref $previous_bill ? $previous_bill->_date : 0; + my $date_end = ref $following_bill ? $following_bill->_date : undef; + + my %query = ( + table => 'cust_pay', + hashref => { + custnum => $self->custnum, + _date => { op => '>=', value => $date_start }, + }, + ); + $query{extra_sql} = " AND _date <= $date_end " if $date_end; + + @cust_pay_objs = qsearch(\%query); - my @b; - my $detailed = $self->conf->exists('invoice_payment_details'); - my @objects; - if ( $self->conf->exists('previous_balance-payments_since') ) { - # then show payments dated on/after the previous bill... - if ( $opt{'template'} eq 'statement' ) { - # then the current bill is a "statement" (i.e. an invoice sent as - # a payment receipt) - # and in that case we want to see payments on or after THIS invoice - @objects = qsearch('cust_pay', { - 'custnum' => $self->custnum, - '_date' => {op => '>=', value => $self->_date}, - }); } else { - # the normal case: payments on or after the previous invoice - my $date = 0; - $date = $self->previous_bill->_date if $self->previous_bill; - @objects = qsearch('cust_pay', { - 'custnum' => $self->custnum, - '_date' => {op => '>=', value => $date}, + # Case 2 (see above) + # Return payments timestamped between this and the previous bill +print "\nCASE 2\n"; + my $date_start = 0; + my $date_end = $self->_date; + + my $previous_bill = $self->previous_bill; + if (ref $previous_bill) { + $date_start = $previous_bill->_date; + } + + @cust_pay_objs = qsearch({ + table => 'cust_pay', + hashref => { + custnum => $self->custnum, + _date => {op => '>=', value => $date_start}, + }, + extra_sql => " AND _date <= $date_end ", }); - # and before the current bill... - @objects = grep { $_->_date < $self->_date } @objects; } + } else { - @objects = $self->cust_bill_pay; + # Case 1 (see above) + # Return payments applied only to this bill + + @cust_pay_objs = $self->cust_bill_pay; + } - foreach my $obj (@objects) { + $self->set( + '_items_payments', + [ $self->__items_payments_make_hashref(@cust_pay_objs) ] + ); + return @{ $self->get('_items_payments') }; +} + +=sub _items_payments_total + + Return a total of all records returned by _items_payments + Results vary based on invoicing conf flags + +=cut + +sub _items_payments_total { + my $self = shift; + my $tot = 0; + $tot += $_->{amount} for $self->_items_payments(); + return $tot; +} + +sub __items_payments_make_hashref { + # Transform a FS::cust_pay object into a simple hashref for invoice + my ($self, @cust_pay_objs) = @_; + my $c_invoice_payment_details = $self->conf->exists('invoice_payment_details'); + my @return; + + for my $obj (@cust_pay_objs) { + + # In case we're passed FS::cust_bill_pay (or something else?) + # Below, we use $obj to render amount rather than $cust_apy. + # If we were passed cust_bill_pay objs, then: + # $obj->amount represents the amount applied to THIS invoice + # $cust_pay->amount represents the total payment, which may have + # been applied accross several invoices. + # If we were passed cust_bill_pay objects, then the conf flag + # previous_balance-payments_since is NOT set, so we should not + # present any payments not applied to this invoice. my $cust_pay = $obj->isa('FS::cust_pay') ? $obj : $obj->cust_pay; - my $desc = $self->mt('Payment received').' '. - $self->time2str_local('short', $cust_pay->_date ); - $desc .= $self->mt(' via ') . - $cust_pay->payby_payinfo_pretty( $self->cust_main->locale ) - if $detailed; - - push @b, { - 'description' => $desc, - 'amount' => sprintf("%.2f", $obj->amount ) - }; - } - @b; + my %r_obj = ( + _date => $cust_pay->_date, + amount => sprintf("%.2f", $obj->amount), + paynum => $cust_pay->paynum, + payinfo => $cust_pay->payby_payinfo_pretty(), + description => join(' ', + $self->mt('Payment received'), + $self->time2str_local('short', $cust_pay->_date), + ), + ); -} + if ($c_invoice_payment_details) { + $r_obj{description} = join(' ', + $r_obj{description}, + $self->mt('via'), + $cust_pay->payby_payinfo_pretty($self->cust_main->locale), + ); + } + + push @return, \%r_obj; + } + return @return; +} + +=sub _items_total() + + Generate the line-items to be shown on the bill in the "Totals" section + + Returns a list of hashrefs, each with the keys: + - total_item: description field + - total_amount: dollar-formatted number amount + + Information presented by this method varies based on Conf + + Conf previous_balance-payments_due + - default, flag not set + Only transactions that were applied to this bill bill be + displayed and calculated intothe total. If items exist in + the past-due section, those items will disappear from this + invoice if they have been paid off. + + - previous_balance-payments_due flag is set + Transactions occuring after the timestsamp of this + invoice are not reflected on invoice line items + + Only payments/credits applied between the previous invoice + and this one are displayed and calculated into the total + + - previous_balance-payments_due && $opt{template} eq 'statement' + Same as above, except payments/credits occuring before the date + of the following invoice are also displayed and calculated into + the total + + Conf previous_balance-exclude_from_total + - default, flag not set + The "Totals" section contains a single line item. + The dollar amount of this line items is a sum of old and new charges + - previous_balance-exclude_from_total flag is set + The "Totals" section contains two line items. + One for previous balance, one for new charges + !NOTE: Avent virtualization flag 'disable_previous_balance' can + override the global conf flag previous_balance-exclude_from_total + + Conf invoice_show_prior_due_date + - default, flag not set + Total line item in the "Totals" section does not mention due date + - invoice_show_prior_due_date flag is set + Total line item in the "Totals" section includes either the due + date of the invoice, or the specified invoice terms + ? Not sure why this is called "Prior" due date, since we seem to be + displaying THIS due date... +=cut sub _items_total { my $self = shift; my $conf = $self->conf; - my @items; - my ($pr_total) = $self->previous; - my ($previous_charges_desc, $new_charges_desc, $new_charges_amount); + my $c_multi_line_total = 0; + $c_multi_line_total = 1 + if $conf->exists('previous_balance-exclude_from_total') + && $self->enable_previous(); - if ( $conf->exists('previous_balance-exclude_from_total') ) { - # if enabled, specifically add a line for the previous balance total - $previous_charges_desc = $self->mt( - $conf->config('previous_balance-text') || 'Previous Balance' - ); + my @line_items; + my $invoice_charges = $self->charged(); - # then return separate lines for previous balance and total new charges - if ( $pr_total ) { - push @items, - { total_item => $previous_charges_desc, - total_amount => sprintf('%.2f',$pr_total) - }; - } - } + # _items_previous() is aware of conf flags + my $previous_balance = 0; + $previous_balance += $_->{amount} for $self->_items_previous(); + + my $total_charges; + my $total_descr; + + if ( $previous_balance && $c_multi_line_total ) { + # previous balance, new charges on separate lines - if ( $conf->exists('previous_balance-exclude_from_total') - or !$self->enable_previous ) { - # show new charges only + push @line_items, { + total_amount => sprintf('%.2f',$previous_balance), + total_item => $self->mt( + $conf->config('previous_balance-text') || 'Previous Balance' + ), + }; - $new_charges_desc = $self->mt( + $total_charges = $invoice_charges; + $total_descr = $self->mt( $conf->config('previous_balance-text-total_new_charges') - || 'Total New Charges' + || 'Total New Charges' ); - $new_charges_amount = $self->charged; - } else { - # show new charges + previous invoice total - - $new_charges_desc = $self->mt('Total Charges'); - if ( $self->enable_previous ) { - $new_charges_amount = sprintf('%.2f', $self->charged + $pr_total); - } else { - $new_charges_amount = sprintf('%.2f', $self->charged); - } - + # previous balance and new charges combined into a single total line + $total_charges = $invoice_charges + $previous_balance; + $total_descr = $self->mt('Total Charges'); } if ( $conf->exists('invoice_show_prior_due_date') ) { # then the due date should be shown with Total New Charges, # and should NOT be shown with the Balance Due message. + if ( $self->due_date ) { - # localize the "Please pay by" message and the date itself - # (grammar issues with this, yeah) - $new_charges_desc .= ' - ' . $self->mt('Please pay by') . ' ' . - $self->due_date2str('short'); + $total_descr = join(' ', + $total_descr, + '-', + $self->mt('Please pay by'), + $self->due_date2str('short') + ); } elsif ( $self->terms ) { - # phrases like "due on receipt" should be localized - $new_charges_desc .= ' - ' . $self->mt($self->terms); + $total_descr = join(' ', + $total_descr, + '-', + $self->mt($self->terms) + ); } } - push @items, - { total_item => $new_charges_desc, - total_amount => $new_charges_amount, - }; + push @line_items, { + total_amount => sprintf('%.2f', $total_charges), + total_item => $total_descr, + }; - @items; + return @line_items; } +=item _items_aging_balances + + Returns an array of aged balance amounts from a given epoch timestamp. + + The time of day is ignored for this calculation, so that slight differences + on the generation time of an invoice doesn't determine which column an + aged balance falls into. + + Will not include any balances dated after the given timestamp in + the calculated totals + usage: + @aged_balances = $b->_items_aging_balances( $b->_date ) + + @aged_balances = ( + under30d, + 30d-60d, + 60d-90d, + over90d + ) + +=cut + +sub _items_aging_balances { + my ($self, $basetime) = @_; + die "Incorrect usage of _items_aging_balances()" unless ref $self; + + $basetime = $self->_date unless $basetime; + my @aging_balances = (0, 0, 0, 0); + my @open_invoices = $self->_items_previous(); + my $d30 = 2592000; # 60 * 60 * 24 * 30, + my $d60 = 5184000; # 60 * 60 * 24 * 60, + my $d90 = 7776000; # 60 * 60 * 24 * 90 + + # Move the clock back on our given day to 12:00:01 AM + my $dt_basetime = DateTime->from_epoch(epoch => $basetime); + my $dt_12am = DateTime->new( + year => $dt_basetime->year, + month => $dt_basetime->month, + day => $dt_basetime->day, + hour => 0, + minute => 0, + second => 1, + )->epoch(); + + # set our epoch breakpoints + $_ = $dt_12am - $_ for $d30, $d60, $d90; + + # grep the aged balances + for my $oinv (@open_invoices) { + if ($oinv->{_date} <= $basetime && $oinv->{_date} > $d30) { + # If post invoice dated less than 30days ago + $aging_balances[0] += $oinv->{amount}; + } elsif ($oinv->{_date} <= $d30 && $oinv->{_date} > $d60) { + # If past invoice dated between 30-60 days ago + $aging_balances[1] += $oinv->{amount}; + } elsif ($oinv->{_date} <= $d60 && $oinv->{_date} > $d90) { + # If past invoice dated between 60-90 days ago + $aging_balances[2] += $oinv->{amount}; + } else { + # If past invoice dated 90+ days ago + $aging_balances[3] += $oinv->{amount}; + } + } + + return map{ sprintf('%.2f',$_) } @aging_balances; +} =item call_details [ OPTION => VALUE ... ] @@ -2933,7 +3564,7 @@ sub call_details { my $row = shift; $row->amount ? $row->phonenum. ",". $detail : '"Billed number",'. $detail; - + }; } @@ -3022,7 +3653,7 @@ sub process_re_X { } -# this is called from search/cust_bill.html and given all its search +# this is called from search/cust_bill.html and given all its search # parameters, so it needs to perform the same search. sub re_X { @@ -3040,7 +3671,7 @@ sub re_X { delete $query->{'count_query'}; delete $query->{'count_addl'}; - $query->{debug} = 1; # was in here before, is obviously useful + $query->{debug} = 1; # was in here before, is obviously useful my @cust_bill = qsearch( $query ); @@ -3090,8 +3721,8 @@ Returns an SQL fragment to retreive the amount owed (charged minus credited and sub owed_sql { my ($class, $start, $end) = @_; - 'charged - '. - $class->paid_sql($start, $end). ' - '. + 'charged - '. + $class->paid_sql($start, $end). ' - '. $class->credited_sql($start, $end); } @@ -3180,4 +3811,3 @@ documentation. =cut 1; - diff --git a/conf/invoice_htmlsummary b/conf/invoice_htmlsummary index 47bdbfb7c..249db9b07 100644 --- a/conf/invoice_htmlsummary +++ b/conf/invoice_htmlsummary @@ -9,29 +9,20 @@ <table class="invoice_summary"> <tr><th colspan=2><br></th></tr> <tr> - <td><b><u><br>Summary of Previous Balance and Payments<br></u></b></td> + <td><b><u><br>Summary of Previous Balance<br></u></b></td> <td></td> </tr> <tr> <td><b>Previous Balance</b></td> - <td align="right"><b><%= $dollar.$true_previous_balance %></b></td> - </tr> - <tr> - <td><b>Payments</b></td> - <th align="right"><b><%= $dollar.$balance_adjustments %></b></th> - </tr> - <tr> - <td><b>Balance Outstanding</b></td> - <td align="right"><b><%= $dollar.sprintf('%.2f', $true_previous_balance - $balance_adjustments) %></b></td> + <td align=right><b><%= "${dollar}${true_previous_balance}" %></b></td> </tr> <tr><th colspan=2><br></th></tr> <tr><td colspan=2><br></td></tr> <tr> <td><b><u>Summary of New Charges</u></b></td> - <td></td> </tr> <tr><td colspan=2><br></td></tr> - <%= + <%= my $last = $summary_subtotals[-1]; foreach my $section (@summary_subtotals) { $OUT .= '<tr><td><b>'. ($section->{'description'} ? $section->{'description'} : 'Charges' ). '</b></td>'; @@ -44,15 +35,23 @@ <td align="right"><b><%= $dollar.$current_less_finance %></b></td> </tr> <tr><th colspan=2><br></th></tr> + <tr> + <td><b><u><br>Summary of Payments and Credits<br></u></b></td> + <td></td> + </tr> + <tr> + <td><b>Payments and Credits</b></td> + <td align="right"><b>-<%= $dollar.$balance_adjustments %></b></td> + </tr> + <tr><th colspan=2><br></th></tr> <tr><td colspan=2><br></td></tr> <tr> <td><b><u>Invoice Summary</u></b></td> - <td></td> </tr> <tr><td colspan=2><br></td></tr> <tr> <td><b>Previous Past Due Charges</b></td> - <td align="right"><b><%= $dollar.sprintf('%.2f', $true_previous_balance - $balance_adjustments) %></b></td> + <td align="right"><b><%= $dollar.$true_previous_balance %></b></td> </tr> <tr> <td><b>Finance charges on overdue amount</b></td> @@ -60,18 +59,18 @@ </tr> <tr> <td><b>New Charges</b></td> - <th align="right"><b><%= $dollar.$current_less_finance %></b></th> + <td align="right"><b><%= $dollar.$current_less_finance %></b></td> </tr> - - <%= - - #false laziness w/invoice_latexsummary and above + <%= foreach my $section ( grep $_->{adjust_section}, @sections) { $OUT .= '<tr><td><b>'. ($section->{'description'} ? $section->{'description'} : 'Charges' ). '</b></td>'; $OUT .= qq(<th align="right"><b>). $section->{'subtotal'}. "</b></th></tr>"; } %> - + <tr> + <td><b>Payments and Credits</b></td> + <th align="right"><b>-<%= $dollar.sprintf('%.2f', $balance_adjustments) %></b></th> + </tr> <tr> <td><b>Total Amount Due</b></td> <td align="right"><b><%= $dollar.sprintf('%.2f', $balance) %></b></td> |