X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_main%2FBilling.pm;h=deb5e84d195f87964dbb26d755f299352b160119;hp=5efeb826847af50ab62d33d42123aabbd1f972a4;hb=07ed221540128b8c75f4cb5a2af1e01b25fa8e18;hpb=035c0cf4bb01805d2bacec7971597f03a6491af6 diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 5efeb8268..c0c15e44c 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -1,12 +1,15 @@ package FS::cust_main::Billing; use strict; +use feature 'state'; use vars qw( $conf $DEBUG $me ); use Carp; 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 Tie::RefHash; use FS::cust_bill; use FS::cust_bill_pkg; use FS::cust_bill_pkg_display; @@ -20,6 +23,10 @@ use FS::cust_bill_pkg_tax_rate_location; use FS::part_event; use FS::part_event_condition; use FS::pkg_category; +use FS::FeeOrigin_Mixin; +use FS::Log; +use FS::TaxEngine; +use FS::Misc::Savepoint; # 1 is mostly method/subroutine entry and options # 2 traces progress of some operations @@ -52,7 +59,7 @@ Cancels and suspends any packages due, generates bills, applies payments and credits, and applies collection events to run cards, send bills and notices, etc. -By default, warns on errors and continues with the next operation (but see the +Any errors prevent subsequent operations from continuing and die (but see the "fatal" flag below). Options are passed as name-value pairs. Currently available options are: @@ -103,6 +110,10 @@ options of those methods are also available. sub bill_and_collect { my( $self, %options ) = @_; + my $log = FS::Log->new('FS::cust_main::Billing::bill_and_collect'); + my %logopt = (object => $self); + $log->debug('start', %logopt); + my $error; #$options{actual_time} not $options{time} because freeside-daily -d is for @@ -111,54 +122,114 @@ 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( $options{actual_time} ); + $log->debug('canceling expired packages', %logopt); + $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; } - elsif ( $options{fatal} ) { die $error; } - else { warn $error; } + else { die $error; } } - $error = $self->suspend_adjourned_pkgs( $options{actual_time} ); + $log->debug('suspending adjourned packages', %logopt); + $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; } - elsif ( $options{fatal} ) { die $error; } - else { warn $error; } + else { die $error; } + } + + $log->debug('unsuspending resumed packages', %logopt); + $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; } + else { die $error; } + } + + my $tax_district_method = $conf->config('tax_district_method'); + if ( $tax_district_method && $tax_district_method eq 'wa_sales' ) { + # When using Washington State Sales Tax Districts, + # Bail out of billing customer if sales tax district for location is missing + + $log->debug('checking cust_location tax districts', %logopt); + + if ( + my @cust_locations_missing_district = + $self->cust_locations_missing_district + ) { + $error = sprintf + 'cust_location missing tax district: '. + join( ', ' => ( + map( + { + sprintf + 'locationnum(%s) %s %s %s %s', + $_->locationnum, + $_->address1, + $_->city, + $_->state, + $_->zip + } + @cust_locations_missing_district + ) + )); + } + } + if ( $error ) { + $error = "Error calculating taxes ".$self->custnum. ": $error"; + if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; } + else { die $error; } } $job->update_statustext('20,billing packages') if $job; + $log->debug('billing packages', %logopt); $error = $self->bill( %options ); if ( $error ) { $error = "Error billing custnum ". $self->custnum. ": $error"; if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; } - elsif ( $options{fatal} ) { die $error; } - else { warn $error; } + else { die $error; } } $job->update_statustext('50,applying payments and credits') if $job; + $log->debug('applying payments and credits', %logopt); $error = $self->apply_payments_and_credits; if ( $error ) { $error = "Error applying custnum ". $self->custnum. ": $error"; if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; } - elsif ( $options{fatal} ) { die $error; } - else { warn $error; } + else { die $error; } } - $job->update_statustext('70,running collection events') if $job; - unless ( $conf->exists('cancelled_cust-noevents') - && ! $self->num_ncancelled_pkgs - ) { + # In a batch tax environment, do not run collection if any pending + # invoices were created. Collection will run after the next tax batch. + state $is_batch_tax = FS::TaxEngine->new->info->{batch} ? 1 : 0; + if ( $is_batch_tax && $self->pending_invoice_count ) { + warn "skipped collection for custnum ".$self->custnum. + " due to pending invoices\n" if $DEBUG; + } elsif ( $conf->exists('cancelled_cust-noevents') + && ! $self->num_ncancelled_pkgs ) + { + warn "skipped collection for custnum ".$self->custnum. + " because they have no active packages\n" if $DEBUG; + } else { + # run collection normally + $job->update_statustext('70,running collection events') if $job; + $log->debug('running collection events', %logopt); $error = $self->collect( %options ); if ( $error ) { $error = "Error collecting custnum ". $self->custnum. ": $error"; if ($options{fatal} && $options{fatal} eq 'return') { return $error; } - elsif ($options{fatal} ) { die $error; } - else { warn $error; } + else { die $error; } } } + $job->update_statustext('100,finished') if $job; + $log->debug('finish', %logopt); ''; @@ -166,30 +237,58 @@ 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 " } ); my @errors = (); - foreach my $cust_pkg ( @cancel_pkgs ) { + my @really_cancel_pkgs = (); + my @cancel_reasons = (); + + CUST_PKG: foreach my $cust_pkg ( @cancel_pkgs ) { my $cpr = $cust_pkg->last_cust_pkg_reason('expire'); - my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum, - 'reason_otaker' => $cpr->otaker - ) - : () - ); - push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $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; + } + my $error = $cust_pkg->change( 'cust_pkg' => $new_pkg, + 'unprotect_svcs' => 1, + ); + push @errors, $error if $error && ref($error) ne 'FS::cust_pkg'; + + } else { # just cancel it + + push @really_cancel_pkgs, $cust_pkg; + push @cancel_reasons, $cpr; + + } } - scalar(@errors) ? join(' / ', @errors) : ''; + if (@really_cancel_pkgs) { + + my %cancel_opt = ( 'cust_pkg' => \@really_cancel_pkgs, + 'cust_pkg_reason' => \@cancel_reasons, + 'time' => $time, + ); + + push @errors, $self->cancel_pkgs(%cancel_opt); + + } + + join(' / ', @errors); } 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 +324,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 +369,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 @@ -272,6 +391,10 @@ An array ref of specific packages (objects) to attempt billing, instead trying a A hashref of pkgparts to exclude from this billing run (can also be specified as a comma-separated scalar). +=item no_prepaid + +Do not bill prepaid packages. Used by freeside-daily. + =item invoice_time Used in conjunction with the I