X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_main%2FBilling.pm;h=3dc8f9cadc4e1b9d73a0dca87157445dbf43d50b;hp=3638d00f21aaca6cd27b974d0ecf7e7dd4dc773d;hb=a72a10f754f7465121d6137bb3dcee0a21ea6443;hpb=4bbf4035783020c31df0358980ca54de5893851d diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 3638d00f2..3dc8f9cad 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -7,12 +7,12 @@ use Data::Dumper; use List::Util qw( min ); use FS::UID qw( dbh ); use FS::Record qw( qsearch qsearchs dbdef ); +use FS::Misc::DateTime qw( day_end ); use FS::cust_bill; use FS::cust_bill_pkg; use FS::cust_bill_pkg_display; use FS::cust_bill_pay; use FS::cust_credit_bill; -use FS::cust_pkg; use FS::cust_tax_adjustment; use FS::tax_rate; use FS::tax_rate_location; @@ -20,6 +20,8 @@ use FS::cust_bill_pkg_tax_location; use FS::cust_bill_pkg_tax_rate_location; use FS::part_event; use FS::part_event_condition; +use FS::pkg_category; +use FS::Log; # 1 is mostly method/subroutine entry and options # 2 traces progress of some operations @@ -103,6 +105,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,7 +117,7 @@ sub bill_and_collect { my $job = $options{'job'}; $job->update_statustext('0,cleaning expired packages') if $job; - $error = $self->cancel_expired_pkgs( $options{actual_time} ); + $error = $self->cancel_expired_pkgs( day_end( $options{actual_time} ) ); if ( $error ) { $error = "Error expiring custnum ". $self->custnum. ": $error"; if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; } @@ -120,7 +125,7 @@ sub bill_and_collect { else { warn $error; } } - $error = $self->suspend_adjourned_pkgs( $options{actual_time} ); + $error = $self->suspend_adjourned_pkgs( day_end( $options{actual_time} ) ); if ( $error ) { $error = "Error adjourning custnum ". $self->custnum. ": $error"; if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; } @@ -128,6 +133,14 @@ sub bill_and_collect { else { warn $error; } } + $error = $self->unsuspend_resumed_pkgs( day_end( $options{actual_time} ) ); + if ( $error ) { + $error = "Error resuming custnum ".$self->custnum. ": $error"; + if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; } + elsif ( $options{fatal} ) { die $error; } + else { warn $error; } + } + $job->update_statustext('20,billing packages') if $job; $error = $self->bill( %options ); if ( $error ) { @@ -159,6 +172,7 @@ sub bill_and_collect { } } $job->update_statustext('100,finished') if $job; + $log->debug('finish', object => $self, agentnum => $self->agentnum); ''; @@ -166,7 +180,7 @@ sub bill_and_collect { sub cancel_expired_pkgs { my ( $self, $time, %options ) = @_; - + my @cancel_pkgs = $self->ncancelled_pkgs( { 'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time " } ); @@ -176,20 +190,21 @@ sub cancel_expired_pkgs { foreach my $cust_pkg ( @cancel_pkgs ) { my $cpr = $cust_pkg->last_cust_pkg_reason('expire'); my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum, - 'reason_otaker' => $cpr->otaker + 'reason_otaker' => $cpr->otaker, + 'time' => $time, ) : () ); push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error; } - scalar(@errors) ? join(' / ', @errors) : ''; + join(' / ', @errors); } sub suspend_adjourned_pkgs { my ( $self, $time, %options ) = @_; - + my @susp_pkgs = $self->ncancelled_pkgs( { 'extra_sql' => " AND ( susp IS NULL OR susp = 0 ) @@ -225,7 +240,25 @@ sub suspend_adjourned_pkgs { push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error; } - scalar(@errors) ? join(' / ', @errors) : ''; + join(' / ', @errors); + +} + +sub unsuspend_resumed_pkgs { + my ( $self, $time, %options ) = @_; + + my @unsusp_pkgs = $self->ncancelled_pkgs( { + 'extra_sql' => " AND resume IS NOT NULL AND resume > 0 AND resume <= $time " + } ); + + my @errors = (); + + foreach my $cust_pkg ( @unsusp_pkgs ) { + my $error = $cust_pkg->unsuspend( 'time' => $time ); + push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error; + } + + join(' / ', @errors); } @@ -252,7 +285,9 @@ charges, etc. =item freq_override If set, then override the normal frequency and look for a part_pkg_discount -to take at that frequency. +to take at that frequency. This is appropriate only when the normal +frequency for all packages is monthly, and is an error otherwise. Use +C to limit the set of packages included in billing. =item time @@ -379,7 +414,7 @@ sub bill { next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart}; - warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1; + warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG; #? to avoid use of uninitialized value errors... ? $cust_pkg->setfield('bill', '') @@ -402,18 +437,37 @@ sub bill { my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : ''; - my $error = - $self->_make_lines( 'part_pkg' => $part_pkg, - 'cust_pkg' => $cust_pkg, - 'precommit_hooks' => \@precommit_hooks, - 'line_items' => $cust_bill_pkg{$pass}, - 'setup' => $total_setup{$pass}, - 'recur' => $total_recur{$pass}, - 'tax_matrix' => $taxlisthash{$pass}, - 'time' => $time, - 'real_pkgpart' => $real_pkgpart, - 'options' => \%options, - ); + 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} ) { + $error = + $self->_make_lines( 'part_pkg' => $part_pkg, + 'cust_pkg' => $cust_pkg, + 'precommit_hooks' => \@precommit_hooks, + 'line_items' => $cust_bill_pkg{$pass}, + 'setup' => $total_setup{$pass}, + 'recur' => $total_recur{$pass}, + 'tax_matrix' => $taxlisthash{$pass}, + 'time' => $time, + 'real_pkgpart' => $real_pkgpart, + 'options' => \%options, + ); + + # Stop if anything goes wrong + last if $error; + + # or if we're not incrementing the bill date. + last if ($cust_pkg->getfield('bill') || 0) == $next_bill; + + # or if we're letting it run only once + last if $options{cancel}; + + $next_bill = $cust_pkg->getfield('bill') || 0; + + #stop if -o was passed to freeside-daily + last if $options{'one_recur'}; + } if ($error) { $dbh->rollback if $oldAutoCommit && !$options{no_commit}; return $error; @@ -435,8 +489,8 @@ sub bill { 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" + warn "$me billing pass $pass\n" + #.Dumper(\@cust_bill_pkg)."\n" if $DEBUG > 2; if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) || @@ -547,7 +601,7 @@ sub bill { #create the new invoice my $cust_bill = new FS::cust_bill ( { 'custnum' => $self->custnum, - '_date' => ( $invoice_time ), + '_date' => $invoice_time, 'charged' => $charged, 'billing_balance' => $balance, 'previous_balance' => $previous_balance, @@ -580,21 +634,52 @@ sub bill { #discard bundled packages of 0 value sub _omit_zero_value_bundles { + my @in = @_; my @cust_bill_pkg = (); my @cust_bill_pkg_bundle = (); my $sum = 0; + my $discount_show_always = 0; + + 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; - foreach my $cust_bill_pkg ( @_ ) { if (scalar(@cust_bill_pkg_bundle) && !$cust_bill_pkg->pkgpart_override) { - push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0; + 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; } + $sum += $cust_bill_pkg->setup + $cust_bill_pkg->recur; push @cust_bill_pkg_bundle, $cust_bill_pkg; + } - push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0; + + 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 + ) + ); + + warn " _omit_zero_value_bundles: ". scalar(@in). + '->'. scalar(@cust_bill_pkg). "\n" #. Dumper(@cust_bill_pkg). "\n" + if $DEBUG > 2; (@cust_bill_pkg); @@ -641,41 +726,66 @@ jurisdictions (i.e. Texas) have tax exemptions which are date sensitive. sub calculate_taxes { my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_; + # $taxlisthash is a hashref + # keys are identifiers, values are arrayrefs + # each arrayref starts with a tax object (cust_main_county or tax_rate) + # then any cust_bill_pkg objects the tax applies to + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; - warn "$me calculate_taxes\n". - Dumper($self, $cust_bill_pkg, $taxlisthash, $invoice_time). "\n" + warn "$me calculate_taxes\n" + #.Dumper($self, $cust_bill_pkg, $taxlisthash, $invoice_time). "\n" if $DEBUG > 2; 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 taxnums (not internal identifiers!) + # values are arrayrefs of cust_tax_exempt_pkg objects + my %tax_exemption; + foreach my $tax ( keys %$taxlisthash ) { + # $tax is a tax identifier 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 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, 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_object->taxnum} ||= []; my $hashref_or_error = - $tax_object->taxline( $taxlisthash->{$tax}, + $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); + # then collect any new exemptions generated for this tax + push @$exemptions, @{ $_->cust_tax_exempt_pkg } + foreach @$taxables; + unshift @{ $taxlisthash->{$tax} }, $tax_object; my $name = $hashref_or_error->{'name'}; @@ -685,10 +795,12 @@ sub calculate_taxes { $taxname{ $name } ||= []; push @{ $taxname{ $name } }, $tax; - $tax{ $tax } += $amount; + $tax_amount{ $tax } += $amount; + # link records between cust_main_county/tax_rate and cust_location $tax_location{ $tax } ||= []; - if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) { + $tax_rate_location{ $tax } ||= []; + if ( ref($tax_object) eq 'FS::cust_main_county' ) { push @{ $tax_location{ $tax } }, { 'taxnum' => $tax_object->taxnum, @@ -698,9 +810,7 @@ sub calculate_taxes { 'amount' => sprintf('%.2f', $amount ), }; } - - $tax_rate_location{ $tax } ||= []; - if ( ref($tax_object) eq 'FS::tax_rate' ) { + elsif ( ref($tax_object) eq 'FS::tax_rate' ) { my $taxratelocationnum = $tax_object->tax_rate_location->taxratelocationnum; push @{ $tax_rate_location{ $tax } }, @@ -718,26 +828,37 @@ sub calculate_taxes { #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'; - - push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, - splice( @{ $_->_cust_tax_exempt_pkg } ); + my $taxables = $taxlisthash->{$tax}; + my $tax_object = shift @$taxables; # the rest are line items + foreach my $cust_bill_pkg ( @$taxables ) { + next unless ref($cust_bill_pkg) eq 'FS::cust_bill_pkg'; + + my @cust_tax_exempt_pkg = splice @{ $cust_bill_pkg->cust_tax_exempt_pkg }; + + next unless @cust_tax_exempt_pkg; + # get the non-disintegrated version + my $real_cust_bill_pkg = $packagemap{$cust_bill_pkg->pkgnum} + or die "can't distribute tax exemptions: no line item for ". + Dumper($_). " in packagemap ". + join(',', sort {$a<=>$b} keys %packagemap). "\n"; + + push @{ $real_cust_bill_pkg->cust_tax_exempt_pkg }, + @cust_tax_exempt_pkg; } } #consolidate and create tax line items warn "consolidating and generating...\n" if $DEBUG > 2; foreach my $taxname ( keys %taxname ) { - my $tax = 0; + 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}; + warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1; + $tax_total += $tax_amount{$taxitem}; push @cust_bill_pkg_tax_location, map { new FS::cust_bill_pkg_tax_location $_ } @{ $tax_location{ $taxitem } }; @@ -745,9 +866,9 @@ sub calculate_taxes { map { new FS::cust_bill_pkg_tax_rate_location $_ } @{ $tax_rate_location{ $taxitem } }; } - next unless $tax; + next unless $tax_total; - $tax = sprintf('%.2f', $tax ); + $tax_total = sprintf('%.2f', $tax_total ); my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname, 'disabled' => '', @@ -768,7 +889,7 @@ sub calculate_taxes { push @tax_line_items, new FS::cust_bill_pkg { 'pkgnum' => 0, - 'setup' => $tax, + 'setup' => $tax_total, 'recur' => 0, 'sdate' => '', 'edate' => '', @@ -790,7 +911,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 $precommit_hooks = $params{precommit_hooks} or die "no package specified"; + 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"; my $total_recur = $params{recur} or die "no recur accumulator specified"; @@ -798,13 +919,18 @@ sub _make_lines { my $time = $params{'time'} or die "no time specified"; my (%options) = %{$params{options}}; + if ( $part_pkg->freq ne '1' and ($options{'freq_override'} || 0) > 0 ) { + # this should never happen + die 'freq_override billing attempted on non-monthly package '. + $cust_pkg->pkgnum; + } + my $dbh = dbh; my $real_pkgpart = $params{real_pkgpart}; my %hash = $cust_pkg->hash; my $old_cust_pkg = new FS::cust_pkg \%hash; my @details = (); - my @discounts = (); my $lineitems = 0; $cust_pkg->pkgpart($part_pkg->pkgpart); @@ -815,29 +941,36 @@ sub _make_lines { my $setup = 0; my $unitsetup = 0; - if ( $options{'resetup'} - || ( ! $cust_pkg->setup - && ( ! $cust_pkg->start_date - || $cust_pkg->start_date <= $time - ) - && ( ! $conf->exists('disable_setup_suspended_pkgs') - || ( $conf->exists('disable_setup_suspended_pkgs') && - ! $cust_pkg->getfield('susp') - ) - ) - ) - and !$options{recurring_only} - ) + my @setup_discounts = (); + my %setup_param = ( 'discounts' => \@setup_discounts ); + if ( ! $options{recurring_only} + and ! $options{cancel} + and ( $options{'resetup'} + || ( ! $cust_pkg->setup + && ( ! $cust_pkg->start_date + || $cust_pkg->start_date <= day_end($time) + ) + && ( ! $conf->exists('disable_setup_suspended_pkgs') + || ( $conf->exists('disable_setup_suspended_pkgs') && + ! $cust_pkg->getfield('susp') + ) + ) + ) + ) + ) { warn " bill setup\n" if $DEBUG > 1; - $lineitems++; - $setup = eval { $cust_pkg->calc_setup( $time, \@details ) }; - return "$@ running calc_setup for $cust_pkg\n" - if $@; + unless ( $cust_pkg->waive_setup ) { + $lineitems++; - $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh + $setup = eval { $cust_pkg->calc_setup( $time, \@details, \%setup_param ) }; + return "$@ running calc_setup for $cust_pkg\n" + if $@; + + $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh + } $cust_pkg->setfield('setup', $time) unless $cust_pkg->setup; @@ -853,19 +986,21 @@ sub _make_lines { # bill recurring fee ### - #XXX unit stuff here too my $recur = 0; my $unitrecur = 0; + my @recur_discounts = (); my $sdate; - if ( ! $cust_pkg->get('susp') - and ! $cust_pkg->get('start_date') - and ( $part_pkg->getfield('freq') ne '0' - && ( $cust_pkg->getfield('bill') || 0 ) <= $time - ) - || ( $part_pkg->plan eq 'voip_cdr' - && $part_pkg->option('bill_every_call') - ) - || ( $options{cancel} ) + if ( ! $cust_pkg->start_date + and ( ! $cust_pkg->susp || $cust_pkg->option('suspend_bill',1) + || ( $part_pkg->option('suspend_bill', 1) ) + && ! $cust_pkg->option('no_suspend_bill',1) + ) + and + ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= day_end($time) ) + || ( $part_pkg->plan eq 'voip_cdr' + && $part_pkg->option('bill_every_call') + ) + || $options{cancel} ) { # XXX should this be a package event? probably. events are called @@ -885,25 +1020,36 @@ 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 ) <= $time + && ( $cust_pkg->getfield('bill') || 0 ) <= day_end($time) && !$options{cancel} ); - my %param = ( 'precommit_hooks' => $precommit_hooks, + my %param = ( %setup_param, + 'precommit_hooks' => $precommit_hooks, 'increment_next_bill' => $increment_next_bill, - 'discounts' => \@discounts, + 'discounts' => \@recur_discounts, 'real_pkgpart' => $real_pkgpart, 'freq_override' => $options{freq_override} || '', + 'setup_fee' => 0, ); my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur'; # There may be some part_pkg for which this is wrong. Only those # which can_discount are supported. + # (the UI should prevent adding discounts to these at the moment) + warn "calling $method on cust_pkg ". $cust_pkg->pkgnum. + " for pkgpart ". $cust_pkg->pkgpart. + " with params ". join(' / ', map "$_=>$param{$_}", keys %param). "\n" + if $DEBUG > 2; + $recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) }; return "$@ running $method for $cust_pkg\n" if ( $@ ); + #base_cancel??? + $unitrecur = $cust_pkg->part_pkg->base_recur || $recur; #XXX uuh + if ( $increment_next_bill ) { my $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0); @@ -921,6 +1067,20 @@ sub _make_lines { } + if ( $param{'setup_fee'} ) { + # Add an additional setup fee at the billing stage. + # Used for prorate_defer_bill. + $setup += $param{'setup_fee'}; + $unitsetup += $param{'setup_fee'}; + $lineitems++; + } + + if ( defined $param{'discount_left_setup'} ) { + foreach my $discount_setup ( values %{$param{'discount_left_setup'}} ) { + $setup -= $discount_setup; + } + } + } warn "\$setup is undefined" unless defined($setup); @@ -932,7 +1092,7 @@ sub _make_lines { # If $cust_pkg has been modified, update it (if we're a real pkgpart) ### - if ( $lineitems || $options{has_hidden} ) { + if ( $lineitems ) { if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) { # hmm.. and if just the options are modified in some weird price plan? @@ -941,6 +1101,7 @@ sub _make_lines { if $DEBUG >1; my $error = $cust_pkg->replace( $old_cust_pkg, + 'depend_jobnum'=>$options{depend_jobnum}, 'options' => { $cust_pkg->options }, ) unless $options{no_commit}; @@ -957,9 +1118,18 @@ sub _make_lines { return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum; } - if ( $setup != 0 || - $recur != 0 || - !$part_pkg->hidden && $options{has_hidden} ) #include some $0 lines + my $discount_show_always = $conf->exists('discount-show-always') + && ( ($setup == 0 && scalar(@setup_discounts)) + || ($recur == 0 && scalar(@recur_discounts)) + ); + + if ( $setup != 0 + || $recur != 0 + || (!$part_pkg->hidden && $options{has_hidden}) #include some $0 lines + || $discount_show_always + || ($setup == 0 && $cust_pkg->_X_show_zero('setup')) + || ($recur == 0 && $cust_pkg->_X_show_zero('recur')) + ) { warn " charges (setup=$setup, recur=$recur); adding line items\n" @@ -980,16 +1150,21 @@ sub _make_lines { 'unitrecur' => $unitrecur, 'quantity' => $cust_pkg->quantity, 'details' => \@details, - 'discounts' => \@discounts, + 'discounts' => [ @setup_discounts, @recur_discounts ], 'hidden' => $part_pkg->hidden, 'freq' => $part_pkg->freq, }; - if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) { + if ( $part_pkg->option('prorate_defer_bill',1) + and !$hash{last_bill} ) { + # both preceding and upcoming, technically + $cust_bill_pkg->sdate( $cust_pkg->setup ); + $cust_bill_pkg->edate( $cust_pkg->bill ); + } elsif ( $part_pkg->recur_temporality eq 'preceding' ) { $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->option('recur_temporality', 1) 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}; @@ -1005,9 +1180,11 @@ sub _make_lines { # handle taxes ### - my $error = - $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options); - return $error if $error; + 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; + } push @$cust_bill_pkgs, $cust_bill_pkg; @@ -1040,7 +1217,14 @@ sub _handle_taxes { push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel}); push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel}); - if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) { + 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 !~ /Y/i && $self->payby ne 'COMP' ) { + if ( $self->payby ne 'COMP' ) { if ( $conf->exists('enable_taxproducts') && ( scalar($part_pkg->part_pkg_taxoverride) @@ -1049,42 +1233,38 @@ sub _handle_taxes { ) { - if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) { - return "fatal: Can't (yet) use tax-pkg_address with taxproducts"; - } + if ( !$exempt ) { - foreach my $class (@classes) { - my $err_or_ref = $self->_gather_taxes( $part_pkg, $class ); - return $err_or_ref unless ref($err_or_ref); - $taxes{$class} = $err_or_ref; - } + foreach my $class (@classes) { + my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg ); + 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 ); + return $err_or_ref unless ref($err_or_ref); + $taxes{''} = $err_or_ref; + } - unless (exists $taxes{''}) { - my $err_or_ref = $self->_gather_taxes( $part_pkg, '' ); - return $err_or_ref unless ref($err_or_ref); - $taxes{''} = $err_or_ref; } - } else { + } else { # cust_main_county tax system - my @loc_keys = qw( city county state country ); - my %taxhash; - if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) { - my $cust_location = $cust_pkg->cust_location; - %taxhash = map { $_ => $cust_location->$_() } @loc_keys; - } else { - my $prefix = - ( $conf->exists('tax-ship_address') && length($self->ship_last) ) - ? 'ship_' - : ''; - %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys; - } + # 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 $location = $cust_pkg->tax_location; + my %taxhash = map { $_ => $location->$_ } @loc_keys; $taxhash{'taxclass'} = $part_pkg->taxclass; - my @taxes = (); + warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2; + + my @taxes = (); # entries are cust_main_county objects my %taxhash_elim = %taxhash; - my @elim = qw( city county state ); + my @elim = qw( district city county state ); do { #first try a match with taxclass @@ -1101,15 +1281,11 @@ sub _handle_taxes { } while ( !scalar(@taxes) && scalar(@elim) ); - @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) } - @taxes - if $self->cust_main_exemption; #just to be safe - - if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) { - foreach (@taxes) { - $_->set('pkgnum', $cust_pkg->pkgnum ); - $_->set('locationnum', $cust_pkg->locationnum ); - } + foreach (@taxes) { + # These could become cust_bill_pkg_tax_location records, + # or cust_tax_exempt_pkg. We'll decide later. + $_->set('pkgnum', $cust_pkg->pkgnum); + $_->set('locationnum', $cust_pkg->tax_locationnum); } $taxes{''} = [ @taxes ]; @@ -1126,61 +1302,38 @@ sub _handle_taxes { } #if $conf->exists('enable_taxproducts') ... - } - - my @display = (); - my $separate = $conf->exists('separate_usage'); - my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart }; - my $usage_mandate = $temp_pkg->part_pkg->option('usage_mandate', 'Hush!'); - my $section = $temp_pkg->part_pkg->categoryname; - if ( $separate || $section || $usage_mandate ) { - - my %hash = ( 'section' => $section ); - - $section = $temp_pkg->part_pkg->option('usage_section', 'Hush!'); - my $summary = $temp_pkg->part_pkg->option('summarize_usage', 'Hush!'); - if ( $separate ) { - push @display, new FS::cust_bill_pkg_display { type => 'S', %hash }; - push @display, new FS::cust_bill_pkg_display { type => 'R', %hash }; - } else { - push @display, new FS::cust_bill_pkg_display - { type => '', - %hash, - ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ), - }; - } - - if ($separate && $section && $summary) { - push @display, new FS::cust_bill_pkg_display { type => 'U', - summary => 'Y', - %hash, - }; - } - if ($usage_mandate || $section && $summary) { - $hash{post_total} = 'Y'; - } - - if ($separate || $usage_mandate) { - $hash{section} = $section if ($separate || $usage_mandate); - push @display, new FS::cust_bill_pkg_display { type => 'U', %hash }; - } + } # if $self->payby eq 'COMP' - } - $cust_bill_pkg->set('display', \@display); + #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; - + $taxname .= ' pkgnum'. $cust_pkg->pkgnum; + # We need to create a separate $taxlisthash entry for each pkgnum + # 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 + # tax applies to. $taxlisthash->{ $taxname } ||= [ $tax ]; push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg; @@ -1227,11 +1380,18 @@ sub _gather_taxes { my $self = shift; my $part_pkg = shift; my $class = shift; + my $cust_pkg = 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 $geocode = $self->geocode('cch'); my @taxclassnums = map { $_->taxclassnum } $part_pkg->part_pkg_taxoverride($class); @@ -1393,8 +1553,12 @@ sub retry_realtime { my $mine = '( ' . join ( ' OR ' , map { + my $cust_join = FS::part_event->eventtables_cust_join->{$_} || ''; + my $custnum = FS::part_event->eventtables_custnum->{$_}; "( part_event.eventtable = " . dbh->quote($_) - . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ; + . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key + . " from $_ $cust_join" + . " where $custnum = " . dbh->quote( $self->custnum ) . "))" ; } FS::part_event->eventtables) . ') '; @@ -1410,17 +1574,23 @@ sub retry_realtime { cust_bill_batch ); - my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'", - @realtime_events - ). - ' ) '; + my $is_realtime_event = + ' part_event.action IN ( '. + join(',', map "'$_'", @realtime_events ). + ' ) '; + + my $batch_or_statustext = + "( part_event.action = 'cust_bill_batch' + OR ( statustext IS NOT NULL AND statustext != '' ) + )"; + - my @cust_event = qsearchs({ + my @cust_event = qsearch({ 'table' => 'cust_event', 'select' => 'cust_event.*', 'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join", 'hashref' => { 'status' => 'done' }, - 'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ". + 'extra_sql' => " AND $batch_or_statustext ". " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1" }); @@ -1554,7 +1724,7 @@ sub do_cust_event { #XXX lock event #re-eval event conditions (a previous event could have changed things) - unless ( $cust_event->test_conditions( 'time' => $time ) ) { + unless ( $cust_event->test_conditions ) { #don't leave stray "new/locked" records around my $error = $cust_event->delete; return $error if $error; @@ -1568,7 +1738,7 @@ sub do_cust_event { if $DEBUG > 1; #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options? - if ( my $error = $cust_event->do_event() ) { + if ( my $error = $cust_event->do_event( 'time' => $time ) ) { #XXX wtf is this? figure out a proper dealio with return value #from do_event return $error; @@ -1625,174 +1795,6 @@ Explicitly pass the objects to be tested (typically used with eventtable). Set to true to return the objects, but not actually insert them into the database. -=item discount_terms - -Returns a list of lengths for term discounts - -=cut - -sub _discount_pkgs_and_bill { -my $self = shift; - - my @cust_bill = $self->cust_bill; - my $cust_bill = pop @cust_bill; - return () unless $cust_bill && $cust_bill->owed; - - my @where = (); - push @where, "cust_bill_pkg.invnum = ". $cust_bill->invnum; - push @where, "cust_bill_pkg.pkgpart_override IS NULL"; - push @where, "part_pkg.freq = '1'"; - push @where, "(cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0)"; - push @where, "(cust_pkg.susp IS NULL OR cust_pkg.susp = 0)"; - push @where, "0<(SELECT count(*) FROM part_pkg_discount - WHERE part_pkg.pkgpart = part_pkg_discount.pkgpart)"; - push @where, - "0=(SELECT count(*) FROM cust_bill_pkg_discount - WHERE cust_bill_pkg.billpkgnum = cust_bill_pkg_discount.billpkgnum)"; - - my $extra_sql = 'WHERE '. join(' AND ', @where); - - my @cust_pkg = - qsearch({ - 'table' => 'cust_pkg', - 'select' => "DISTINCT cust_pkg.*", - 'addl_from' => 'JOIN cust_bill_pkg USING(pkgnum) '. - 'JOIN part_pkg USING(pkgpart)', - 'hashref' => {}, - 'extra_sql' => $extra_sql, - }); - - ($cust_bill, @cust_pkg); -} - -sub _discountable_pkgs_at_term { - my ($term, @pkgs) = @_; - my $part_pkg = new FS::part_pkg { freq => $term - 1 }; - grep { ( !$_->adjourn || $_->adjourn > $part_pkg->add_freq($_->bill) ) && - ( !$_->expire || $_->expire > $part_pkg->add_freq($_->bill) ) - } - @pkgs; -} - -=item discount_terms - -Returns a list of lengths for term discounts - -=cut - -sub discount_terms { -my $self = shift; - - my %terms = (); - - my @discount_pkgs = $self->_discount_pkgs_and_bill; - shift @discount_pkgs; #discard bill; - - map { $terms{$_->months} = 1 } - grep { $_->months && $_->months > 1 } - map { $_->discount } - map { $_->part_pkg->part_pkg_discount } - @discount_pkgs; - - return sort { $a <=> $b } keys %terms; - -} - -=back - -=item discount_term_values MONTHS - -Returns a list with credit, dollar amount saved, and total bill acheived -by prepaying the most recent invoice for MONTHS. - -=cut - -sub discount_term_values { - my $self = shift; - my $term = shift; - - local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; - - warn "$me discount_term_values called with $term\n" if $DEBUG; - - my %result = (); - - my @packages = $self->_discount_pkgs_and_bill; - my $cust_bill = shift(@packages); - @packages = _discountable_pkgs_at_term( $term, @packages ); - return () unless scalar(@packages); - - $_->bill($_->last_bill) foreach @packages; - my @final = map { new FS::cust_pkg { $_->hash } } @packages; - - my %options = ( - 'recurring_only' => 1, - 'no_usage_reset' => 1, - 'no_commit' => 1, - ); - - my %params = ( - 'return_bill' => [], - 'pkg_list' => \@packages, - 'time' => $cust_bill->_date, - ); - - my $error = $self->bill(%options, %params); - die $error if $error; # XXX think about this a bit more - - my $credit = 0; - $credit += $_->charged foreach @{$params{return_bill}}; - $credit = sprintf('%.2f', $credit); - warn "$me discount_term_values $term credit: $credit\n" if $DEBUG; - - %params = ( - 'return_bill' => [], - 'pkg_list' => \@packages, - 'time' => $packages[0]->part_pkg->add_freq($cust_bill->_date) - ); - - $error = $self->bill(%options, %params); - die $error if $error; # XXX think about this a bit more - - my $next = 0; - $next += $_->charged foreach @{$params{return_bill}}; - warn "$me discount_term_values $term next: $next\n" if $DEBUG; - - %params = ( - 'return_bill' => [], - 'pkg_list' => \@final, - 'time' => $cust_bill->_date, - 'freq_override' => $term, - ); - - $error = $self->bill(%options, %params); - die $error if $error; # XXX think about this a bit more - - my $final = $self->balance - $credit; - $final += $_->charged foreach @{$params{return_bill}}; - $final = sprintf('%.2f', $final); - warn "$me discount_term_values $term final: $final\n" if $DEBUG; - - my $savings = sprintf('%.2f', $self->balance + ($term - 1) * $next - $final); - - ( $credit, $savings, $final ); - -} - -sub discount_terms_hash { - my $self = shift; - - my %result = (); - my @terms = $self->discount_terms; - foreach my $term (@terms) { - my @result = $self->discount_term_values($term); - $result{$term} = [ @result ] if scalar(@result); - } - - return %result; - -} - =back =cut @@ -1846,45 +1848,45 @@ sub due_cust_event { @objects = @{ $opt{'objects'} }; - } else { + } elsif ( $eventtable eq 'cust_main' ) { - #my @objects = $self->$eventtable(); # sub cust_main { @{ [ $self ] }; } - if ( $eventtable eq 'cust_main' ) { - @objects = ( $self ); - } else { - - my $cm_join = - "LEFT JOIN cust_main USING ( custnum )"; - - #some false laziness w/Cron::bill bill_where - - my $join = FS::part_event_condition->join_conditions_sql( $eventtable); - my $where = FS::part_event_condition->where_conditions_sql($eventtable, - 'time'=>$opt{'time'}, - ); - $where = $where ? "AND $where" : ''; - - my $are_part_event = - "EXISTS ( SELECT 1 FROM part_event $join - WHERE check_freq = '$check_freq' - AND eventtable = '$eventtable' - AND ( disabled = '' OR disabled IS NULL ) - $where - ) - "; - #eofalse - - @objects = $self->$eventtable( - 'addl_from' => $cm_join, - 'extra_sql' => " AND $are_part_event", - ); - } + @objects = ( $self ); - } + } else { + + my $cm_join = " LEFT JOIN cust_main USING ( custnum )"; + # linkage not needed here because FS::cust_main->$eventtable will + # already supply it + + #some false laziness w/Cron::bill bill_where + + my $join = FS::part_event_condition->join_conditions_sql( $eventtable); + my $where = FS::part_event_condition->where_conditions_sql($eventtable, + 'time'=>$opt{'time'}, + ); + $where = $where ? "AND $where" : ''; + + my $are_part_event = + "EXISTS ( SELECT 1 FROM part_event $join + WHERE check_freq = '$check_freq' + AND eventtable = '$eventtable' + AND ( disabled = '' OR disabled IS NULL ) + $where + ) + "; + #eofalse + + @objects = $self->$eventtable( + 'addl_from' => $cm_join, + 'extra_sql' => " AND $are_part_event", + ); + } # if ( !$opt{objects} and $eventtable ne 'cust_main' ) my @e_cust_event = (); - my $cross = "CROSS JOIN $eventtable"; + my $linkage = FS::part_event->eventtables_cust_join->{$eventtable} || ''; + + my $cross = "CROSS JOIN $eventtable $linkage"; $cross .= ' LEFT JOIN cust_main USING ( custnum )' unless $eventtable eq 'cust_main'; @@ -1932,7 +1934,9 @@ sub due_cust_event { " possible events found for $eventtable ". $object->$pkey(). "\n"; } - push @e_cust_event, map { $_->new_cust_event($object) } @part_event; + push @e_cust_event, map { + $_->new_cust_event($object, 'time' => $opt{'time'}) + } @part_event; } @@ -1966,8 +1970,7 @@ sub due_cust_event { my %unsat = (); - @cust_event = grep $_->test_conditions( 'time' => $opt{'time'}, - 'stats_hashref' => \%unsat ), + @cust_event = grep $_->test_conditions( 'stats_hashref' => \%unsat ), @cust_event; warn " ". scalar(@cust_event). " cust events left satisfying conditions\n" @@ -2220,11 +2223,14 @@ sub apply_payments { my $amount = min( $payment->unapplied, $owed ); - my $cust_bill_pay = new FS::cust_bill_pay ( { + my $cbp = { 'paynum' => $payment->paynum, 'invnum' => $cust_bill->invnum, 'amount' => $amount, - } ); + }; + $cbp->{_date} = $payment->_date + if $options{'manual'} && $options{'backdate_application'}; + my $cust_bill_pay = new FS::cust_bill_pay($cbp); $cust_bill_pay->pkgnum( $payment->pkgnum ) if $conf->exists('pkg-balances') && $payment->pkgnum; my $error = $cust_bill_pay->insert(%options); @@ -2244,6 +2250,29 @@ sub apply_payments { return $total_unapplied_payments; } +=back + +=head1 FLOW + + bill_and_collect + + cancel_expired_pkgs + suspend_adjourned_pkgs + unsuspend_resumed_pkgs + + bill + (do_cust_event pre-bill) + _make_lines + _handle_taxes + (vendor-only) _gather_taxes + _omit_zero_value_bundles + calculate_taxes + + apply_payments_and_credits + collect + do_cust_event + due_cust_event + =head1 BUGS =head1 SEE ALSO