X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_pkg.pm;h=3e87aadf57d4849132bd33bb885ade761dbc1785;hp=b1cdf45d46f538a897568747cd343ee5883708a6;hb=470878235c5f61ca7acc6ef618943071ca9736b5;hpb=71b86f3f89e8fdeb7142fa82cdbe3e3afce7a903 diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index b1cdf45d4..3e87aadf5 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -1,5 +1,5 @@ package FS::cust_pkg; -use base qw( FS::cust_pkg::Search +use base qw( FS::cust_pkg::Search FS::cust_pkg::API FS::otaker_Mixin FS::cust_main_Mixin FS::Sales_Mixin FS::contact_Mixin FS::location_Mixin FS::m2m_Common FS::option_Common @@ -106,6 +106,8 @@ FS::cust_pkg - Object methods for cust_pkg objects $seconds = $record->seconds_since($timestamp); + #bulk cancel+order... perhaps slightly deprecated, only used by the bulk + # cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi) $error = FS::cust_pkg::order( $custnum, \@pkgparts ); $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] ); @@ -241,6 +243,39 @@ sub cust_unlinked_msg { ' (cust_pkg.pkgnum '. $self->pkgnum. ')'; } +=item set_initial_timers + +If required by the package definition, sets any automatic expire, adjourn, +or contract_end timers to some number of months after the start date +(or setup date, if the package has already been setup). If the package has +a delayed setup fee after a period of "free days", will also set the +start date to the end of that period. + +=cut + +sub set_initial_timers { + my $self = shift; + my $part_pkg = $self->part_pkg; + foreach my $action ( qw(expire adjourn contract_end) ) { + my $months = $part_pkg->option("${action}_months",1); + if($months and !$self->get($action)) { + my $start = $self->start_date || $self->setup || time; + $self->set($action, $part_pkg->add_freq($start, $months) ); + } + } + + # if this package has "free days" and delayed setup fee, then + # set start date that many days in the future. + # (this should have been set in the UI, but enforce it here) + if ( $part_pkg->option('free_days',1) + && $part_pkg->option('delay_setup',1) + ) + { + $self->start_date( $part_pkg->default_start_date ); + } + ''; +} + =item insert [ OPTION => VALUE ... ] Adds this billing item to the database ("Orders" the item). If there is an @@ -305,6 +340,9 @@ sub insert { if ( ! $import && ! $options{'change'} ) { + # set order date to now + $self->order_date(time) unless ($import && $self->order_date); + # if the package def says to start only on the first of the month: if ( $part_pkg->option('start_1st', 1) && !$self->start_date ) { my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5]; @@ -313,35 +351,17 @@ sub insert { $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) ); } - # set up any automatic expire/adjourn/contract_end timers - # based on the start date - foreach my $action ( qw(expire adjourn contract_end) ) { - my $months = $part_pkg->option("${action}_months",1); - if($months and !$self->$action) { - my $start = $self->start_date || $self->setup || time; - $self->$action( $part_pkg->add_freq($start, $months) ); - } - } - - # if this package has "free days" and delayed setup fee, then - # set start date that many days in the future. - # (this should have been set in the UI, but enforce it here) - if ( ! $options{'change'} - && $part_pkg->option('free_days', 1) - && $part_pkg->option('delay_setup',1) - #&& ! $self->start_date - ) - { - $self->start_date( $part_pkg->default_start_date ); + if ($self->susp eq 'now' or $part_pkg->start_on_hold) { + # if the package was ordered on hold: + # - suspend it + # - don't set the start date (it will be started manually) + $self->set('susp', $self->order_date); + $self->set('start_date', ''); + } else { + # set expire/adjourn/contract_end timers, and free days, if appropriate + $self->set_initial_timers; } - } - - # set order date unless it was specified as part of an import - # or this was previously a different package - $self->order_date(time) unless ($import && $self->order_date) - or $self->change_pkgnum; - - $self->susp( $self->order_date ) if $self->susp eq 'now'; + } # else this is a package change, and shouldn't have "new package" behavior my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; @@ -649,8 +669,9 @@ sub check { || $self->ut_numbern('resume') || $self->ut_numbern('expire') || $self->ut_numbern('dundate') - || $self->ut_enum('no_auto', [ '', 'Y' ]) - || $self->ut_enum('waive_setup', [ '', 'Y' ]) + || $self->ut_flag('no_auto', [ '', 'Y' ]) + || $self->ut_flag('waive_setup', [ '', 'Y' ]) + || $self->ut_flag('separate_bill') || $self->ut_textn('agent_pkgid') || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ]) || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ]) @@ -766,18 +787,30 @@ to a different pkgpart or location, and probably shouldn't be in any other case. If it's not set, the 'unused_credit_cancel' part_pkg option will be used. +=item no_delay_cancel - prevents delay_cancel behavior +no matter what other options say, for use when changing packages (or any +other time you're really sure you want an immediate cancel) + =back If there is an error, returns the error, otherwise returns false. =cut +#NOT DOCUMENTING - this should only be used when calling recursively +#=item delay_cancel - for internal use, to allow proper handling of +#supplemental packages when the main package is flagged to suspend +#before cancelling, probably shouldn't be used otherwise (set the +#corresponding package option instead) + sub cancel { my( $self, %options ) = @_; my $error; # pass all suspend/cancel actions to the main package - if ( $self->main_pkgnum and !$options{'from_main'} ) { + # (unless the pkglinknum has been removed, then the link is defunct and + # this package can be canceled on its own) + if ( $self->main_pkgnum and $self->pkglinknum and !$options{'from_main'} ) { return $self->main_pkg->cancel(%options); } @@ -803,6 +836,21 @@ sub cancel { my $date = $options{'date'} if $options{'date'}; # expire/cancel later $date = '' if ($date && $date <= $cancel_time); # complain instead? + my $delay_cancel = $options{'no_delay_cancel'} ? 0 : $options{'delay_cancel'}; + if ( !$date && $self->part_pkg->option('delay_cancel',1) + && (($self->status eq 'active') || ($self->status eq 'suspended')) + && !$options{'no_delay_cancel'} + ) { + my $expdays = $conf->config('part_pkg-delay_cancel-days') || 1; + my $expsecs = 60*60*24*$expdays; + my $suspfor = $self->susp ? $cancel_time - $self->susp : 0; + $expsecs = $expsecs - $suspfor if $suspfor; + unless ($expsecs <= 0) { #if it's already been suspended long enough, don't re-suspend + $delay_cancel = 1; + $date = $cancel_time + $expsecs; + } + } + #race condition: usage could be ongoing until unprovisioned #resolved by performing a change package instead (which unprovisions) and #later cancelling @@ -867,22 +915,32 @@ sub cancel { return $error; } } - } #unless $date my %hash = $self->hash; if ( $date ) { $hash{'expire'} = $date; + if ($delay_cancel) { + # just to be sure these are clear + $hash{'adjourn'} = undef; + $hash{'resume'} = undef; + } } else { $hash{'cancel'} = $cancel_time; } $hash{'change_custnum'} = $options{'change_custnum'}; + # if this is a supplemental package that's lost its part_pkg_link, and it's + # being canceled for real, unlink it completely + if ( !$date and ! $self->pkglinknum ) { + $hash{main_pkgnum} = ''; + } + my $new = new FS::cust_pkg ( \%hash ); $error = $new->replace( $self, options => { $self->options } ); if ( $self->change_to_pkgnum ) { my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum); - $error ||= $change_to->cancel || $change_to->delete; + $error ||= $change_to->cancel('no_delay_cancel' => 1) || $change_to->delete; } if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -890,18 +948,31 @@ sub cancel { } foreach my $supp_pkg ( $self->supplemental_pkgs ) { - $error = $supp_pkg->cancel(%options, 'from_main' => 1); + $error = $supp_pkg->cancel(%options, + 'from_main' => 1, + 'date' => $date, #in case it got changed by delay_cancel + 'delay_cancel' => $delay_cancel, + ); if ( $error ) { $dbh->rollback if $oldAutoCommit; return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error"; } } - foreach my $usage ( $self->cust_pkg_usage ) { - $error = $usage->delete; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "deleting usage pools: $error"; + if ($delay_cancel && !$options{'from_main'}) { + $error = $new->suspend( + 'from_cancel' => 1, + 'time' => $cancel_time + ); + } + + unless ($date) { + foreach my $usage ( $self->cust_pkg_usage ) { + $error = $usage->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "deleting usage pools: $error"; + } } } @@ -921,7 +992,7 @@ sub cancel { } else { $error = send_email( - 'from' => $conf->config('invoice_from', $self->cust_main->agentnum), + 'from' => $conf->invoice_from_full( $self->cust_main->agentnum ), 'to' => \@invoicing_list, 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ), 'body' => [ map "$_\n", $conf->config('cancelmessage') ], @@ -1004,7 +1075,8 @@ sub uncancel { setup susp adjourn resume expire start_date contract_end dundate change_date change_pkgpart change_locationnum - manual_flag no_auto quantity agent_pkgid recur_show_zero setup_show_zero + manual_flag no_auto separate_bill quantity agent_pkgid + recur_show_zero setup_show_zero ), }; @@ -1181,7 +1253,7 @@ Available options are: =over 4 -=item reason - can be set to a cancellation reason (see L), +=item reason - can be set to a cancellation reason (see L), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: - typenum - Reason type (see L @@ -1200,6 +1272,9 @@ separately. =item from_main - allows a supplemental package to be suspended, rather than redirecting the method call to its main package. For internal use. +=item from_cancel - used when suspending from the cancel method, forces +this to skip everything besides basic suspension. For internal use. + =back If there is an error, returns the error, otherwise returns false. @@ -1242,7 +1317,7 @@ sub suspend { } # some false laziness with sub cancel - if ( !$options{nobill} && !$date && + if ( !$options{nobill} && !$date && !$options{'from_cancel'} && $self->part_pkg->option('bill_suspend_as_cancel',1) ) { # kind of a kludge--'bill_suspend_as_cancel' to avoid having to # make the entire cust_main->bill path recognize 'suspend' and @@ -1259,6 +1334,7 @@ sub suspend { if $error; } + my $cust_pkg_reason; if ( $options{'reason'} ) { $error = $self->insert_reason( 'reason' => $options{'reason'}, 'action' => $date ? 'adjourn' : 'suspend', @@ -1269,6 +1345,21 @@ sub suspend { dbh->rollback if $oldAutoCommit; return "Error inserting cust_pkg_reason: $error"; } + $cust_pkg_reason = qsearchs('cust_pkg_reason', { + 'date' => $date ? $date : $suspend_time, + 'action' => $date ? 'A' : 'S', + 'pkgnum' => $self->pkgnum, + }); + } + + # if a reasonnum was passed, get the actual reason object so we can check + # unused_credit + # (passing a reason hashref is still allowed, but it can't be used with + # the fancy behavioral options.) + + my $reason; + if ($options{'reason'} =~ /^\d+$/) { + $reason = FS::reason->by_key($options{'reason'}); } my %hash = $self->hash; @@ -1295,13 +1386,21 @@ sub suspend { return $error; } - unless ( $date ) { - # credit remaining time if appropriate - if ( $self->part_pkg->option('unused_credit_suspend', 1) ) { - my $error = $self->credit_remaining('suspend', $suspend_time); - if ($error) { - $dbh->rollback if $oldAutoCommit; - return $error; + unless ( $date ) { # then we are suspending now + + unless ($options{'from_cancel'}) { + # credit remaining time if appropriate + # (if required by the package def, or the suspend reason) + my $unused_credit = $self->part_pkg->option('unused_credit_suspend',1) + || ( defined($reason) && $reason->unused_credit ); + + if ( $unused_credit ) { + warn "crediting unused time on pkg#".$self->pkgnum."\n" if $DEBUG; + my $error = $self->credit_remaining('suspend', $suspend_time); + if ($error) { + $dbh->rollback if $oldAutoCommit; + return $error; + } } } @@ -1331,8 +1430,29 @@ sub suspend { } } + # suspension fees: if there is a feepart, and it's not an unsuspend fee, + # and this is not a suspend-before-cancel + if ( $cust_pkg_reason ) { + my $reason_obj = $cust_pkg_reason->reason; + if ( $reason_obj->feepart and + ! $reason_obj->fee_on_unsuspend and + ! $options{'from_cancel'} ) { + + # register the need to charge a fee, cust_main->bill will do the rest + warn "registering suspend fee: pkgnum ".$self->pkgnum.", feepart ".$reason->feepart."\n" + if $DEBUG; + my $cust_pkg_reason_fee = FS::cust_pkg_reason_fee->new({ + 'pkgreasonnum' => $cust_pkg_reason->num, + 'pkgnum' => $self->pkgnum, + 'feepart' => $reason->feepart, + 'nextbill' => $reason->fee_hold, + }); + $error ||= $cust_pkg_reason_fee->insert; + } + } + my $conf = new FS::Conf; - if ( $conf->config('suspend_email_admin') ) { + if ( $conf->config('suspend_email_admin') && !$options{'from_cancel'} ) { my $error = send_email( 'from' => $conf->config('invoice_from', $self->cust_main->agentnum), @@ -1382,6 +1502,21 @@ are mandatory. =cut +# Implementation note: +# +# If you pkgpart-change a package that has been billed, and it's set to give +# credit on package change, then this method gets called and then the new +# package will have no last_bill date. Therefore the customer will be credited +# only once (per billing period) even if there are multiple package changes. +# +# If you location-change a package that has been billed, this method will NOT +# be called and the new package WILL have the last bill date of the old +# package. +# +# If the new package is then canceled within the same billing cycle, +# credit_remaining needs to run calc_remain on the OLD package to determine +# the amount of unused time to credit. + sub credit_remaining { # Add a credit for remaining service my ($self, $mode, $time) = @_; @@ -1398,7 +1533,30 @@ sub credit_remaining { and $next_bill > 0 # the package has a next bill date and $next_bill >= $time # which is in the future ) { - my $remaining_value = $self->calc_remain('time' => $time); + my @cust_credit_source_bill_pkg = (); + my $remaining_value = 0; + + my $remain_pkg = $self; + $remaining_value = $remain_pkg->calc_remain( + 'time' => $time, + 'cust_credit_source_bill_pkg' => \@cust_credit_source_bill_pkg, + ); + + # we may have to walk back past some package changes to get to the + # one that actually has unused time + while ( $remaining_value == 0 ) { + if ( $remain_pkg->change_pkgnum ) { + $remain_pkg = FS::cust_pkg->by_key($remain_pkg->change_pkgnum); + } else { + # the package has really never been billed + return; + } + $remaining_value = $remain_pkg->calc_remain( + 'time' => $time, + 'cust_credit_source_bill_pkg' => \@cust_credit_source_bill_pkg, + ); + } + if ( $remaining_value > 0 ) { warn "Crediting for $remaining_value on package ".$self->pkgnum."\n" if $DEBUG; @@ -1406,6 +1564,7 @@ sub credit_remaining { $remaining_value, 'Credit for unused time on '. $self->part_pkg->pkg, 'reason_type' => $reason_type, + 'cust_credit_source_bill_pkg' => \@cust_credit_source_bill_pkg, ); return "Error crediting customer \$$remaining_value for unused time". " on ". $self->part_pkg->pkg. ": $error" @@ -1470,6 +1629,8 @@ sub unsuspend { return ""; # no error # complain instead? } + # handle the case of setting a future unsuspend (resume) date + # and do not continue to actually unsuspend the package my $date = $opt{'date'}; if ( $date and $date > time ) { # return an error if $date <= time? @@ -1493,6 +1654,11 @@ sub unsuspend { } #if $date + if (!$self->setup) { + # then this package is being released from on-hold status + $self->set_initial_timers; + } + my @labels = (); foreach my $cust_svc ( @@ -1529,19 +1695,46 @@ sub unsuspend { my $conf = new FS::Conf; #adjust the next bill date forward - $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive - if $inactive > 0 + # increment next bill date if certain conditions are met: + # - it was due to be billed at some point + # - either the global or local config says to do this + my $adjust_bill = 0; + if ( + $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} ) && ( $opt{'adjust_next_bill'} || $conf->exists('unsuspend-always_adjust_next_bill_date') || $self->part_pkg->option('unsuspend_adjust_bill', 1) ) - && ! $self->option('suspend_bill',1) - && ( ! $self->part_pkg->option('suspend_bill',1) - || $self->option('no_suspend_bill',1) - ) - && $hash{'order_date'} != $hash{'susp'} - ; + ) { + $adjust_bill = 1; + } + + # but not if: + # - the package billed during suspension + # - or it was ordered on hold + # - or the customer was credited for the unused time + + if ( $self->option('suspend_bill',1) + or ( $self->part_pkg->option('suspend_bill',1) + and ! $self->option('no_suspend_bill',1) + ) + or $hash{'order_date'} == $hash{'susp'} + ) { + $adjust_bill = 0; + } + + if ( $adjust_bill ) { + if ( $self->part_pkg->option('unused_credit_suspend') + or ( ref($reason) and $reason->unused_credit ) ) { + # then the customer was credited for the unused time before suspending, + # so their next bill should be immediate + $hash{'bill'} = time; + } else { + # add the length of time suspended to the bill date + $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive; + } + } $hash{'susp'} = ''; $hash{'adjourn'} = '' if $hash{'adjourn'} and $hash{'adjourn'} < time; @@ -1555,23 +1748,39 @@ sub unsuspend { my $unsusp_pkg; - if ( $reason && $reason->unsuspend_pkgpart ) { - my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart) - or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart. - " not found."; - my $start_date = $self->cust_main->next_bill_date - if $reason->unsuspend_hold; - - if ( $part_pkg ) { - $unsusp_pkg = FS::cust_pkg->new({ - 'custnum' => $self->custnum, - 'pkgpart' => $reason->unsuspend_pkgpart, - 'start_date' => $start_date, - 'locationnum' => $self->locationnum, - # discount? probably not... + if ( $reason ) { + if ( $reason->unsuspend_pkgpart ) { + warn "Suspend reason '".$reason->reason."' uses deprecated unsuspend_pkgpart feature.\n"; + my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart) + or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart. + " not found."; + my $start_date = $self->cust_main->next_bill_date + if $reason->unsuspend_hold; + + if ( $part_pkg ) { + $unsusp_pkg = FS::cust_pkg->new({ + 'custnum' => $self->custnum, + 'pkgpart' => $reason->unsuspend_pkgpart, + 'start_date' => $start_date, + 'locationnum' => $self->locationnum, + # discount? probably not... + }); + + $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg ); + } + } + # new way, using fees + if ( $reason->feepart and $reason->fee_on_unsuspend ) { + # register the need to charge a fee, cust_main->bill will do the rest + warn "registering unsuspend fee: pkgnum ".$self->pkgnum.", feepart ".$reason->feepart."\n" + if $DEBUG; + my $cust_pkg_reason_fee = FS::cust_pkg_reason_fee->new({ + 'pkgreasonnum' => $cust_pkg_reason->num, + 'pkgnum' => $self->pkgnum, + 'feepart' => $reason->feepart, + 'nextbill' => $reason->fee_hold, }); - - $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg ); + $error ||= $cust_pkg_reason_fee->insert; } if ( $error ) { @@ -1758,6 +1967,40 @@ sub change { my $error; + if ( $opt->{'cust_location'} ) { + $error = $opt->{'cust_location'}->find_or_insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "creating location record: $error"; + } + $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum; + } + + # Before going any further here: if the package is still in the pre-setup + # state, it's safe to modify it in place. No need to charge/credit for + # partial period, transfer services, transfer usage pools, copy invoice + # details, or change any dates. + if ( ! $self->setup and ! $opt->{cust_pkg} and ! $opt->{cust_main} ) { + foreach ( qw( locationnum pkgpart quantity refnum salesnum ) ) { + if ( length($opt->{$_}) ) { + $self->set($_, $opt->{$_}); + } + } + # almost. if the new pkgpart specifies start/adjourn/expire timers, + # apply those. + if ( $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart ) { + $self->set_initial_timers; + } + $error = $self->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "modifying package: $error"; + } else { + $dbh->commit if $oldAutoCommit; + return ''; + } + } + my %hash = (); my $time = time; @@ -1768,15 +2011,6 @@ sub change { $hash{"change_$_"} = $self->$_() foreach qw( pkgnum pkgpart locationnum ); - if ( $opt->{'cust_location'} ) { - $error = $opt->{'cust_location'}->find_or_insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "creating location record: $error"; - } - $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum; - } - if ( $opt->{'cust_pkg'} ) { # treat changing to a package with a different pkgpart as a # pkgpart change (because it is) @@ -1791,6 +2025,7 @@ sub change { my $unused_credit = 0; my $keep_dates = $opt->{'keep_dates'}; + # Special case. If the pkgpart is changing, and the customer is # going to be credited for remaining time, don't keep setup, bill, # or last_bill dates, and DO pass the flag to cancel() to credit @@ -1823,6 +2058,9 @@ sub change { # 2. (more importantly) changing a package before it's billed $hash{'waive_setup'} = $self->waive_setup; + # if this package is scheduled for a future package change, preserve that + $hash{'change_to_pkgnum'} = $self->change_to_pkgnum; + my $custnum = $self->custnum; if ( $opt->{cust_main} ) { my $cust_main = $opt->{cust_main}; @@ -1848,6 +2086,15 @@ sub change { $cust_pkg->set("change_$_", $self->get($_)); } $cust_pkg->set('change_date', $time); + $cust_pkg->set('start_date', ''); # it's starting now + # if we are crediting unused time, then create the new package as a new + # package, charge its setup fee, etc. (same as an immediate change) + if (! $unused_credit) { + foreach my $date ( qw(setup bill last_bill susp adjourn resume + contract_end ) ) { + $cust_pkg->set($date, $self->getfield($date)); + } + } $error = $cust_pkg->replace; } else { @@ -1869,7 +2116,9 @@ sub change { } # Transfer services and cancel old package. - + # Enforce service limits only if this is a pkgpart change. + local $FS::cust_svc::ignore_quantity; + $FS::cust_svc::ignore_quantity = 1 if $same_pkgpart; $error = $self->transfer($cust_pkg); if ($error and $error == 0) { # $old_pkg->transfer failed. @@ -2041,6 +2290,7 @@ sub change { unused_credit => $unused_credit, nobill => $keep_dates, change_custnum => ( $self->custnum != $custnum ? $custnum : '' ), + no_delay_cancel => 1, ); if ($error) { $dbh->rollback if $oldAutoCommit; @@ -2138,7 +2388,7 @@ sub change_later { $error = $self->replace || $err_or_pkg->replace || - $change_to->cancel || + $change_to->cancel('no_delay_cancel' => 1) || $change_to->delete; } else { $error = $err_or_pkg; @@ -2258,6 +2508,7 @@ and, I: - start_date: the date when it will be billed - amount: the setup fee to be charged - quantity: the multiplier for the setup fee +- separate_bill: whether to put the charge on a separate invoice If you pass 'adjust_commission' => 1, and the classnum changes, and there are commission credits linked to this charge, they will be recalculated. @@ -2313,29 +2564,53 @@ sub modify_charge { } if ( !$self->get('setup') ) { - # not yet billed, so allow amount and quantity + # not yet billed, so allow amount, setup_cost, quantity, start_date, + # and separate_bill + + if ( exists($opt{'amount'}) + and $part_pkg->option('setup_fee') != $opt{'amount'} + and $opt{'amount'} > 0 ) { + + $pkg_opt{'setup_fee'} = $opt{'amount'}; + $pkg_opt_modified = 1; + } + + if ( exists($opt{'setup_cost'}) + and $part_pkg->setup_cost != $opt{'setup_cost'} + and $opt{'setup_cost'} > 0 ) { + + $part_pkg->set('setup_cost', $opt{'setup_cost'}); + } + if ( exists($opt{'quantity'}) and $opt{'quantity'} != $self->quantity and $opt{'quantity'} > 0 ) { $self->set('quantity', $opt{'quantity'}); } + if ( exists($opt{'start_date'}) and $opt{'start_date'} != $self->start_date ) { $self->set('start_date', $opt{'start_date'}); } - if ( exists($opt{'amount'}) - and $part_pkg->option('setup_fee') != $opt{'amount'} - and $opt{'amount'} > 0 ) { - - $pkg_opt{'setup_fee'} = $opt{'amount'}; - $pkg_opt_modified = 1; + if ( exists($opt{'separate_bill'}) + and $opt{'separate_bill'} ne $self->separate_bill ) { + $self->set('separate_bill', $opt{'separate_bill'}); } + + } # else simply ignore them; the UI shouldn't allow editing the fields + + if ( exists($opt{'taxclass'}) + and $part_pkg->taxclass ne $opt{'taxclass'}) { + + $part_pkg->set('taxclass', $opt{'taxclass'}); + } + my $error; if ( $part_pkg->modified or $pkg_opt_modified ) { # can we safely modify the package def? @@ -2433,12 +2708,10 @@ sub modify_charge { -use Storable 'thaw'; -use MIME::Base64; use Data::Dumper; sub process_bulk_cust_pkg { my $job = shift; - my $param = thaw(decode_base64(shift)); + my $param = shift; warn Dumper($param) if $DEBUG; my $old_part_pkg = qsearchs('part_pkg', @@ -2718,7 +2991,7 @@ sub set_cust_pkg_detail { =item cust_event -Returns the new-style customer billing events (see L) for this invoice. +Returns the customer billing events (see L) for this invoice. =cut @@ -2735,19 +3008,41 @@ sub cust_event { =item num_cust_event -Returns the number of new-style customer billing events (see L) for this invoice. +Returns the number of customer billing events (see L) for this package. =cut #false laziness w/cust_bill.pm sub num_cust_event { my $self = shift; - my $sql = - "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ". - " WHERE tablenum = ? AND eventtable = 'cust_pkg'"; + my $sql = "SELECT COUNT(*) ". $self->_from_cust_event_where; + $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref->[0]; +} + +=item exists_cust_event + +Returns true if there are customer billing events (see L) for this package. More efficient than using num_cust_event. + +=cut + +sub exists_cust_event { + my $self = shift; + my $sql = "SELECT 1 ". $self->_from_cust_event_where. " LIMIT 1"; + my $row = $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref; + $row ? $row->[0] : ''; +} + +sub _from_cust_event_where { + #my $self = shift; + " FROM cust_event JOIN part_event USING ( eventpart ) ". + " WHERE tablenum = ? AND eventtable = 'cust_pkg' "; +} + +sub _prep_ex { + my( $self, $sql, @args ) = @_; my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql"; - $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql"; - $sth->fetchrow_arrayref->[0]; + $sth->execute(@args) or die $sth->errstr. " executing $sql"; + $sth; } =item part_pkg_currency_option OPTIONNAME @@ -2799,7 +3094,7 @@ sub cust_svc_unsorted { sub cust_svc_unsorted_arrayref { my $self = shift; - return () unless $self->num_cust_svc(@_); + return [] unless $self->num_cust_svc(@_); my %opt = (); if ( @_ && $_[0] =~ /^\d+/ ) { @@ -2855,12 +3150,16 @@ sub h_cust_svc { if $DEBUG; my ($end, $start, $mode) = @_; + + local($FS::Record::qsearch_qualify_columns) = 0; + my @cust_svc = $self->_sort_cust_svc( [ qsearch( 'h_cust_svc', { 'pkgnum' => $self->pkgnum, }, FS::h_cust_svc->sql_h_search(@_), ) ] ); + if ( defined($mode) && $mode eq 'I' ) { my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc; return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc; @@ -3161,9 +3460,9 @@ Class method that returns the list of possible status strings for packages =cut tie my %statuscolor, 'Tie::IxHash', - 'on hold' => '7E0079', #purple! + 'on hold' => 'FF00F5', #brighter purple! 'not yet billed' => '009999', #teal? cyan? - 'one-time charge' => '000000', + 'one-time charge' => '0000CC', #blue #'000000', 'active' => '00CC00', 'suspended' => 'FF9900', 'cancelled' => 'FF0000', @@ -3176,6 +3475,11 @@ sub statuses { keys %statuscolor; } +sub statuscolors { + #my $self = shift; + \%statuscolor; +} + =item statuscolor Returns a hex triplet color string for this package's status. @@ -3187,6 +3491,34 @@ sub statuscolor { $statuscolor{$self->status}; } +=item is_status_delay_cancel + +Returns true if part_pkg has option delay_cancel, +cust_pkg status is 'suspended' and expire is set +to cancel package within the next day (or however +many days are set in global config part_pkg-delay_cancel-days. + +This is not a real status, this only meant for hacking display +values, because otherwise treating the package as suspended is +really the whole point of the delay_cancel option. + +=cut + +sub is_status_delay_cancel { + my ($self) = @_; + if ( $self->main_pkgnum and $self->pkglinknum ) { + return $self->main_pkg->is_status_delay_cancel; + } + return 0 unless $self->part_pkg->option('delay_cancel',1); + return 0 unless $self->status eq 'suspended'; + return 0 unless $self->expire; + my $conf = new FS::Conf; + my $expdays = $conf->config('part_pkg-delay_cancel-days') || 1; + my $expsecs = 60*60*24*$expdays; + return 0 unless $self->expire < time + $expsecs; + return 1; +} + =item pkg_label Returns a label for this package. (Currently "pkgnum: pkg - comment" or @@ -3586,7 +3918,7 @@ sub transfer { return ('Package does not exist: '.$dest_pkgnum) unless $dest; foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) { - $target{$pkg_svc->svcpart} = $pkg_svc->quantity; + $target{$pkg_svc->svcpart} = $pkg_svc->quantity * ( $dest->quantity || 1 ); } foreach my $cust_svc ($dest->cust_svc) { @@ -3814,7 +4146,7 @@ sub insert_reason { $reasonnum = $reason->reasonnum; } else { - return "Unparsable reason: ". $options{'reason'}; + return "Unparseable reason: ". $options{'reason'}; } my $cust_pkg_reason = @@ -4015,7 +4347,7 @@ sub apply_usage { minutes => min($cust_pkg_usage->minutes, $minutes), }); $cust_pkg_usage->set('minutes', - sprintf('%.0f', $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes) + $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes ); $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert; $minutes -= $cdr_cust_pkg_usage->minutes; @@ -4438,6 +4770,9 @@ sub _X_show_zero { =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ] +Bulk cancel + order subroutine. Perhaps slightly deprecated, only used by the +bulk cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi) + CUSTNUM is a customer (see L) PKGPARTS is a list of pkgparts specifying the the billing item definitions (see @@ -4574,7 +4909,7 @@ sub order { $dbh->rollback if $oldAutoCommit; return "Unable to transfer all services from package ".$old_pkg->pkgnum; } - $error = $old_pkg->cancel( quiet=>1 ); + $error = $old_pkg->cancel( quiet=>1, 'no_delay_cancel'=>1 ); if ($error) { $dbh->rollback; return $error; @@ -4660,6 +4995,23 @@ sub _upgrade_data { # class method my $sth = dbh->prepare($sql); $sth->execute or die $sth->errstr; } + + # RT31194: supplemental package links that are deleted don't clean up + # linked records + my @pkglinknums = qsearch({ + 'select' => 'DISTINCT cust_pkg.pkglinknum', + 'table' => 'cust_pkg', + 'addl_from' => ' LEFT JOIN part_pkg_link USING (pkglinknum) ', + 'extra_sql' => ' WHERE cust_pkg.pkglinknum IS NOT NULL + AND part_pkg_link.pkglinknum IS NULL', + }); + foreach (@pkglinknums) { + my $pkglinknum = $_->pkglinknum; + warn "cleaning part_pkg_link #$pkglinknum\n"; + my $part_pkg_link = FS::part_pkg_link->new({pkglinknum => $pkglinknum}); + my $error = $part_pkg_link->remove_linked; + die $error if $error; + } } =back