X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_pkg.pm;h=c70a6795f3e2144f81bd374278d2917f82f787dd;hp=456847ea724dced1b0faeefa972ac2da8a5d3832;hb=dba5666db2791c7c9694969470b452633b74d573;hpb=cd254e04ba204b5d0b4a69b65c392fb175dd1e97 diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 456847ea7..c70a6795f 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -8,7 +8,7 @@ use base qw( FS::cust_pkg::Search FS::cust_pkg::API use strict; use Carp qw(cluck); use Scalar::Util qw( blessed ); -use List::Util qw(min max); +use List::Util qw(min max sum); use Tie::IxHash; use Time::Local qw( timelocal timelocal_nocheck ); use MIME::Entity; @@ -38,6 +38,8 @@ use FS::sales; # for modify_charge use FS::cust_credit; +use Data::Dumper; + # temporary fix; remove this once (un)suspend admin notices are cleaned up use FS::Misc qw(send_email); @@ -58,6 +60,8 @@ our $upgrade = 0; #go away after setup+start dates cleaned up for old customers our $cache_enabled = 0; +our $disable_start_on_hold = 0; + sub _simplecache { my( $self, $hashref ) = @_; if ( $cache_enabled && $hashref->{'pkg'} && $hashref->{'plan'} ) { @@ -395,7 +399,10 @@ sub insert { $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) ); } - if ($self->susp eq 'now' or $part_pkg->start_on_hold) { + if ( $self->susp eq 'now' + or ( $part_pkg->start_on_hold && ! $disable_start_on_hold ) + ) + { # if the package was ordered on hold: # - suspend it # - don't set the start date (it will be started manually) @@ -533,6 +540,7 @@ sub delete { # cust_bill_pay.pkgnum (wtf, shouldn't reference pkgnum) # cust_pkg_usage.pkgnum # cust_pkg.uncancel_pkgnum, change_pkgnum, main_pkgnum, and change_to_pkgnum + # rt_field_charge.pkgnum # cust_svc is handled by canceling the package before deleting it # cust_pkg_option is handled via option_Common @@ -1093,17 +1101,38 @@ sub cancel { $dbh->commit or die $dbh->errstr if $oldAutoCommit; return '' if $date; #no errors - my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list; - if ( !$options{'quiet'} && - $conf->exists('emailcancel', $self->cust_main->agentnum) && - @invoicing_list ) { - my $msgnum = $conf->config('cancel_msgnum', $self->cust_main->agentnum); - my $error = ''; - if ( $msgnum ) { - my $msg_template = qsearchs('msg_template', { msgnum => $msgnum }); - $error = $msg_template->send( 'cust_main' => $self->cust_main, - 'object' => $self ); - } + my $cust_main = $self->cust_main; + + my @invoicing_list = $cust_main->invoicing_list_emailonly; + my $msgnum = $conf->config('cancel_msgnum', $cust_main->agentnum); + if ( !$options{'quiet'} + && $conf->config_bool('emailcancel', $cust_main->agentnum) + && @invoicing_list + && $msgnum + ) + { + my $msg_template = qsearchs('msg_template', { msgnum => $msgnum }); + my $error = $msg_template->send( + 'cust_main' => $cust_main, + 'object' => $self, + ); + #should this do something on errors? + } + + my %pkg_class = map { $_=>1 } + $conf->config('cancel_msgnum-referring_cust-pkg_class'); + my $ref_msgnum = $conf->config('cancel_msgnum-referring_cust'); + if ( !$options{'quiet'} + && $cust_main->referral_custnum + && $pkg_class{ $self->classnum } + && $ref_msgnum + ) + { + my $msg_template = qsearchs('msg_template', { msgnum => $ref_msgnum }); + my $error = $msg_template->send( + 'cust_main' => $cust_main->referring_cust_main, + 'object' => $self, + ); #should this do something on errors? } @@ -1133,14 +1162,12 @@ sub cancel_if_expired { For cancelled cust_pkg, returns a list of new, uninserted FS::svc_X records for services that would be inserted by L. Returned objects also -include the field '_uncancel_svcnum' that contains the original svcnum. +include the field _h_svc_x, which contains the service history object. + Set pkgnum before inserting. Accepts the following options: -summarize_size - if true, returns empty list if number of potential services is -equal to or greater than this - only_svcnum - arrayref of svcnum, only returns objects for these svcnum (and only if they would otherwise be returned by this) @@ -1158,19 +1185,20 @@ sub uncancel_svc_x { my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz ); my @h_cust_svc = $self->h_cust_svc( $end, $start ); - return () if $opt{'summarize_size'} and @h_cust_svc >= $opt{'summarize_size'}; - my @svc_x; foreach my $h_cust_svc (@h_cust_svc) { next if $opt{'only_svcnum'} && !(grep { $_ == $h_cust_svc->svcnum } @{$opt{'only_svcnum'}}); + # filter out services that still exist on this package (ie preserved svcs) + # but keep services that have since been provisioned on another package (for informational purposes) + next if qsearchs('cust_svc',{ 'svcnum' => $h_cust_svc->svcnum, 'pkgnum' => $self->pkgnum }); my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start ); - #next unless $h_svc_x; #should this happen? + next unless $h_svc_x; # this probably doesn't happen, but just in case (my $table = $h_svc_x->table) =~ s/^h_//; require "FS/$table.pm"; my $class = "FS::$table"; my $svc_x = $class->new( { 'svcpart' => $h_cust_svc->svcpart, - '_uncancel_svcnum' => $h_cust_svc->svcnum, + '_h_svc_x' => $h_svc_x, map { $_ => $h_svc_x->get($_) } fields($table) } ); @@ -1211,18 +1239,22 @@ svc uncancel_svcnum -label +label - from history table if not currently calculable, undefined if it can't be loaded reprovisionable - 1 if test reprovision succeeded, otherwise 0 +num_cust_svc - number of svcs for this svcpart, only if summarizing (see below) + Cannot be run from within a transaction. Performs inserts to test the results, and then rolls back the transaction. Does not perform exports, so does not catch if export would fail. Also accepts the following options: -summarize_size - if true, returns empty list if number of potential services is -equal to or greater than this +no_test_reprovision - skip the test inserts (reprovisionable field will not exist) + +summarize_size - if true, returns a single summary record for svcparts with at +least this many svcs, will have key num_cust_svc but not uncancel_svcnum, label or reprovisionable =cut @@ -1235,23 +1267,51 @@ sub uncancel_svc_summary { local $FS::svc_Common::noexport_hack = 1; # very important not to run exports!!! local $FS::UID::AutoCommit = 0; + # sort by svcpart, to check summarize_size + my $uncancel_svc_x = {}; + foreach my $svc_x (sort { $a->{'svcpart'} <=> $b->{'svcpart'} } $self->uncancel_svc_x) { + $uncancel_svc_x->{$svc_x->svcpart} = [] unless $uncancel_svc_x->{$svc_x->svcpart}; + push @{$uncancel_svc_x->{$svc_x->svcpart}}, $svc_x; + } + my @out; - foreach my $svc_x ($self->uncancel_svc_x(%opt)) { - $svc_x->pkgnum($self->pkgnum); # provisioning services on a canceled package, will be rolled back - my $part_svc = $svc_x->part_svc; - my $out = { - 'svcpart' => $part_svc->svcpart, - 'svc' => $part_svc->svc, - 'uncancel_svcnum' => $svc_x->get('_uncancel_svcnum'), - }; - if ($svc_x->insert) { # if error inserting - $out->{'label'} = "(cannot re-provision)"; - $out->{'reprovisionable'} = 0; + foreach my $svcpart (keys %$uncancel_svc_x) { + my @svcpart_svc_x = @{$uncancel_svc_x->{$svcpart}}; + if ($opt{'summarize_size'} && (@svcpart_svc_x >= $opt{'summarize_size'})) { + my $svc_x = $svcpart_svc_x[0]; #grab first one for access to $part_svc + my $part_svc = $svc_x->part_svc; + push @out, { + 'svcpart' => $part_svc->svcpart, + 'svc' => $part_svc->svc, + 'num_cust_svc' => scalar(@svcpart_svc_x), + }; } else { - $out->{'label'} = $svc_x->label; - $out->{'reprovisionable'} = 1; + foreach my $svc_x (@svcpart_svc_x) { + my $part_svc = $svc_x->part_svc; + my $out = { + 'svcpart' => $part_svc->svcpart, + 'svc' => $part_svc->svc, + 'uncancel_svcnum' => $svc_x->get('_h_svc_x')->svcnum, + }; + $svc_x->pkgnum($self->pkgnum); # provisioning services on a canceled package, will be rolled back + my $insert_error; + unless ($opt{'no_test_reprovision'}) { + # avoid possibly fatal errors from missing linked records + eval { $insert_error = $svc_x->insert }; + $insert_error ||= $@; + } + if ($opt{'no_test_reprovision'} or $insert_error) { + # avoid possibly fatal errors from missing linked records + eval { $out->{'label'} = $svc_x->label }; + eval { $out->{'label'} = $svc_x->get('_h_svc_x')->label } unless defined($out->{'label'}); + $out->{'reprovisionable'} = 0 unless $opt{'no_test_reprovision'}; + } else { + $out->{'label'} = $svc_x->label; + $out->{'reprovisionable'} = 1; + } + push @out, $out; + } } - push @out, $out; } dbh->rollback; @@ -1400,14 +1460,15 @@ sub uncancel { =item unexpire -Cancels any pending expiration (sets the expire field to null). +Cancels any pending expiration (sets the expire field to null) +for this package and any supplemental packages. If there is an error, returns the error, otherwise returns false. =cut sub unexpire { - my( $self, %options ) = @_; + my( $self ) = @_; my $error; my $oldAutoCommit = $FS::UID::AutoCommit; @@ -1437,6 +1498,14 @@ sub unexpire { return $error; } + foreach my $supp_pkg ( $self->supplemental_pkgs ) { + $error = $supp_pkg->unexpire; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "unexpiring supplemental pkg#".$supp_pkg->pkgnum.": $error"; + } + } + $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; #no errors @@ -1733,50 +1802,105 @@ sub credit_remaining { my $conf = FS::Conf->new; my $reason_type = $conf->config($mode.'_credit_type'); - my $last_bill = $self->getfield('last_bill') || 0; - my $next_bill = $self->getfield('bill') || 0; - if ( $last_bill > 0 # the package has been billed - and $next_bill > 0 # the package has a next bill date - and $next_bill >= $time # which is in the future - ) { - my @cust_credit_source_bill_pkg = (); - my $remaining_value = 0; + $time ||= time; - my $remain_pkg = $self; - $remaining_value = $remain_pkg->calc_remain( - 'time' => $time, - 'cust_credit_source_bill_pkg' => \@cust_credit_source_bill_pkg, - ); + my $remain_pkg = $self; + my (@billpkgnums, @amounts, @setuprecurs); + + # we may have to walk back past some package changes to get to the + # one that actually has unused time. loop until that happens, or we + # reach the first package in the chain. + while (1) { + my $last_bill = $remain_pkg->get('last_bill') || 0; + my $next_bill = $remain_pkg->get('bill') || 0; + if ( $last_bill > 0 # the package has been billed + and $next_bill > 0 # the package has a next bill date + and $next_bill >= $time # which is in the future + ) { + + # Find actual charges for the period ending on or after the cancel + # date. + my @charges = qsearch('cust_bill_pkg', { + pkgnum => $remain_pkg->pkgnum, + edate => {op => '>=', value => $time}, + recur => {op => '>' , value => 0}, + }); + + foreach my $cust_bill_pkg (@charges) { + # hack to deal with the weird behavior of edate on package + # cancellation + my $edate = $cust_bill_pkg->edate; + if ( $self->recur_temporality eq 'preceding' ) { + $edate = $self->add_freq($cust_bill_pkg->sdate); + } + + # this will also get any package charges that are _entirely_ after + # the cancellation date (can happen with advance billing). in that + # case, use the entire recurring charge: + my $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage; + my $max_credit = $amount + - $cust_bill_pkg->credited('', '', setuprecur => 'recur') || 0; + + # but if the cancellation happens during the interval, prorate it: + # (XXX obey prorate_round_day here?) + if ( $cust_bill_pkg->sdate < $time ) { + $amount = $amount * + ($edate - $time) / ($edate - $cust_bill_pkg->sdate); + } + + # if there are existing credits, don't let the sum of credits exceed + # the recurring charge + $amount = $max_credit if $amount > $max_credit; + + $amount = sprintf('%.2f', $amount); + + # if no time has been used and/or there are existing line item + # credits, we may end up not needing to credit anything. + if ( $amount > 0 ) { + + push @billpkgnums, $cust_bill_pkg->billpkgnum; + push @amounts, $amount; + push @setuprecurs, 'recur'; + + warn "Crediting for $amount on package ".$remain_pkg->pkgnum."\n" + if $DEBUG; + } - # 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, - ); + + last if @charges; } - if ( $remaining_value > 0 ) { - warn "Crediting for $remaining_value on package ".$self->pkgnum."\n" - if $DEBUG; - my $error = $self->cust_main->credit( - $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" - if $error; - } #if $remaining_value - } #if $last_bill, etc. + if ( my $changed_from_pkgnum = $remain_pkg->change_pkgnum ) { + $remain_pkg = FS::cust_pkg->by_key($changed_from_pkgnum); + } else { + # the package has really never been billed + return; + } + } + + # keep traditional behavior here. + local $@; + my $reason = FS::reason->new_or_existing( + reason => 'Credit for unused time on '. $self->part_pkg->pkg, + type => $reason_type, + class => 'R', + ); + if ( $@ ) { + return "failed to set credit reason: $@"; + } + + my $error = FS::cust_credit->credit_lineitems( + 'billpkgnums' => \@billpkgnums, + 'setuprecurs' => \@setuprecurs, + 'amounts' => \@amounts, + 'custnum' => $self->custnum, + 'date' => time, + 'reasonnum' => $reason->reasonnum, + 'apply' => 1, + 'set_source' => 1, + ); + ''; } @@ -2044,14 +2168,15 @@ sub unsuspend { =item unadjourn -Cancels any pending suspension (sets the adjourn field to null). +Cancels any pending suspension (sets the adjourn field to null) +for this package and any supplemental packages. If there is an error, returns the error, otherwise returns false. =cut sub unadjourn { - my( $self, %options ) = @_; + my( $self ) = @_; my $error; my $oldAutoCommit = $FS::UID::AutoCommit; @@ -2088,6 +2213,14 @@ sub unadjourn { return $error; } + foreach my $supp_pkg ( $self->supplemental_pkgs ) { + $error = $supp_pkg->unadjourn; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "unadjourning supplemental pkg#".$supp_pkg->pkgnum.": $error"; + } + } + $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; #no errors @@ -2228,32 +2361,76 @@ sub change { $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum; } + # figure out if we're changing pkgpart + if ( $opt->{'cust_pkg'} ) { + $opt->{'pkgpart'} = $opt->{'cust_pkg'}->pkgpart; + } + + # whether to override pkgpart checking on the new package + my $same_pkgpart = 1; + if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) { + $same_pkgpart = 0; + } + + $self->set('waive_setup', $opt->{'waive_setup'}) if $opt->{'waive_setup'}; + # 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. + # partial period, transfer usage pools, copy invoice details, or change any + # dates. We DO need to "transfer" services (from the package to itself) to + # check their validity on the new pkgpart. if ( ! $self->setup and ! $opt->{cust_pkg} and ! $opt->{cust_main} ) { - foreach ( qw( locationnum pkgpart quantity refnum salesnum ) ) { + foreach ( qw( locationnum pkgpart quantity refnum salesnum waive_setup ) ) { 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 ) { + if ( !$same_pkgpart ) { $error ||= $self->set_initial_timers; } # but if contract_end was explicitly specified, that overrides all else $self->set('contract_end', $opt->{'contract_end'}) if $opt->{'contract_end'}; + $error ||= $self->replace; if ( $error ) { $dbh->rollback if $oldAutoCommit; return "modifying package: $error"; - } else { - $dbh->commit if $oldAutoCommit; - return $self; } + + # check/convert services (only on pkgpart change, to avoid surprises + # when editing locations) + # (maybe do this if changing quantity?) + if ( !$same_pkgpart ) { + + $error = $self->transfer($self); + + if ( $error and $error == 0 ) { + $error = "transferring $error"; + } elsif ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) { + warn "trying transfer again with change_svcpart option\n" if $DEBUG; + $error = $self->transfer($self, 'change_svcpart'=>1 ); + if ($error and $error == 0) { + $error = "converting $error"; + } + } + + if ($error > 0) { + $error = "unable to transfer all services"; + } + + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + } # done transferring services + + $dbh->commit if $oldAutoCommit; + return $self; + } my %hash = (); @@ -2266,18 +2443,6 @@ sub change { $hash{"change_$_"} = $self->$_() foreach qw( pkgnum pkgpart locationnum ); - if ( $opt->{'cust_pkg'} ) { - # treat changing to a package with a different pkgpart as a - # pkgpart change (because it is) - $opt->{'pkgpart'} = $opt->{'cust_pkg'}->pkgpart; - } - - # whether to override pkgpart checking on the new package - my $same_pkgpart = 1; - if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) { - $same_pkgpart = 0; - } - my $unused_credit = 0; my $keep_dates = $opt->{'keep_dates'}; @@ -2457,9 +2622,9 @@ sub change { foreach my $old_discount ($self->cust_pkg_discount_active) { # don't remove the old discount, we may still need to bill that package. my $new_discount = new FS::cust_pkg_discount { - 'pkgnum' => $cust_pkg->pkgnum, - 'discountnum' => $old_discount->discountnum, - 'months_used' => $old_discount->months_used, + 'pkgnum' => $cust_pkg->pkgnum, + map { $_ => $old_discount->$_() } + qw( discountnum months_used end_date usernum setuprecur ), }; $error = $new_discount->insert; if ( $error ) { @@ -2480,6 +2645,21 @@ sub change { return "transferring package notes: $error"; } } + + # transfer scheduled expire/adjourn reasons + foreach my $action ('expire', 'adjourn') { + if ( $cust_pkg->get($action) ) { + my $reason = $self->last_cust_pkg_reason($action); + if ( $reason ) { + $reason->set('pkgnum', $cust_pkg->pkgnum); + $error = $reason->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "transferring $action reason: $error"; + } + } + } + } my @new_supp_pkgs; @@ -2560,6 +2740,19 @@ sub change { return "canceling old package: $error"; } + # transfer rt_field_charge, if we're not changing pkgpart + # after billing of old package, before billing of new package + if ( $same_pkgpart ) { + foreach my $rt_field_charge ($self->rt_field_charge) { + $rt_field_charge->set('pkgnum', $cust_pkg->pkgnum); + $error = $rt_field_charge->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "transferring rt_field_charge: $error"; + } + } + } + if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) { #$self->cust_main my $error = $cust_pkg->cust_main->bill( @@ -2847,7 +3040,7 @@ sub modify_charge { $pkg_opt_modified = 1; } } - $pkg_opt_modified = 1 if (scalar(@old_additional) - 1) != $i; + $pkg_opt_modified = 1 if scalar(@old_additional) != $i; $pkg_opt{'additional_count'} = $i if $i > 0; my $old_classnum; @@ -3001,9 +3194,6 @@ sub modify_charge { ''; } - - -use Data::Dumper; sub process_bulk_cust_pkg { my $job = shift; my $param = shift; @@ -3904,23 +4094,27 @@ sub labels { map { [ $_->label ] } $self->cust_svc; } -=item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ] +=item h_labels END_TIMESTAMP [, START_TIMESTAMP [, MODE [, LOCALE ] ] ] Like the labels method, but returns historical information on services that were active as of END_TIMESTAMP and (optionally) not cancelled before START_TIMESTAMP. If MODE is 'I' (for 'invoice'), services with the I flag will be omitted. -Returns a list of lists, calling the label method for all (historical) services -(see L) of this billing item. +If LOCALE is passed, service definition names will be localized. + +Returns a list of lists, calling the label method for all (historical) +services (see L) of this billing item. =cut sub h_labels { my $self = shift; - warn "$me _h_labels called on $self\n" + my ($end, $start, $mode, $locale) = @_; + warn "$me h_labels\n" if $DEBUG; - map { [ $_->label(@_) ] } $self->h_cust_svc(@_); + map { [ $_->label($end, $start, $locale) ] } + $self->h_cust_svc($end, $start, $mode); } =item labels_short @@ -3933,15 +4127,15 @@ individual services rather than individual items. =cut sub labels_short { - shift->_labels_short( 'labels', @_ ); + shift->_labels_short( 'labels' ); # 'labels' takes no further arguments } -=item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ] +=item h_labels_short END_TIMESTAMP [, START_TIMESTAMP [, MODE [, LOCALE ] ] ] Like h_labels, except returns a simple flat list, and shortens long -(currently >5 or the cust_bill-max_same_services configuration value) lists of -identical services to one line that lists the service label and the number of -individual services rather than individual items. +(currently >5 or the cust_bill-max_same_services configuration value) lists +of identical services to one line that lists the service label and the +number of individual services rather than individual items. =cut @@ -3949,6 +4143,9 @@ sub h_labels_short { shift->_labels_short( 'h_labels', @_ ); } +# takes a method name ('labels' or 'h_labels') and all its arguments; +# maybe should be "shorten($self->h_labels( ... ) )" + sub _labels_short { my( $self, $method ) = ( shift, shift ); @@ -4226,8 +4423,10 @@ sub transfer { $target{$pkg_svc->svcpart} = $pkg_svc->quantity * ( $dest->quantity || 1 ); } - foreach my $cust_svc ($dest->cust_svc) { - $target{$cust_svc->svcpart}--; + unless ( $self->pkgnum == $dest->pkgnum ) { + foreach my $cust_svc ($dest->cust_svc) { + $target{$cust_svc->svcpart}--; + } } my %svcpart2svcparts = (); @@ -4261,24 +4460,42 @@ sub transfer { my $error; foreach my $cust_svc ($self->cust_svc) { my $svcnum = $cust_svc->svcnum; - if($target{$cust_svc->svcpart} > 0 - or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option + + if ( $target{$cust_svc->svcpart} > 0 + or $FS::cust_svc::ignore_quantity # maybe should be a 'force' option + ) + { $target{$cust_svc->svcpart}--; + + local $FS::cust_svc::ignore_quantity = 1 + if $self->pkgnum == $dest->pkgnum; + + #why run replace at all in the $self->pkgnum == $dest->pkgnum case? + # we do want to trigger location and pkg_change exports, but + # without pkgnum changing from an old to new package, cust_svc->replace + # doesn't know how to trigger those. :/ + # does this mean we scrap the whole idea of "safe to modify it in place", + # or do we special-case and pass the info needed to cust_svc->replace? :/ + my $new = new FS::cust_svc { $cust_svc->hash }; $new->pkgnum($dest_pkgnum); $error = $new->replace($cust_svc); + } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) { + if ( $DEBUG ) { warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n"; warn "alternates to consider: ". join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n"; } + my @alternate = grep { warn "considering alternate svcpart $_: ". "$target{$_} available in new package\n" if $DEBUG; $target{$_} > 0; } @{$svcpart2svcparts{$cust_svc->svcpart}}; + if ( @alternate ) { warn "alternate(s) found\n" if $DEBUG; my $change_svcpart = $alternate[0]; @@ -4290,13 +4507,16 @@ sub transfer { } else { $remaining++; } + } else { $remaining++ } + if ( $error ) { my @label = $cust_svc->label; return "$label[0] $label[1]: $error"; } + } return $remaining; } @@ -4902,6 +5122,17 @@ sub cancel_sql { "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0"; } +=item ncancelled_recurring_sql + +Returns an SQL expression identifying un-cancelled, recurring packages. + +=cut + +sub ncancelled_recurring_sql { + $_[0]->recurring_sql(). + " AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 ) "; +} + =item status_sql Returns an SQL expression to give the package status as a string. @@ -5355,6 +5586,23 @@ sub forward_emails { } # Used by FS::Upgrade to migrate to a new database. +sub _upgrade_schema { # class method + my ($class, %opts) = @_; + + my $sql = ' + UPDATE cust_pkg SET change_to_pkgnum = NULL + WHERE change_to_pkgnum IS NOT NULL + AND NOT EXISTS ( SELECT 1 FROM cust_pkg AS ctcp + WHERE ctcp.pkgnum = cust_pkg.change_to_pkgnum + ) + '; + + my $sth = dbh->prepare($sql) or die dbh->errstr; + $sth->execute or die $sth->errstr; + ''; +} + +# Used by FS::Upgrade to migrate to a new database. sub _upgrade_data { # class method my ($class, %opts) = @_; $class->_upgrade_otaker(%opts); @@ -5394,6 +5642,32 @@ sub _upgrade_data { # class method my $error = $part_pkg_link->remove_linked; die $error if $error; } + + # RT#73607: canceling a package with billing addons sometimes changes its + # pkgpart. + # Find records where the last replace_new record for the package before it + # was canceled has a different pkgpart from the package itself. + my @cust_pkg = qsearch({ + 'table' => 'cust_pkg', + 'select' => 'cust_pkg.*, h_cust_pkg.pkgpart AS h_pkgpart', + 'addl_from' => ' JOIN ( + SELECT pkgnum, MAX(historynum) AS historynum FROM h_cust_pkg + WHERE cancel IS NULL + AND history_action = \'replace_new\' + GROUP BY pkgnum + ) AS last_history USING (pkgnum) + JOIN h_cust_pkg USING (historynum)', + 'extra_sql' => ' WHERE cust_pkg.cancel is not null + AND cust_pkg.pkgpart != h_cust_pkg.pkgpart' + }); + foreach my $cust_pkg ( @cust_pkg ) { + my $pkgnum = $cust_pkg->pkgnum; + warn "fixing pkgpart on canceled pkg#$pkgnum\n"; + $cust_pkg->set('pkgpart', $cust_pkg->h_pkgpart); + my $error = $cust_pkg->replace; + die $error if $error; + } + } =back