X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_main%2FBilling.pm;h=8d389928b91e8920499c29f1c6b07bea5808207c;hp=85cafd62cd8609001b45b573c234285e7bfe8e13;hb=e9e0cf0989259b94d9758eceff448666a2e5a5cc;hpb=f50a821d306b561d602edbdac0dac958b862ec0c diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 85cafd62c..8d389928b 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -21,6 +21,8 @@ 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 # 2 traces progress of some operations @@ -104,6 +106,9 @@ options of those methods are also available. sub bill_and_collect { my( $self, %options ) = @_; + my $log = FS::Log->new('bill_and_collect'); + $log->debug('start', object => $self, agentnum => $self->agentnum); + my $error; #$options{actual_time} not $options{time} because freeside-daily -d is for @@ -112,8 +117,13 @@ sub bill_and_collect { $options{'actual_time'} ||= time; my $job = $options{'job'}; + my $actual_time = ( $conf->exists('next-bill-ignore-time') + ? day_end( $options{actual_time} ) + : $options{actual_time} + ); + $job->update_statustext('0,cleaning expired packages') if $job; - $error = $self->cancel_expired_pkgs( day_end( $options{actual_time} ) ); + $error = $self->cancel_expired_pkgs( $actual_time ); if ( $error ) { $error = "Error expiring custnum ". $self->custnum. ": $error"; if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; } @@ -121,7 +131,7 @@ sub bill_and_collect { else { warn $error; } } - $error = $self->suspend_adjourned_pkgs( day_end( $options{actual_time} ) ); + $error = $self->suspend_adjourned_pkgs( $actual_time ); if ( $error ) { $error = "Error adjourning custnum ". $self->custnum. ": $error"; if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; } @@ -129,7 +139,7 @@ sub bill_and_collect { else { warn $error; } } - $error = $self->unsuspend_resumed_pkgs( day_end( $options{actual_time} ) ); + $error = $self->unsuspend_resumed_pkgs( $actual_time ); if ( $error ) { $error = "Error resuming custnum ".$self->custnum. ": $error"; if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; } @@ -168,6 +178,7 @@ sub bill_and_collect { } } $job->update_statustext('100,finished') if $job; + $log->debug('finish', object => $self, agentnum => $self->agentnum); ''; @@ -182,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; } @@ -347,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'}) @@ -405,6 +437,7 @@ sub bill { my @precommit_hooks = (); $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ]; #param checks? + foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) { next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart}; @@ -426,6 +459,24 @@ sub bill { my @part_pkg = $cust_pkg->part_pkg->self_and_bill_linked; $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden); + # if this package was changed from another package, + # and it hasn't been billed since then, + # and package balances are enabled, + if ( $cust_pkg->change_pkgnum + and $cust_pkg->change_date >= ($cust_pkg->last_bill || 0) + and $cust_pkg->change_date < $invoice_time + and $conf->exists('pkg-balances') ) + { + # _transfer_balance will also create the appropriate credit + my @transfer_items = $self->_transfer_balance($cust_pkg); + # $part_pkg[0] is the "real" part_pkg + my $pass = ($cust_pkg->no_auto || $part_pkg[0]->no_auto) ? + 'no_auto' : ''; + push @{ $cust_bill_pkg{$pass} }, @transfer_items; + # treating this as recur, just because most charges are recur... + ${$total_recur{$pass}} += $_->recur foreach @transfer_items; + } + foreach my $part_pkg ( @part_pkg ) { $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill ); @@ -435,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, @@ -482,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') ) @@ -583,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 @@ -682,8 +819,6 @@ sub _omit_zero_value_bundles { =item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME -This is a weird one. Perhaps it should not even be exposed. - Generates tax line items (see L) for this customer. Usually used internally by bill method B. @@ -735,63 +870,70 @@ sub calculate_taxes { my @tax_line_items = (); # keys are tax names (as printed on invoices / itemdesc ) - # values are listrefs of taxlisthash keys (internal identifiers) + # values are arrayrefs of taxlisthash keys (internal identifiers) my %taxname = (); # keys are taxlisthash keys (internal identifiers) # values are (cumulative) amounts - my %tax = (); + my %tax_amount = (); # keys are taxlisthash keys (internal identifiers) - # values are listrefs of cust_bill_pkg_tax_location hashrefs + # values are arrayrefs of cust_bill_pkg_tax_location hashrefs my %tax_location = (); # keys are taxlisthash keys (internal identifiers) - # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs + # values are arrayrefs of cust_bill_pkg_tax_rate_location hashrefs my %tax_rate_location = (); + # keys are taxlisthash keys (internal identifiers!) + # values are arrayrefs of cust_tax_exempt_pkg objects + my %tax_exemption; + foreach my $tax ( keys %$taxlisthash ) { - # $tax is a tax identifier + # $tax is a tax identifier (intersection of a tax definition record + # and a cust_bill_pkg record) my $tax_object = shift @{ $taxlisthash->{$tax} }; # $tax_object is a cust_main_county or tax_rate - # (with pkgnum and locationnum set) - # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg objects + # (with billpkgnum, pkgnum, locationnum set) + # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg component objects + # (setup, recurring, usage classes) warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2; warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2; # taxline calculates the tax on all cust_bill_pkgs in the - # first (arrayref) argument - my $hashref_or_error = - $tax_object->taxline( $taxlisthash->{$tax}, + # first (arrayref) argument, and returns a hashref of 'name' + # (the line item description) and 'amount'. + # It also calculates exemptions and attaches them to the cust_bill_pkgs + # in the argument. + my $taxables = $taxlisthash->{$tax}; + my $exemptions = $tax_exemption{$tax} ||= []; + my $taxline = $tax_object->taxline( + $taxables, 'custnum' => $self->custnum, - 'invoice_time' => $invoice_time + 'invoice_time' => $invoice_time, + 'exemptions' => $exemptions, ); - return $hashref_or_error unless ref($hashref_or_error); + return $taxline unless ref($taxline); unshift @{ $taxlisthash->{$tax} }, $tax_object; - my $name = $hashref_or_error->{'name'}; - my $amount = $hashref_or_error->{'amount'}; + if ( $tax_object->isa('FS::cust_main_county') ) { + # then $taxline is a real line item + push @{ $taxname{ $taxline->itemdesc } }, $taxline; - #warn "adding $amount as $name\n"; - $taxname{ $name } ||= []; - push @{ $taxname{ $name } }, $tax; + } else { + # leave this as is for now - $tax{ $tax } += $amount; + my $name = $taxline->{'name'}; + my $amount = $taxline->{'amount'}; - # link records between cust_main_county/tax_rate and cust_location - $tax_location{ $tax } ||= []; - $tax_rate_location{ $tax } ||= []; - if ( ref($tax_object) eq 'FS::cust_main_county' ) { - push @{ $tax_location{ $tax } }, - { - 'taxnum' => $tax_object->taxnum, - 'taxtype' => ref($tax_object), - 'pkgnum' => $tax_object->get('pkgnum'), - 'locationnum' => $tax_object->get('locationnum'), - 'amount' => sprintf('%.2f', $amount ), - }; - } - elsif ( ref($tax_object) eq 'FS::tax_rate' ) { + #warn "adding $amount as $name\n"; + $taxname{ $name } ||= []; + push @{ $taxname{ $name } }, $tax; + + $tax_amount{ $tax } += $amount; + + # link records between cust_main_county/tax_rate and cust_location + $tax_rate_location{ $tax } ||= []; my $taxratelocationnum = $tax_object->tax_rate_location->taxratelocationnum; push @{ $tax_rate_location{ $tax } }, @@ -802,50 +944,52 @@ sub calculate_taxes { 'locationtaxid' => $tax_object->location, 'taxratelocationnum' => $taxratelocationnum, }; - } - - } - - #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit - my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg; - foreach my $tax ( keys %$taxlisthash ) { - foreach ( @{ $taxlisthash->{$tax} }[1 ... scalar(@{ $taxlisthash->{$tax} })] ) { - next unless ref($_) eq 'FS::cust_bill_pkg'; - - my @cust_tax_exempt_pkg = splice( @{ $_->_cust_tax_exempt_pkg } ); - - next unless @cust_tax_exempt_pkg; #just avoiding the prob when irrelevant? - die "can't distribute tax exemptions: no line item for ". Dumper($_). - " in packagemap ". join(',', sort {$a<=>$b} keys %packagemap). "\n" - unless $packagemap{$_->pkgnum}; - - push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, - @cust_tax_exempt_pkg; - } - } + } #if ref($tax_object)... + } #foreach keys %$taxlisthash #consolidate and create tax line items warn "consolidating and generating...\n" if $DEBUG > 2; foreach my $taxname ( keys %taxname ) { - my $tax = 0; + my @cust_bill_pkg_tax_location; + my @cust_bill_pkg_tax_rate_location; + my $tax_cust_bill_pkg = FS::cust_bill_pkg->new({ + 'pkgnum' => 0, + 'recur' => 0, + 'sdate' => '', + 'edate' => '', + 'itemdesc' => $taxname, + 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location, + 'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location, + }); + + my $tax_total = 0; my %seen = (); - my @cust_bill_pkg_tax_location = (); - my @cust_bill_pkg_tax_rate_location = (); warn "adding $taxname\n" if $DEBUG > 1; foreach my $taxitem ( @{ $taxname{$taxname} } ) { - next if $seen{$taxitem}++; - warn "adding $tax{$taxitem}\n" if $DEBUG > 1; - $tax += $tax{$taxitem}; - push @cust_bill_pkg_tax_location, - map { new FS::cust_bill_pkg_tax_location $_ } - @{ $tax_location{ $taxitem } }; - push @cust_bill_pkg_tax_rate_location, - map { new FS::cust_bill_pkg_tax_rate_location $_ } - @{ $tax_rate_location{ $taxitem } }; + if ( ref($taxitem) eq 'FS::cust_bill_pkg' ) { + # then we need to transfer the amount and the links from the + # line item to the new one we're creating. + $tax_total += $taxitem->setup; + foreach my $link ( @{ $taxitem->get('cust_bill_pkg_tax_location') } ) { + $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg); + push @cust_bill_pkg_tax_location, $link; + } + } else { + # the tax_rate way + next if $seen{$taxitem}++; + warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1; + $tax_total += $tax_amount{$taxitem}; + push @cust_bill_pkg_tax_rate_location, + map { new FS::cust_bill_pkg_tax_rate_location $_ } + @{ $tax_rate_location{ $taxitem } }; + } } - next unless $tax; + next unless $tax_total; - $tax = sprintf('%.2f', $tax ); + # we should really neverround this up...I guess it's okay if taxline + # already returns amounts with 2 decimal places + $tax_total = sprintf('%.2f', $tax_total ); + $tax_cust_bill_pkg->set('setup', $tax_total); my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname, 'disabled' => '', @@ -863,19 +1007,9 @@ sub calculate_taxes { push @display, new FS::cust_bill_pkg_display { type => 'S', %hash }; } + $tax_cust_bill_pkg->set('display', \@display); - push @tax_line_items, new FS::cust_bill_pkg { - 'pkgnum' => 0, - 'setup' => $tax, - 'recur' => 0, - 'sdate' => '', - 'edate' => '', - 'itemdesc' => $taxname, - 'display' => \@display, - 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location, - 'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location, - }; - + push @tax_line_items, $tax_cust_bill_pkg; } \@tax_line_items; @@ -888,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"; @@ -912,6 +1047,11 @@ sub _make_lines { $cust_pkg->pkgpart($part_pkg->pkgpart); + my $cmp_time = ( $conf->exists('next-bill-ignore-time') + ? day_end( $time ) + : $time + ); + ### # bill setup ### @@ -920,12 +1060,14 @@ sub _make_lines { my $unitsetup = 0; my @setup_discounts = (); my %setup_param = ( 'discounts' => \@setup_discounts ); + my $setup_billed_currency = ''; + my $setup_billed_amount = 0; if ( ! $options{recurring_only} and ! $options{cancel} and ( $options{'resetup'} || ( ! $cust_pkg->setup && ( ! $cust_pkg->start_date - || $cust_pkg->start_date <= day_end($time) + || $cust_pkg->start_date <= $cmp_time ) && ( ! $conf->exists('disable_setup_suspended_pkgs') || ( $conf->exists('disable_setup_suspended_pkgs') && @@ -946,7 +1088,13 @@ sub _make_lines { return "$@ running calc_setup for $cust_pkg\n" if $@; - $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh + $unitsetup = $cust_pkg->base_setup() + || $setup; #XXX uuh + + if ( $setup_param{'billed_currency'} ) { + $setup_billed_currency = delete $setup_param{'billed_currency'}; + $setup_billed_amount = delete $setup_param{'billed_amount'}; + } } $cust_pkg->setfield('setup', $time) @@ -966,6 +1114,8 @@ sub _make_lines { my $recur = 0; my $unitrecur = 0; my @recur_discounts = (); + my $recur_billed_currency = ''; + my $recur_billed_amount = 0; my $sdate; if ( ! $cust_pkg->start_date and ( ! $cust_pkg->susp || $cust_pkg->option('suspend_bill',1) @@ -973,7 +1123,7 @@ sub _make_lines { && ! $cust_pkg->option('no_suspend_bill',1) ) and - ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= day_end($time) ) + ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= $cmp_time ) || ( $part_pkg->plan eq 'voip_cdr' && $part_pkg->option('bill_every_call') ) @@ -997,7 +1147,7 @@ sub _make_lines { #over two params! lets at least switch to a hashref for the rest... my $increment_next_bill = ( $part_pkg->freq ne '0' - && ( $cust_pkg->getfield('bill') || 0 ) <= day_end($time) + && ( $cust_pkg->getfield('bill') || 0 ) <= $cmp_time && !$options{cancel} ); my %param = ( %setup_param, @@ -1025,13 +1175,40 @@ sub _make_lines { if ( $@ ); #base_cancel??? - $unitrecur = $cust_pkg->part_pkg->base_recur || $recur; #XXX uuh + $unitrecur = $cust_pkg->base_recur( \$sdate ) || $recur; #XXX uuh, better + + if ( $param{'billed_currency'} ) { + $recur_billed_currency = delete $param{'billed_currency'}; + $recur_billed_amount = delete $param{'billed_amount'}; + } if ( $increment_next_bill ) { - my $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0); + my $next_bill; + + if ( my $main_pkg = $cust_pkg->main_pkg ) { + # supplemental package + # to keep in sync with the main package, simulate billing at + # its frequency + my $main_pkg_freq = $main_pkg->part_pkg->freq; + my $supp_pkg_freq = $part_pkg->freq; + my $ratio = $supp_pkg_freq / $main_pkg_freq; + if ( $ratio != int($ratio) ) { + # the UI should prevent setting up packages like this, but just + # in case + return "supplemental package period is not an integer multiple of main package period"; + } + $next_bill = $sdate; + for (1..$ratio) { + $next_bill = $part_pkg->add_freq( $next_bill, $main_pkg_freq ); + } + + } else { + # the normal case + $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0); return "unparsable frequency: ". $part_pkg->freq if $next_bill == -1; + } #pro-rating magic - if $recur_prog fiddled $sdate, want to use that # only for figuring next bill date, nothing else, so, reset $sdate again @@ -1120,16 +1297,20 @@ sub _make_lines { push @details, @cust_pkg_detail; my $cust_bill_pkg = new FS::cust_bill_pkg { - 'pkgnum' => $cust_pkg->pkgnum, - 'setup' => $setup, - 'unitsetup' => $unitsetup, - 'recur' => $recur, - 'unitrecur' => $unitrecur, - 'quantity' => $cust_pkg->quantity, - 'details' => \@details, - 'discounts' => [ @setup_discounts, @recur_discounts ], - 'hidden' => $part_pkg->hidden, - 'freq' => $part_pkg->freq, + 'pkgnum' => $cust_pkg->pkgnum, + 'setup' => $setup, + 'unitsetup' => $unitsetup, + 'setup_billed_currency' => $setup_billed_currency, + 'setup_billed_amount' => $setup_billed_amount, + 'recur' => $recur, + 'unitrecur' => $unitrecur, + 'recur_billed_currency' => $recur_billed_currency, + 'recur_billed_amount' => $recur_billed_amount, + 'quantity' => $cust_pkg->quantity, + 'details' => \@details, + 'discounts' => [ @setup_discounts, @recur_discounts ], + 'hidden' => $part_pkg->hidden, + 'freq' => $part_pkg->freq, }; if ( $part_pkg->option('prorate_defer_bill',1) @@ -1141,7 +1322,7 @@ sub _make_lines { $cust_bill_pkg->sdate( $hash{last_bill} ); $cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1 $cust_bill_pkg->edate( $time ) if $options{cancel}; - } else { #if ( $part_pkg->recur_temporality eq 'upcoming' ) { + } else { #if ( $part_pkg->recur_temporality eq 'upcoming' ) $cust_bill_pkg->sdate( $sdate ); $cust_bill_pkg->edate( $cust_pkg->bill ); #$cust_bill_pkg->edate( $time ) if $options{cancel}; @@ -1157,11 +1338,13 @@ sub _make_lines { # handle taxes ### - unless ( $discount_show_always ) { - my $error = - $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options); - return $error if $error; - } + my $error = $self->_handle_taxes( $taxlisthash, $cust_bill_pkg ); + return $error if $error; + + $cust_bill_pkg->set_display( + part_pkg => $part_pkg, + real_pkgpart => $real_pkgpart, + ); push @$cust_bill_pkgs, $cust_bill_pkg; @@ -1173,226 +1356,290 @@ sub _make_lines { } +=item _transfer_balance TO_PKG [ FROM_PKGNUM ] + +Takes one argument, a cust_pkg object that is being billed. This will +be called only if the package was created by a package change, and has +not been billed since the package change, and package balance tracking +is enabled. The second argument can be an alternate package number to +transfer the balance from; this should not be used externally. + +Transfers the balance from the previous package (now canceled) to +this package, by crediting one package and creating an invoice item for +the other. Inserts the credit and returns the invoice item (so that it +can be added to an invoice that's being built). + +If the previous package was never billed, and was also created by a package +change, then this will also transfer the balance from I previous +package, and so on, until reaching a package that either has been billed +or was not created by a package change. + +=cut + +my $balance_transfer_reason; + +sub _transfer_balance { + my $self = shift; + my $cust_pkg = shift; + my $from_pkgnum = shift || $cust_pkg->change_pkgnum; + my $from_pkg = FS::cust_pkg->by_key($from_pkgnum); + + my @transfers; + + # if $from_pkg is not the first package in the chain, and it was never + # billed, walk back + if ( $from_pkg->change_pkgnum and scalar($from_pkg->cust_bill_pkg) == 0 ) { + @transfers = $self->_transfer_balance($cust_pkg, $from_pkg->change_pkgnum); + } + + my $prev_balance = $self->balance_pkgnum($from_pkgnum); + if ( $prev_balance != 0 ) { + $balance_transfer_reason ||= FS::reason->new_or_existing( + 'reason' => 'Package balance transfer', + 'type' => 'Internal adjustment', + 'class' => 'R' + ); + + my $credit = FS::cust_credit->new({ + 'custnum' => $self->custnum, + 'amount' => abs($prev_balance), + 'reasonnum' => $balance_transfer_reason->reasonnum, + '_date' => $cust_pkg->change_date, + }); + + my $cust_bill_pkg = FS::cust_bill_pkg->new({ + 'setup' => 0, + 'recur' => abs($prev_balance), + #'sdate' => $from_pkg->last_bill, # not sure about this + #'edate' => $cust_pkg->change_date, + 'itemdesc' => $self->mt('Previous Balance, [_1]', + $from_pkg->part_pkg->pkg), + }); + + if ( $prev_balance > 0 ) { + # credit the old package, charge the new one + $credit->set('pkgnum', $from_pkgnum); + $cust_bill_pkg->set('pkgnum', $cust_pkg->pkgnum); + } else { + # the reverse + $credit->set('pkgnum', $cust_pkg->pkgnum); + $cust_bill_pkg->set('pkgnum', $from_pkgnum); + } + my $error = $credit->insert; + die "error transferring package balance from #".$from_pkgnum. + " to #".$cust_pkg->pkgnum.": $error\n" if $error; + + push @transfers, $cust_bill_pkg; + } # $prev_balance != 0 + + return @transfers; +} + +=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. + +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], ... ], +} + +'cust_main_county' can also be 'tax_rate'. The first object in the array +is always the cust_main_county or tax_rate identified by the key. + +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 = @_; - local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + # 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; - my %cust_bill_pkg = (); - my %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; - push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel}); - push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel}); - - my $exempt = $conf->exists('cust_class-tax_exempt') - ? ( $self->cust_class ? $self->cust_class->tax : '' ) - : $self->tax; + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; - if ( $exempt !~ /Y/i && $self->payby ne 'COMP' ) { + return if ( $self->payby eq 'COMP' ); #dubious - if ( $conf->exists('enable_taxproducts') - && ( scalar($part_pkg->part_pkg_taxoverride) - || $part_pkg->has_taxproduct - ) - ) + if ( $conf->exists('enable_taxproducts') + && ( scalar($part_item->part_pkg_taxoverride) + || $part_item->has_taxproduct + ) + ) { + # EXTERNAL TAX RATES (via tax_rate) + my %cust_bill_pkg = (); + my %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; + 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 : '' ) + : $self->tax; + # standardize this just to be sure + $exempt = ($exempt eq 'Y') ? 'Y' : ''; + + 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; } - } else { - - 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; + } - warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2; + 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 + # the line item. + # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that + # apply to $key-class charges. + my @taxes = @{ $taxes{$key} || [] }; + my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; + + my %localtaxlisthash = (); + foreach my $tax ( @taxes ) { + + # this is the tax identifier, not the taxname + my $taxname = ref( $tax ). ' '. $tax->taxnum; + # $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 + # tax applies to. + $taxlisthash->{ $taxname } ||= [ $tax ]; + push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg; + + $localtaxlisthash{ $taxname } ||= [ $tax ]; + push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg; - my @taxes = (); # entries are cust_main_county objects - my %taxhash_elim = %taxhash; - my @elim = qw( district city county state ); - do { + } - #first try a match with taxclass - @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); + warn "finding taxed taxes...\n" if $DEBUG > 2; + foreach my $tax ( keys %localtaxlisthash ) { + my $tax_object = shift @{ $localtaxlisthash{$tax} }; + warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n" + if $DEBUG > 2; + next unless $tax_object->can('tax_on_tax'); + + 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" + if $DEBUG > 2; + next unless exists( $localtaxlisthash{ $totname } ); # only increase + # existing taxes + warn "adding $totname to taxed taxes\n" if $DEBUG > 2; + # calculate the tax amount that the tax_on_tax will apply to + my $hashref_or_error = + $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}; - if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) { - #then try a match without taxclass - my %no_taxclass = %taxhash_elim; - $no_taxclass{ 'taxclass' } = ''; - @taxes = qsearch( 'cust_main_county', \%no_taxclass ); } + } + } - $taxhash_elim{ shift(@elim) } = ''; - - } while ( !scalar(@taxes) && scalar(@elim) ); - - @taxes = grep { ! $_->taxname || ! $self->tax_exemption($_->taxname) } - @taxes - if $self->cust_main_exemption; #just to be safe - - warn "using taxes:\n". Dumper(@taxes) if $DEBUG > 2; + } else { - # all packages now have a locationnum and should get a - # cust_bill_pkg_tax_location record. The tax_locationnum - # may be the package's locationnum, or the customer's bill - # or service location. - foreach (@taxes) { - $_->set('pkgnum', $cust_pkg->pkgnum); - $_->set('locationnum', $cust_pkg->tax_locationnum); - } + # INTERNAL TAX RATES (cust_main_county) - $taxes{''} = [ @taxes ]; - $taxes{'setup'} = [ @taxes ]; - $taxes{'recur'} = [ @taxes ]; - $taxes{$_} = [ @taxes ] foreach (@classes); + # We fetch taxes even if the customer is completely exempt, + # because we need to record that fact. - # # maybe eliminate this entirely, along with all the 0% records - # unless ( @taxes ) { - # return - # "fatal: can't find tax rate for state/county/country/taxclass ". - # join('/', map $taxhash{$_}, qw(state county country taxclass) ); - # } + my @loc_keys = qw( district city county state country ); + my %taxhash = map { $_ => $location->$_ } @loc_keys; - } #if $conf->exists('enable_taxproducts') ... + $taxhash{'taxclass'} = $part_item->taxclass; - } + warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2; - #what's this doing in the middle of _handle_taxes? probably should split - #this into three parts above in _make_lines - $cust_bill_pkg->set_display( part_pkg => $part_pkg, - real_pkgpart => $real_pkgpart, - ); - - my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; - 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 - # the line item. - # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that - # apply to $key-class charges. - my @taxes = @{ $taxes{$key} || [] }; - my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; - - my %localtaxlisthash = (); - foreach my $tax ( @taxes ) { - - # this is the tax identifier, not the taxname - my $taxname = ref( $tax ). ' '. $tax->taxnum; -# $taxname .= ' pkgnum'. $cust_pkg->pkgnum. -# ' locationnum'. $cust_pkg->locationnum -# if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum; - - # $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 - # tax applies to - $taxlisthash->{ $taxname } ||= [ $tax ]; - push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg; - - $localtaxlisthash{ $taxname } ||= [ $tax ]; - push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg; + my @taxes = (); # entries are cust_main_county objects + my %taxhash_elim = %taxhash; + my @elim = qw( district city county state ); + do { - } + #first try a match with taxclass + @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); - warn "finding taxed taxes...\n" if $DEBUG > 2; - foreach my $tax ( keys %localtaxlisthash ) { - my $tax_object = shift @{ $localtaxlisthash{$tax} }; - warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n" - if $DEBUG > 2; - next unless $tax_object->can('tax_on_tax'); + if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) { + #then try a match without taxclass + my %no_taxclass = %taxhash_elim; + $no_taxclass{ 'taxclass' } = ''; + @taxes = qsearch( 'cust_main_county', \%no_taxclass ); + } - foreach my $tot ( $tax_object->tax_on_tax( $self ) ) { - my $totname = ref( $tot ). ' '. $tot->taxnum; + $taxhash_elim{ shift(@elim) } = ''; - warn "checking $totname which we call ". $tot->taxname. " as applicable\n" - if $DEBUG > 2; - next unless exists( $localtaxlisthash{ $totname } ); # only increase - # existing taxes - warn "adding $totname to taxed taxes\n" if $DEBUG > 2; - my $hashref_or_error = - $tax_object->taxline( $localtaxlisthash{$tax}, - 'custnum' => $self->custnum, - 'invoice_time' => $invoice_time, - ); - return $hashref_or_error - unless ref($hashref_or_error); - - $taxlisthash->{ $totname } ||= [ $tot ]; - push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount}; + } while ( !scalar(@taxes) && scalar(@elim) ); - } + foreach (@taxes) { + my $tax_id = 'cust_main_county '.$_->taxnum; + $taxlisthash->{$tax_id} ||= [ $_ ]; + push @{ $taxlisthash->{$tax_id} }, $cust_bill_pkg; } } - ''; } +=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) ] } @@ -1779,8 +2026,9 @@ sub due_cust_event { #??? #my $DEBUG = $opt{'debug'} + $opt{'debug'} ||= 0; # silence some warnings local($DEBUG) = $opt{'debug'} - if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG; + if $opt{'debug'} > $DEBUG; $DEBUG = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; warn "$me due_cust_event called with options ". @@ -2240,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