X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main%2FBilling.pm;h=8d389928b91e8920499c29f1c6b07bea5808207c;hb=a0e00fa0547e99893c735ab3dbdacdb2bb054f5a;hp=220f66a0cb98e40c985542bcab174a3a85464a10;hpb=b7a2175dd9b386441f4ab66869d73083e5e8beb1;p=freeside.git diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 220f66a0c..8d389928b 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -21,6 +21,7 @@ use FS::cust_bill_pkg_tax_rate_location; use FS::part_event; use FS::part_event_condition; use FS::pkg_category; +use FS::cust_event_fee; use FS::Log; # 1 is mostly method/subroutine entry and options @@ -192,14 +193,30 @@ sub cancel_expired_pkgs { my @errors = (); - foreach my $cust_pkg ( @cancel_pkgs ) { + CUST_PKG: foreach my $cust_pkg ( @cancel_pkgs ) { my $cpr = $cust_pkg->last_cust_pkg_reason('expire'); - my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum, + my $error; + + if ( $cust_pkg->change_to_pkgnum ) { + + my $new_pkg = FS::cust_pkg->by_key($cust_pkg->change_to_pkgnum); + if ( !$new_pkg ) { + push @errors, 'can\'t change pkgnum '.$cust_pkg->pkgnum.' to pkgnum '. + $cust_pkg->change_to_pkgnum.'; not expiring'; + next CUST_PKG; + } + $error = $cust_pkg->change( 'cust_pkg' => $new_pkg, + 'unprotect_svcs' => 1 ); + $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 @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error; } @@ -357,6 +374,11 @@ sub bill { my $time = $options{'time'} || time; my $invoice_time = $options{'invoice_time'} || $time; + my $cmp_time = ( $conf->exists('next-bill-ignore-time') + ? day_end( $time ) + : $time + ); + $options{'not_pkgpart'} ||= {}; $options{'not_pkgpart'} = { map { $_ => 1 } split(/\s*,\s*/, $options{'not_pkgpart'}) @@ -464,7 +486,7 @@ sub bill { my $next_bill = $cust_pkg->getfield('bill') || 0; my $error; # let this run once if this is the last bill upon cancellation - while ( $next_bill <= $time or $options{cancel} ) { + while ( $next_bill <= $cmp_time or $options{cancel} ) { $error = $self->_make_lines( 'part_pkg' => $part_pkg, 'cust_pkg' => $cust_pkg, @@ -511,12 +533,100 @@ sub bill { my @cust_bill_pkg = _omit_zero_value_bundles(@{ $cust_bill_pkg{$pass} }); - next unless @cust_bill_pkg; #don't create an invoice w/o line items - warn "$me billing pass $pass\n" #.Dumper(\@cust_bill_pkg)."\n" if $DEBUG > 2; + ### + # process fees + ### + + my @pending_event_fees = FS::cust_event_fee->by_cust($self->custnum, + hashref => { 'billpkgnum' => '' } + ); + warn "$me found pending fee events:\n".Dumper(\@pending_event_fees)."\n" + if @pending_event_fees and $DEBUG > 1; + + # determine whether to generate an invoice + my $generate_bill = scalar(@cust_bill_pkg) > 0; + + foreach my $event_fee (@pending_event_fees) { + $generate_bill = 1 unless $event_fee->nextbill; + } + + # don't create an invoice with no line items, or where the only line + # items are fees that are supposed to be held until the next invoice + next if !$generate_bill; + + # calculate fees... + my @fee_items; + foreach my $event_fee (@pending_event_fees) { + my $object = $event_fee->cust_event->cust_X; + my $part_fee = $event_fee->part_fee; + my $cust_bill; + if ( $object->isa('FS::cust_main') or $object->isa('FS::cust_pkg') ) { + # Not the real cust_bill object that will be inserted--in particular + # there are no taxes yet. If you want to charge a fee on the total + # invoice amount including taxes, you have to put the fee on the next + # invoice. + $cust_bill = FS::cust_bill->new({ + 'custnum' => $self->custnum, + 'cust_bill_pkg' => \@cust_bill_pkg, + 'charged' => ${ $total_setup{$pass} } + + ${ $total_recur{$pass} }, + }); + + # If this is a package event, only apply the fee to line items + # from that package. + if ($object->isa('FS::cust_pkg')) { + $cust_bill->set('cust_bill_pkg', + [ grep { $_->pkgnum == $object->pkgnum } @cust_bill_pkg ] + ); + } + + } elsif ( $object->isa('FS::cust_bill') ) { + # simple case: applying the fee to a previous invoice (late fee, + # etc.) + $cust_bill = $object; + } + # if the fee def belongs to a different agent, don't charge the fee. + # event conditions should prevent this, but just in case they don't, + # skip the fee. + if ( $part_fee->agentnum and $part_fee->agentnum != $self->agentnum ) { + warn "tried to charge fee#".$part_fee->feepart . + " on customer#".$self->custnum." from a different agent.\n"; + next; + } + # also skip if it's disabled + next if $part_fee->disabled eq 'Y'; + # calculate the fee + my $fee_item = $part_fee->lineitem($cust_bill) or next; + # link this so that we can clear the marker on inserting the line item + $fee_item->set('cust_event_fee', $event_fee); + push @fee_items, $fee_item; + + } + + # add fees to the invoice + foreach my $fee_item (@fee_items) { + + push @cust_bill_pkg, $fee_item; + ${ $total_setup{$pass} } += $fee_item->setup; + ${ $total_recur{$pass} } += $fee_item->recur; + + my $part_fee = $fee_item->part_fee; + my $fee_location = $self->ship_location; # I think? + + my $error = $self->_handle_taxes( + $taxlisthash{$pass}, + $fee_item, + location => $fee_location + ); + return $error if $error; + + } + + # XXX implementation of fees is supposed to make this go away... if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) || !$conf->exists('postal_invoice-recurring_only') ) @@ -612,14 +722,12 @@ sub bill { my @cust_bill = $self->cust_bill; my $balance = $self->balance; - my $previous_balance = scalar(@cust_bill) - ? ( $cust_bill[$#cust_bill]->billing_balance || 0 ) - : 0; - - $previous_balance += $cust_bill[$#cust_bill]->charged - if scalar(@cust_bill); - #my $balance_adjustments = - # sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged); + my $previous_bill = $cust_bill[-1] if @cust_bill; + my $previous_balance = 0; + if ( $previous_bill ) { + $previous_balance = $previous_bill->billing_balance + + $previous_bill->charged; + } warn "creating the new invoice\n" if $DEBUG; #create the new invoice @@ -914,6 +1022,7 @@ sub _make_lines { my $part_pkg = $params{part_pkg} or die "no part_pkg specified"; my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified"; + my $cust_location = $cust_pkg->tax_location; my $precommit_hooks = $params{precommit_hooks} or die "no precommit_hooks specified"; my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified"; my $total_setup = $params{setup} or die "no setup accumulator specified"; @@ -1229,18 +1338,8 @@ sub _make_lines { # handle taxes ### - #unless ( $discount_show_always ) { # oh, for god's sake - my $error = $self->_handle_taxes( - $part_pkg, - $taxlisthash, - $cust_bill_pkg, - $cust_pkg, - $options{invoice_time}, - $real_pkgpart, - \%options # I have serious objections to this - ); + my $error = $self->_handle_taxes( $taxlisthash, $cust_bill_pkg ); return $error if $error; - #} $cust_bill_pkg->set_display( part_pkg => $part_pkg, @@ -1336,15 +1435,13 @@ sub _transfer_balance { return @transfers; } -=item _handle_taxes PART_PKG TAXLISTHASH CUST_BILL_PKG CUST_PKG TIME PKGPART [ OPTIONS ] +=item handle_taxes TAXLISTHASH CUST_BILL_PKG [ OPTIONS ] This is _handle_taxes. It's called once for each cust_bill_pkg generated -from _make_lines, along with the part_pkg, cust_pkg, invoice time, the -non-overridden pkgpart, a flag indicating whether the package is being -canceled, and a partridge in a pear tree. +from _make_lines. -The most important argument is 'taxlisthash'. This is shared across the -entire invoice. It looks like this: +TAXLISTHASH is a hashref shared across the entire invoice. It looks like +this: { 'cust_main_county 1001' => [ [FS::cust_main_county], ... ], 'cust_main_county 1002' => [ [FS::cust_main_county], ... ], @@ -1357,25 +1454,35 @@ That "..." is a list of FS::cust_bill_pkg objects that will be fed to the 'taxline' method to calculate the amount of the tax. This doesn't happen until calculate_taxes, though. +OPTIONS may include: +- part_item: a part_pkg or part_fee object to be used as the package/fee + definition. +- location: a cust_location to be used as the billing location. + +If not supplied, part_item will be inferred from the pkgnum or feepart of the +cust_bill_pkg, and location from the pkgnum (or, for fees, the invnum and +the customer's default service location). + =cut sub _handle_taxes { my $self = shift; - my $part_pkg = shift; my $taxlisthash = shift; my $cust_bill_pkg = shift; - my $cust_pkg = shift; - my $invoice_time = shift; - my $real_pkgpart = shift; - my $options = shift; + my %options = @_; + + # at this point I realize that we have enough information to infer all this + # stuff, instead of passing around giant honking argument lists + my $location = $options{location} || $cust_bill_pkg->tax_location; + my $part_item = $options{part_item} || $cust_bill_pkg->part_X; local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; return if ( $self->payby eq 'COMP' ); #dubious if ( $conf->exists('enable_taxproducts') - && ( scalar($part_pkg->part_pkg_taxoverride) - || $part_pkg->has_taxproduct + && ( scalar($part_item->part_pkg_taxoverride) + || $part_item->has_taxproduct ) ) { @@ -1387,9 +1494,8 @@ sub _handle_taxes { my @classes; #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U'; push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; - # debatable - push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel}); - push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel}); + push @classes, 'setup' if $cust_bill_pkg->setup; + push @classes, 'recur' if $cust_bill_pkg->recur; my $exempt = $conf->exists('cust_class-tax_exempt') ? ( $self->cust_class ? $self->cust_class->tax : '' ) @@ -1400,20 +1506,20 @@ sub _handle_taxes { if ( !$exempt ) { foreach my $class (@classes) { - my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg ); + my $err_or_ref = $self->_gather_taxes($part_item, $class, $location); return $err_or_ref unless ref($err_or_ref); $taxes{$class} = $err_or_ref; } unless (exists $taxes{''}) { - my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg ); + my $err_or_ref = $self->_gather_taxes($part_item, '', $location); return $err_or_ref unless ref($err_or_ref); $taxes{''} = $err_or_ref; } } - my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; + my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr foreach my $key (keys %tax_cust_bill_pkg) { # $key is "setup", "recur", or a usage class name. ('' is a usage class.) # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of @@ -1428,11 +1534,6 @@ sub _handle_taxes { # this is the tax identifier, not the taxname my $taxname = ref( $tax ). ' '. $tax->taxnum; - $taxname .= ' billpkgnum'. $cust_bill_pkg->billpkgnum; - # We need to create a separate $taxlisthash entry for each billpkgnum - # on the invoice, so that cust_bill_pkg_tax_location records will - # be linked correctly. - # $taxlisthash: keys are "setup", "recur", and usage classes. # Values are arrayrefs, first the tax object (cust_main_county # or tax_rate) and then any cust_bill_pkg objects that the @@ -1452,7 +1553,7 @@ sub _handle_taxes { if $DEBUG > 2; next unless $tax_object->can('tax_on_tax'); - foreach my $tot ( $tax_object->tax_on_tax( $self ) ) { + foreach my $tot ( $tax_object->tax_on_tax( $location ) ) { my $totname = ref( $tot ). ' '. $tot->taxnum; warn "checking $totname which we call ". $tot->taxname. " as applicable\n" @@ -1460,15 +1561,13 @@ sub _handle_taxes { next unless exists( $localtaxlisthash{ $totname } ); # only increase # existing taxes warn "adding $totname to taxed taxes\n" if $DEBUG > 2; - # we're calling taxline() right here? wtf? + # calculate the tax amount that the tax_on_tax will apply to my $hashref_or_error = - $tax_object->taxline( $localtaxlisthash{$tax}, - 'custnum' => $self->custnum, - 'invoice_time' => $invoice_time, - ); + $tax_object->taxline( $localtaxlisthash{$tax} ); return $hashref_or_error unless ref($hashref_or_error); + # and append it to the list of taxable items $taxlisthash->{ $totname } ||= [ $tot ]; push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount}; @@ -1484,10 +1583,9 @@ sub _handle_taxes { # because we need to record that fact. my @loc_keys = qw( district city county state country ); - my $location = $cust_pkg->tax_location; my %taxhash = map { $_ => $location->$_ } @loc_keys; - $taxhash{'taxclass'} = $part_pkg->taxclass; + $taxhash{'taxclass'} = $part_item->taxclass; warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2; @@ -1520,49 +1618,28 @@ sub _handle_taxes { ''; } +=item _gather_taxes PART_ITEM CLASS CUST_LOCATION + +Internal method used with vendor-provided tax tables. PART_ITEM is a part_pkg +or part_fee (which will define the tax eligibility of the product), CLASS is +'setup', 'recur', null, or a C number, and CUST_LOCATION is the +location where the service was provided (or billed, depending on +configuration). Returns an arrayref of L objects that +can apply to this line item. + +=cut + sub _gather_taxes { my $self = shift; - my $part_pkg = shift; + my $part_item = shift; my $class = shift; - my $cust_pkg = shift; + my $location = shift; local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; - my $geocode; - if ( $cust_pkg->locationnum && $conf->exists('tax-pkg_address') ) { - $geocode = $cust_pkg->cust_location->geocode('cch'); - } else { - $geocode = $self->geocode('cch'); - } - - my @taxes = (); - - my @taxclassnums = map { $_->taxclassnum } - $part_pkg->part_pkg_taxoverride($class); - - unless (@taxclassnums) { - @taxclassnums = map { $_->taxclassnum } - grep { $_->taxable eq 'Y' } - $part_pkg->part_pkg_taxrate('cch', $geocode, $class); - } - warn "Found taxclassnum values of ". join(',', @taxclassnums) - if $DEBUG; - - my $extra_sql = - "AND (". - join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")"; - - @taxes = qsearch({ 'table' => 'tax_rate', - 'hashref' => { 'geocode' => $geocode, }, - 'extra_sql' => $extra_sql, - }) - if scalar(@taxclassnums); - - warn "Found taxes ". - join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n" - if $DEBUG; + my $geocode = $location->geocode('cch'); - [ @taxes ]; + [ $part_item->tax_rates('cch', $geocode, $class) ] } @@ -2411,6 +2488,7 @@ sub apply_payments { _handle_taxes (vendor-only) _gather_taxes _omit_zero_value_bundles + _handle_taxes (for fees) calculate_taxes apply_payments_and_credits