X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main%2FBilling.pm;h=b278fe4103d92f05691fdc2de1e244006e441086;hb=dd9a0ea1c8351841c8d41ab46e94abbdb0c75db4;hp=b7deeddcfff0febb0b85a923a6a72e77b2096b3c;hpb=f32c1ac0d63ea1f3967b7be045beb3cc34be0b4d;p=freeside.git diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index b7deeddcf..b278fe410 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -202,6 +202,9 @@ sub cancel_expired_pkgs { my @errors = (); + my @really_cancel_pkgs; + my @cancel_reasons; + CUST_PKG: foreach my $cust_pkg ( @cancel_pkgs ) { my $cpr = $cust_pkg->last_cust_pkg_reason('expire'); my $error; @@ -219,14 +222,22 @@ sub cancel_expired_pkgs { $error = '' if ref $error eq 'FS::cust_pkg'; } else { # just cancel it - $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum, - 'reason_otaker' => $cpr->otaker, - 'time' => $time, - ) - : () - ); + + push @really_cancel_pkgs, $cust_pkg; + push @cancel_reasons, $cpr; + } - push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error; + } + + if (@really_cancel_pkgs) { + + my %cancel_opt = ( 'cust_pkg' => \@really_cancel_pkgs, + 'cust_pkg_reason' => \@cancel_reasons, + 'time' => $time, + ); + + push @errors, $self->cancel_pkgs(%cancel_opt); + } join(' / ', @errors); @@ -810,56 +821,66 @@ sub bill { } #discard bundled packages of 0 value +# XXX we should reconsider whether we even need this sub _omit_zero_value_bundles { my @in = @_; - my @cust_bill_pkg = (); - my @cust_bill_pkg_bundle = (); - my $sum = 0; - my $discount_show_always = 0; - + my @out = (); + my @bundle = (); + my $discount_show_always = $conf->exists('discount-show-always'); + my $show_this = 0; + + # Sort @in the same way we do during invoice rendering, so we can identify + # bundles. See FS::Template_Mixin::_items_nontax. + @in = sort { $a->pkgnum <=> $b->pkgnum or + $a->sdate <=> $b->sdate or + ($a->pkgpart_override ? 0 : -1) or + ($b->pkgpart_override ? 0 : 1) or + $b->hidden cmp $a->hidden or + $a->pkgpart_override <=> $b->pkgpart_override + } @in; + + # this is a pack-and-deliver pattern. every time there's a cust_bill_pkg + # _without_ pkgpart_override, that's the start of the new bundle. if there's + # an existing bundle, and it contains a nonzero amount (or a zero amount + # that's displayable anyway), push all line items in the bundle. foreach my $cust_bill_pkg ( @in ) { - $discount_show_always = ($cust_bill_pkg->get('discounts') - && scalar(@{$cust_bill_pkg->get('discounts')}) - && $conf->exists('discount-show-always')); - - warn " pkgnum ". $cust_bill_pkg->pkgnum. " sum $sum, ". - "setup_show_zero ". $cust_bill_pkg->setup_show_zero. - "recur_show_zero ". $cust_bill_pkg->recur_show_zero. "\n" - if $DEBUG > 0; - - if (scalar(@cust_bill_pkg_bundle) && !$cust_bill_pkg->pkgpart_override) { - push @cust_bill_pkg, @cust_bill_pkg_bundle - if $sum > 0 - || ($sum == 0 && ( $discount_show_always - || grep {$_->recur_show_zero || $_->setup_show_zero} - @cust_bill_pkg_bundle - ) - ); - @cust_bill_pkg_bundle = (); - $sum = 0; + if (scalar(@bundle) and !$cust_bill_pkg->pkgpart_override) { + # ship out this bundle and reset it + if ( $show_this ) { + push @out, @bundle; + } + @bundle = (); + $show_this = 0; } - $sum += $cust_bill_pkg->setup + $cust_bill_pkg->recur; - push @cust_bill_pkg_bundle, $cust_bill_pkg; + # add this item to the current bundle + push @bundle, $cust_bill_pkg; + # determine if it makes the bundle displayable + if ( $cust_bill_pkg->setup > 0 + or $cust_bill_pkg->recur > 0 + or $cust_bill_pkg->setup_show_zero + or $cust_bill_pkg->recur_show_zero + or ($discount_show_always + and scalar(@{ $cust_bill_pkg->get('discounts')}) + ) + ) { + $show_this++; + } } - push @cust_bill_pkg, @cust_bill_pkg_bundle - if $sum > 0 - || ($sum == 0 && ( $discount_show_always - || grep {$_->recur_show_zero || $_->setup_show_zero} - @cust_bill_pkg_bundle - ) - ); + # last bundle + if ( $show_this) { + push @out, @bundle; + } warn " _omit_zero_value_bundles: ". scalar(@in). - '->'. scalar(@cust_bill_pkg). "\n" #. Dumper(@cust_bill_pkg). "\n" + '->'. scalar(@out). "\n" #. Dumper(@out). "\n" if $DEBUG > 2; - (@cust_bill_pkg); - + @out; } =item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME @@ -914,6 +935,7 @@ sub calculate_taxes { #.Dumper($self, $cust_bill_pkg, $taxlisthash, $invoice_time). "\n" if $DEBUG > 2; + my $custnum = $self->custnum; # The main tax accumulator. One bin for each tax name (itemdesc). # For each subdivision of tax under this name, push a cust_bill_pkg item # for the calculated tax into the arrayref. @@ -929,10 +951,15 @@ sub calculate_taxes { # values are arrayrefs of cust_tax_exempt_pkg objects my %tax_exemption; + # For tax on tax calculation, we need to remember which taxable items + # (and charge classes) had which taxes applied to them. + # # keys are cust_bill_pkg objects (taxable items) # values are hashrefs - # keys are taxlisthash keys - # values are the taxlines generated for those taxes + # keys are charge classes + # values are hashrefs + # keys are taxnums (in tax_rate only; cust_main_county doesn't use this) + # values are the taxlines generated for those taxes tie my %item_has_tax, 'Tie::RefHash', map { $_ => {} } @$cust_bill_pkg; @@ -941,6 +968,7 @@ sub calculate_taxes { my $taxables = $taxlisthash->{$tax_id}; my $tax_object = shift @$taxables; + my $taxnum = $tax_object->taxnum; # $tax_object is a cust_main_county or tax_rate # (with billpkgnum, pkgnum, locationnum set) # the rest of @{ $taxlisthash->{$tax_id} } is cust_bill_pkg objects, @@ -957,34 +985,35 @@ sub calculate_taxes { if ( $tax_object->isa('FS::tax_rate') ) { # EXTERNAL TAXES # STILL have tax_rate-specific crap in here... my @taxlines = $tax_object->taxline( $taxables, - 'custnum' => $self->custnum, + 'custnum' => $custnum, 'invoice_time' => $invoice_time, 'exemptions' => $exemptions, ); next if !@taxlines; if (!ref $taxlines[0]) { # it's an error string - warn "error evaluating $tax_id on custnum ".$self->custnum."\n"; + warn "error evaluating $tax_id on custnum $custnum\n"; return $taxlines[0]; } foreach my $taxline (@taxlines) { push @{ $taxname{ $taxline->itemdesc } }, $taxline; my $link = $taxline->get('cust_bill_pkg_tax_rate_location')->[0]; my $taxable_item = $link->taxable_cust_bill_pkg; - $item_has_tax{$taxable_item}->{$tax_id} = $taxline; + $item_has_tax{$taxable_item}{$taxline->_class}{$taxnum} = $taxline; } + } else { # INTERNAL TAXES # we can do this in a single taxline, because it's not stupid my $taxline = $tax_object->taxline( $taxables, - 'custnum' => $self->custnum, + 'custnum' => $custnum, 'invoice_time' => $invoice_time, 'exemptions' => $exemptions, ); next if !$taxline; if (!ref $taxline) { # it's an error string - warn "error evaluating $tax_id on custnum ".$self->custnum."\n"; + warn "error evaluating $tax_id on custnum $custnum\n"; return $taxline; } # if the calculated tax is zero, don't even keep it @@ -1001,48 +1030,55 @@ sub calculate_taxes { my $this_has_tax = $item_has_tax{$taxable_item}; my $location = $taxable_item->tax_location; - foreach my $tax_id (keys %$this_has_tax) { - my ($class, $taxnum) = split(' ', $tax_id); - # internal taxes don't support tax_on_tax, so we don't bother with - # them here. - next unless $class eq 'FS::tax_rate'; - - # for each tax item that was calculated in phase 1, get the - # tax definition - my $tax_object = FS::tax_rate->by_key($taxnum); - # and find all taxes that apply to it in this location - my @tot = $tax_object->tax_on_tax( $location ); - next if !@tot; - warn "found possible taxed taxnum $taxnum\n" - if $DEBUG > 2; - # Calculate ToT separately for each taxable item, and only if _that - # item_ is already taxed under the ToT. This is counterintuitive. - # See RT#5243. - foreach my $tot (@tot) { - my $tot_id = ref($tot) . ' ' . $tot->taxnum; - warn "checking taxnum ".$tot->taxnum. - " which we call ". $tot->taxname ."\n" + + foreach my $charge_class (keys %$this_has_tax) { + # taxes that apply to this item and charge class + my $this_class_has_tax = $this_has_tax->{$charge_class}; + foreach my $taxnum (keys %$this_class_has_tax) { + + # for each tax item that was calculated in phase 1, get the + # tax definition + my $tax_object = FS::tax_rate->by_key($taxnum); + # and find all taxes that apply to it in this location + my @tot = $tax_object->tax_on_tax( $location ); + next if !@tot; + warn "found possible taxed taxnum $taxnum\n" if $DEBUG > 2; - if ( exists $this_has_tax->{ $tot_id } ) { - warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n" - if $DEBUG; - my @taxlines = $tot->taxline( - $this_has_tax->{ $tax_id }, # the first-stage tax - 'custnum' => $self->custnum, - 'invoice_time' => $invoice_time, - ); - next if (!@taxlines); # it didn't apply after all - if (!ref($taxlines[0])) { - warn "error evaluating $tot_id TOT on custnum ". - $self->custnum."\n"; - return $taxlines[0]; - } - foreach my $taxline (@taxlines) { - push @{ $taxname{ $taxline->itemdesc } }, $taxline; - } - } # if $has_tax - } # foreach my $tot (tax-on-tax rate definition) - } # foreach $taxnum (first-tier rate definition) + # Calculate ToT separately for each taxable item and class, and only + # if _that class on the item_ is already taxed under the ToT. This is + # counterintuitive. + # See RT#5243 and RT#36380. + foreach my $tot (@tot) { + my $totnum = $tot->taxnum; + warn "checking taxnum $totnum which we call ". $tot->taxname ."\n" + if $DEBUG > 2; + # note: if the _null class_ on this item is taxed under the ToT, + # then this specific class is taxed also (because null class + # includes all classes) and so ToT is applicable. + if ( + exists $this_class_has_tax->{ $totnum } + or exists $this_has_tax->{''}{ $totnum } + ) { + + warn "calculating tax on tax: taxnum $totnum on $taxnum\n" + if $DEBUG; + my @taxlines = $tot->taxline( + $this_class_has_tax->{ $taxnum }, # the first-stage tax + 'custnum' => $custnum, + 'invoice_time' => $invoice_time, + ); + next if (!@taxlines); # it didn't apply after all + if (!ref($taxlines[0])) { + warn "error evaluating taxnum $totnum TOT on custnum $custnum\n"; + return $taxlines[0]; + } + foreach my $taxline (@taxlines) { + push @{ $taxname{ $taxline->itemdesc } }, $taxline; + } + } # if $has_tax + } # foreach my $tot (tax-on-tax rate definition) + } # foreach $taxnum (first-tier rate definition) + } # foreach $charge_class } # foreach $taxable_item #consolidate and create tax line items @@ -1180,9 +1216,10 @@ sub _make_lines { # - it doesn't already HAVE a setup date # - or a start date in the future # - and it's not suspended + # - and it doesn't have an expire date in the past # - # The last condition used to check the "disable_setup_suspended" option but - # that's obsolete. We now never set the setup date on a suspended package. + # The "disable_setup_suspended" option is now obsolete; we never set the + # setup date on a suspended package. if ( ! $options{recurring_only} and ! $options{cancel} and ( $options{'resetup'} @@ -1193,6 +1230,8 @@ sub _make_lines { && ( ! $cust_pkg->getfield('susp') ) ) ) + and ( ! $cust_pkg->expire + || $cust_pkg->expire > $cmp_time ) ) { @@ -1205,7 +1244,14 @@ sub _make_lines { return "$@ running calc_setup for $cust_pkg\n" if $@; - $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh + # Only increment unitsetup here if there IS a setup fee. + # prorate_defer_bill may cause calc_setup on a setup-stage package + # to return zero, and the setup fee to be charged later. (This happens + # when it's first billed on the prorate cutoff day. RT#31276.) + if ( $setup ) { + $unitsetup = $cust_pkg->base_setup() + || $setup; #XXX uuh + } } $cust_pkg->setfield('setup', $time) @@ -1226,6 +1272,23 @@ sub _make_lines { my $unitrecur = 0; my @recur_discounts = (); my $sdate; + + my $override_quantity; + + # Conditions for billing the recurring fee: + # - the package doesn't have a future start date + # - and it's not suspended + # - unless suspend_bill is enabled on the package or package def + # - but still not, if the package is on hold + # - or it's suspended for a delayed cancellation + # - and its next bill date is in the past + # - or it doesn't have a next bill date yet + # - or it's a one-time charge + # - or it's a CDR plan with the "bill_every_call" option + # - or it's being canceled + # - and it doesn't have an expire date in the past (this can happen with + # advance billing) + # - again, unless it's being canceled if ( ! $cust_pkg->start_date and ( ! $cust_pkg->susp @@ -1244,6 +1307,12 @@ sub _make_lines { && $part_pkg->option('bill_every_call') ) || $options{cancel} + + and + ( ! $cust_pkg->expire + || $cust_pkg->expire > $cmp_time + || $options{cancel} + ) ) { # XXX should this be a package event? probably. events are called @@ -1290,9 +1359,22 @@ sub _make_lines { return "$@ running $method for $cust_pkg\n" if ( $@ ); + if ($recur eq 'NOTHING') { + # then calc_cancel (or calc_recur but that's not used) has declined to + # generate a recurring lineitem at all. treat this as zero, but also + # try not to generate a lineitem. + $recur = 0; + $lineitems--; + } + #base_cancel??? $unitrecur = $cust_pkg->base_recur( \$sdate ) || $recur; #XXX uuh, better + if ( $param{'override_quantity'} ) { + $override_quantity = $param{'override_quantity'}; + $unitrecur = $recur / $override_quantity; + } + if ( $increment_next_bill ) { my $next_bill; @@ -1317,7 +1399,8 @@ sub _make_lines { } else { # the normal case $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0); - return "unparsable frequency: ". $part_pkg->freq + return "unparsable frequency: ". + ($options{freq_override} || $part_pkg->freq) if $next_bill == -1; } @@ -1336,7 +1419,7 @@ sub _make_lines { # Add an additional setup fee at the billing stage. # Used for prorate_defer_bill. $setup += $param{'setup_fee'}; - $unitsetup += $param{'setup_fee'}; + $unitsetup = $cust_pkg->base_setup(); $lineitems++; } @@ -1346,7 +1429,7 @@ sub _make_lines { } } - } + } # end of recurring fee warn "\$setup is undefined" unless defined($setup); warn "\$recur is undefined" unless defined($recur); @@ -1410,10 +1493,10 @@ sub _make_lines { my $cust_bill_pkg = new FS::cust_bill_pkg { 'pkgnum' => $cust_pkg->pkgnum, 'setup' => $setup, - 'unitsetup' => $unitsetup, + 'unitsetup' => sprintf('%.2f', $unitsetup), 'recur' => $recur, - 'unitrecur' => $unitrecur, - 'quantity' => $cust_pkg->quantity, + 'unitrecur' => sprintf('%.2f', $unitrecur), + 'quantity' => $override_quantity || $cust_pkg->quantity, 'details' => \@details, 'discounts' => [ @setup_discounts, @recur_discounts ], 'hidden' => $part_pkg->hidden, @@ -1684,8 +1767,10 @@ sub _handle_taxes { # We fetch taxes even if the customer is completely exempt, # because we need to record that fact. - my @loc_keys = qw( district city county state country ); - my %taxhash = map { $_ => $location->$_ } @loc_keys; + my %taxhash = map { $_ => $location->get($_) } + qw( district county state country ); + # city names in cust_main_county are uppercase + $taxhash{'city'} = uc($location->get('city')); $taxhash{'taxclass'} = $part_item->taxclass; @@ -2340,6 +2425,7 @@ sub due_cust_event { =item apply_payments_and_credits [ OPTION => VALUE ... ] Applies unapplied payments and credits. +Payments with the no_auto_apply flag set will not be applied. In most cases, this new method should be used in place of sequential apply_payments and apply_credits methods. @@ -2482,6 +2568,7 @@ sub apply_credits { Applies (see L) unapplied payments (see L) to outstanding invoice balances in chronological order. +Payments with the no_auto_apply flag set will not be applied. #and returns the value of any remaining unapplied payments. @@ -2511,7 +2598,7 @@ sub apply_payments { #return 0 unless - my @payments = $self->unapplied_cust_pay; + my @payments = grep { !$_->no_auto_apply } $self->unapplied_cust_pay; my @invoices = $self->open_cust_bill;