X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main%2FBilling.pm;h=bab94c31d4ebf8e8c54b35f3c50fcb5b9e55b72f;hb=a6fe07e49e3fc12169e801b1ed6874c3a5bd8500;hp=20986b0c8e96ae3e33c701639d79448b96ecf225;hpb=13486650e774b398c825b15f37c5e3abd9809aae;p=freeside.git diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 20986b0c8..bab94c31d 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -7,6 +7,7 @@ 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; @@ -20,7 +21,6 @@ use FS::cust_bill_pkg_tax_rate_location; use FS::part_event; use FS::part_event_condition; use FS::pkg_category; -use POSIX; # 1 is mostly method/subroutine entry and options # 2 traces progress of some operations @@ -113,7 +113,7 @@ sub bill_and_collect { my $job = $options{'job'}; $job->update_statustext('0,cleaning expired packages') if $job; - $error = $self->cancel_expired_pkgs( $self->day_end( $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; } @@ -121,7 +121,7 @@ sub bill_and_collect { else { warn $error; } } - $error = $self->suspend_adjourned_pkgs( $self->day_end( $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; } @@ -129,6 +129,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 ) { @@ -165,19 +173,6 @@ sub bill_and_collect { } -sub day_end { - # XXX: sometimes "incorrect" if crossing DST boundaries? - - my $self = shift; - my $time = shift; - - return $time unless $conf->exists('next-bill-ignore-time'); - - my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = - localtime($time); - mktime(59,59,23,$mday,$mon,$year,$wday,$yday,$isdst); -} - sub cancel_expired_pkgs { my ( $self, $time, %options ) = @_; @@ -190,14 +185,15 @@ 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); } @@ -239,7 +235,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); } @@ -266,7 +280,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 @@ -393,7 +409,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', '') @@ -418,7 +434,8 @@ sub bill { my $next_bill = $cust_pkg->getfield('bill') || 0; my $error; - while ( $next_bill <= $time ) { + # 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, @@ -431,11 +448,20 @@ sub bill { 'real_pkgpart' => $real_pkgpart, 'options' => \%options, ); - # Stop if anything goes wrong, or if we're not incrementing - # the bill date. + + # 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}; @@ -695,6 +721,11 @@ 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" @@ -720,9 +751,15 @@ sub calculate_taxes { my %tax_rate_location = (); 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 my $hashref_or_error = $tax_object->taxline( $taxlisthash->{$tax}, 'custnum' => $self->custnum, @@ -741,8 +778,10 @@ sub calculate_taxes { $tax{ $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, @@ -752,9 +791,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 } }, @@ -851,7 +888,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"; @@ -859,13 +896,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); @@ -876,13 +918,14 @@ sub _make_lines { my $setup = 0; my $unitsetup = 0; - my %setup_param = (); + 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 <= $self->day_end($time) + || $cust_pkg->start_date <= day_end($time) ) && ( ! $conf->exists('disable_setup_suspended_pkgs') || ( $conf->exists('disable_setup_suspended_pkgs') && @@ -920,14 +963,17 @@ 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->start_date - and ( ! $cust_pkg->susp || $part_pkg->option('suspend_bill', 1) ) + 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 ) <= $self->day_end($time) ) + ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= day_end($time) ) || ( $part_pkg->plan eq 'voip_cdr' && $part_pkg->option('bill_every_call') ) @@ -951,16 +997,16 @@ 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 ) <= $self->day_end($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, - %setup_param, ); my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur'; @@ -978,6 +1024,9 @@ sub _make_lines { 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); @@ -1046,8 +1095,10 @@ sub _make_lines { return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum; } - my $discount_show_always = ($recur == 0 && scalar(@discounts) - && $conf->exists('discount-show-always')); + my $discount_show_always = $conf->exists('discount-show-always') + && ( ($setup == 0 && scalar(@setup_discounts)) + || ($recur == 0 && scalar(@recur_discounts)) + ); if ( $setup != 0 || $recur != 0 @@ -1076,12 +1127,17 @@ 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->recur_temporality 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}; @@ -1138,7 +1194,11 @@ 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; + + if ( $exempt !~ /Y/i && $self->payby ne 'COMP' ) { if ( $conf->exists('enable_taxproducts') && ( scalar($part_pkg->part_pkg_taxoverride) @@ -1161,24 +1221,15 @@ sub _handle_taxes { } else { - 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; - } + 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 = (); + 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 @@ -1199,11 +1250,13 @@ sub _handle_taxes { @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 ); - } + # 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); } $taxes{''} = [ @taxes ]; @@ -1230,17 +1283,27 @@ sub _handle_taxes { 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; @@ -1460,8 +1523,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) . ') '; @@ -1477,17 +1544,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" }); @@ -1621,7 +1694,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; @@ -1745,45 +1818,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'; @@ -1831,7 +1904,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; } @@ -1865,8 +1940,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" @@ -2154,6 +2228,7 @@ sub apply_payments { cancel_expired_pkgs suspend_adjourned_pkgs + unsuspend_resumed_pkgs bill (do_cust_event pre-bill)