4 use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::location_Mixin
5 FS::m2m_Common FS::option_Common );
6 use vars qw($disable_agentcheck $DEBUG $me);
8 use Scalar::Util qw( blessed );
9 use List::Util qw(min max);
11 use Time::Local qw( timelocal timelocal_nocheck );
13 use FS::UID qw( getotaker dbh driver_name );
14 use FS::Misc qw( send_email );
15 use FS::Record qw( qsearch qsearchs fields );
20 use FS::cust_location;
22 use FS::cust_bill_pkg;
23 use FS::cust_pkg_detail;
24 use FS::cust_pkg_usage;
25 use FS::cdr_cust_pkg_usage;
30 use FS::cust_pkg_reason;
32 use FS::cust_pkg_discount;
37 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
39 # because they load configuration by setting FS::UID::callback (see TODO)
45 # for sending cancel emails in sub cancel
49 $me = '[FS::cust_pkg]';
51 $disable_agentcheck = 0;
55 my ( $hashref, $cache ) = @_;
56 #if ( $hashref->{'pkgpart'} ) {
57 if ( $hashref->{'pkg'} ) {
58 # #@{ $self->{'_pkgnum'} } = ();
59 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
60 # $self->{'_pkgpart'} = $subcache;
61 # #push @{ $self->{'_pkgnum'} },
62 # FS::part_pkg->new_or_cached($hashref, $subcache);
63 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
65 if ( exists $hashref->{'svcnum'} ) {
66 #@{ $self->{'_pkgnum'} } = ();
67 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
68 $self->{'_svcnum'} = $subcache;
69 #push @{ $self->{'_pkgnum'} },
70 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
76 FS::cust_pkg - Object methods for cust_pkg objects
82 $record = new FS::cust_pkg \%hash;
83 $record = new FS::cust_pkg { 'column' => 'value' };
85 $error = $record->insert;
87 $error = $new_record->replace($old_record);
89 $error = $record->delete;
91 $error = $record->check;
93 $error = $record->cancel;
95 $error = $record->suspend;
97 $error = $record->unsuspend;
99 $part_pkg = $record->part_pkg;
101 @labels = $record->labels;
103 $seconds = $record->seconds_since($timestamp);
105 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
106 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
110 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
111 inherits from FS::Record. The following fields are currently supported:
117 Primary key (assigned automatically for new billing items)
121 Customer (see L<FS::cust_main>)
125 Billing item definition (see L<FS::part_pkg>)
129 Optional link to package location (see L<FS::location>)
133 date package was ordered (also remains same on changes)
145 date (next bill date)
173 order taker (see L<FS::access_user>)
177 If this field is set to 1, disables the automatic
178 unsuspension of this package when using the B<unsuspendauto> config option.
182 If not set, defaults to 1
186 Date of change from previous package
196 =item change_locationnum
204 The pkgnum of the package that this package is supplemental to, if any.
208 The package link (L<FS::part_pkg_link>) that defines this supplemental
209 package, if it is one.
213 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
214 are specified as UNIX timestamps; see L<perlfunc/"time">. Also see
215 L<Time::Local> and L<Date::Parse> for conversion functions.
223 Create a new billing item. To add the item to the database, see L<"insert">.
227 sub table { 'cust_pkg'; }
228 sub cust_linked { $_[0]->cust_main_custnum; }
229 sub cust_unlinked_msg {
231 "WARNING: can't find cust_main.custnum ". $self->custnum.
232 ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
235 =item insert [ OPTION => VALUE ... ]
237 Adds this billing item to the database ("Orders" the item). If there is an
238 error, returns the error, otherwise returns false.
240 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
241 will be used to look up the package definition and agent restrictions will be
244 If the additional field I<refnum> is defined, an FS::pkg_referral record will
245 be created and inserted. Multiple FS::pkg_referral records can be created by
246 setting I<refnum> to an array reference of refnums or a hash reference with
247 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
248 record will be created corresponding to cust_main.refnum.
250 The following options are available:
256 If set true, supresses any referral credit to a referring customer.
260 cust_pkg_option records will be created
264 a ticket will be added to this customer with this subject
268 an optional queue name for ticket additions
272 Don't check the legality of the package definition. This should be used
273 when performing a package change that doesn't change the pkgpart (i.e.
281 my( $self, %options ) = @_;
284 $error = $self->check_pkgpart unless $options{'allow_pkgpart'};
285 return $error if $error;
287 my $part_pkg = $self->part_pkg;
289 if ( $part_pkg->option('start_1st', 1) && !$self->start_date ) {
290 my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5];
291 $mon += 1 unless $mday == 1;
292 until ( $mon < 12 ) { $mon -= 12; $year++; }
293 $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) );
296 foreach my $action ( qw(expire adjourn contract_end) ) {
297 my $months = $part_pkg->option("${action}_months",1);
298 if($months and !$self->$action) {
299 my $start = $self->start_date || $self->setup || time;
300 $self->$action( $part_pkg->add_freq($start, $months) );
304 my $free_days = $part_pkg->option('free_days',1);
305 if ( $free_days && $part_pkg->option('delay_setup',1) ) { #&& !$self->start_date
306 my ($mday,$mon,$year) = (localtime(time) )[3,4,5];
307 #my $start_date = ($self->start_date || timelocal(0,0,0,$mday,$mon,$year)) + 86400 * $free_days;
308 my $start_date = timelocal(0,0,0,$mday,$mon,$year) + 86400 * $free_days;
309 $self->start_date($start_date);
312 $self->order_date(time);
314 local $SIG{HUP} = 'IGNORE';
315 local $SIG{INT} = 'IGNORE';
316 local $SIG{QUIT} = 'IGNORE';
317 local $SIG{TERM} = 'IGNORE';
318 local $SIG{TSTP} = 'IGNORE';
319 local $SIG{PIPE} = 'IGNORE';
321 my $oldAutoCommit = $FS::UID::AutoCommit;
322 local $FS::UID::AutoCommit = 0;
325 $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
327 $dbh->rollback if $oldAutoCommit;
331 $self->refnum($self->cust_main->refnum) unless $self->refnum;
332 $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
333 $self->process_m2m( 'link_table' => 'pkg_referral',
334 'target_table' => 'part_referral',
335 'params' => $self->refnum,
338 if ( $self->discountnum ) {
339 my $error = $self->insert_discount();
341 $dbh->rollback if $oldAutoCommit;
346 #if ( $self->reg_code ) {
347 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
348 # $error = $reg_code->delete;
350 # $dbh->rollback if $oldAutoCommit;
355 my $conf = new FS::Conf;
357 if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
359 #this init stuff is still inefficient, but at least its limited to
360 # the small number (any?) folks using ticket emailing on pkg order
363 # use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
370 use FS::TicketSystem;
371 FS::TicketSystem->init();
373 my $q = new RT::Queue($RT::SystemUser);
374 $q->Load($options{ticket_queue}) if $options{ticket_queue};
375 my $t = new RT::Ticket($RT::SystemUser);
376 my $mime = new MIME::Entity;
377 $mime->build( Type => 'text/plain', Data => $options{ticket_subject} );
378 $t->Create( $options{ticket_queue} ? (Queue => $q) : (),
379 Subject => $options{ticket_subject},
382 $t->AddLink( Type => 'MemberOf',
383 Target => 'freeside://freeside/cust_main/'. $self->custnum,
387 if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
388 my $queue = new FS::queue {
389 'job' => 'FS::cust_main::queueable_print',
391 $error = $queue->insert(
392 'custnum' => $self->custnum,
393 'template' => 'welcome_letter',
397 warn "can't send welcome letter: $error";
402 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
409 This method now works but you probably shouldn't use it.
411 You don't want to delete packages, because there would then be no record
412 the customer ever purchased the package. Instead, see the cancel method and
413 hide cancelled packages.
420 local $SIG{HUP} = 'IGNORE';
421 local $SIG{INT} = 'IGNORE';
422 local $SIG{QUIT} = 'IGNORE';
423 local $SIG{TERM} = 'IGNORE';
424 local $SIG{TSTP} = 'IGNORE';
425 local $SIG{PIPE} = 'IGNORE';
427 my $oldAutoCommit = $FS::UID::AutoCommit;
428 local $FS::UID::AutoCommit = 0;
431 foreach my $cust_pkg_discount ($self->cust_pkg_discount) {
432 my $error = $cust_pkg_discount->delete;
434 $dbh->rollback if $oldAutoCommit;
438 #cust_bill_pkg_discount?
440 foreach my $cust_pkg_detail ($self->cust_pkg_detail) {
441 my $error = $cust_pkg_detail->delete;
443 $dbh->rollback if $oldAutoCommit;
448 foreach my $cust_pkg_reason (
450 'table' => 'cust_pkg_reason',
451 'hashref' => { 'pkgnum' => $self->pkgnum },
455 my $error = $cust_pkg_reason->delete;
457 $dbh->rollback if $oldAutoCommit;
464 my $error = $self->SUPER::delete(@_);
466 $dbh->rollback if $oldAutoCommit;
470 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
476 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
478 Replaces the OLD_RECORD with this one in the database. If there is an error,
479 returns the error, otherwise returns false.
481 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
483 Changing pkgpart may have disasterous effects. See the order subroutine.
485 setup and bill are normally updated by calling the bill method of a customer
486 object (see L<FS::cust_main>).
488 suspend is normally updated by the suspend and unsuspend methods.
490 cancel is normally updated by the cancel method (and also the order subroutine
493 Available options are:
499 can be set to a cancellation reason (see L<FS:reason>), 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<FS::reason_type>, reason - Text of the new reason.
503 the access_user (see L<FS::access_user>) providing the reason
507 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
516 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
521 ( ref($_[0]) eq 'HASH' )
525 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
526 #return "Can't change otaker!" if $old->otaker ne $new->otaker;
529 #return "Can't change setup once it exists!"
530 # if $old->getfield('setup') &&
531 # $old->getfield('setup') != $new->getfield('setup');
533 #some logic for bill, susp, cancel?
535 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
537 local $SIG{HUP} = 'IGNORE';
538 local $SIG{INT} = 'IGNORE';
539 local $SIG{QUIT} = 'IGNORE';
540 local $SIG{TERM} = 'IGNORE';
541 local $SIG{TSTP} = 'IGNORE';
542 local $SIG{PIPE} = 'IGNORE';
544 my $oldAutoCommit = $FS::UID::AutoCommit;
545 local $FS::UID::AutoCommit = 0;
548 foreach my $method ( qw(adjourn expire) ) { # How many reasons?
549 if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
550 my $error = $new->insert_reason(
551 'reason' => $options->{'reason'},
552 'date' => $new->$method,
554 'reason_otaker' => $options->{'reason_otaker'},
557 dbh->rollback if $oldAutoCommit;
558 return "Error inserting cust_pkg_reason: $error";
563 #save off and freeze RADIUS attributes for any associated svc_acct records
565 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
567 #also check for specific exports?
568 # to avoid spurious modify export events
569 @svc_acct = map { $_->svc_x }
570 grep { $_->part_svc->svcdb eq 'svc_acct' }
573 $_->snapshot foreach @svc_acct;
577 my $error = $new->SUPER::replace($old,
578 $options->{options} ? $options->{options} : ()
581 $dbh->rollback if $oldAutoCommit;
585 #for prepaid packages,
586 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
587 foreach my $old_svc_acct ( @svc_acct ) {
588 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
590 $new_svc_acct->replace( $old_svc_acct,
591 'depend_jobnum' => $options->{depend_jobnum},
594 $dbh->rollback if $oldAutoCommit;
599 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
606 Checks all fields to make sure this is a valid billing item. If there is an
607 error, returns the error, otherwise returns false. Called by the insert and
615 if ( !$self->locationnum or $self->locationnum == -1 ) {
616 $self->set('locationnum', $self->cust_main->ship_locationnum);
620 $self->ut_numbern('pkgnum')
621 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
622 || $self->ut_numbern('pkgpart')
623 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
624 || $self->ut_numbern('start_date')
625 || $self->ut_numbern('setup')
626 || $self->ut_numbern('bill')
627 || $self->ut_numbern('susp')
628 || $self->ut_numbern('cancel')
629 || $self->ut_numbern('adjourn')
630 || $self->ut_numbern('resume')
631 || $self->ut_numbern('expire')
632 || $self->ut_numbern('dundate')
633 || $self->ut_enum('no_auto', [ '', 'Y' ])
634 || $self->ut_enum('waive_setup', [ '', 'Y' ])
635 || $self->ut_numbern('agent_pkgid')
636 || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ])
637 || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ])
638 || $self->ut_foreign_keyn('main_pkgnum', 'cust_pkg', 'pkgnum')
639 || $self->ut_foreign_keyn('pkglinknum', 'part_pkg_link', 'pkglinknum')
641 return $error if $error;
643 return "A package with both start date (future start) and setup date (already started) will never bill"
644 if $self->start_date && $self->setup;
646 return "A future unsuspend date can only be set for a package with a suspend date"
647 if $self->resume and !$self->susp and !$self->adjourn;
649 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
651 if ( $self->dbdef_table->column('manual_flag') ) {
652 $self->manual_flag('') if $self->manual_flag eq ' ';
653 $self->manual_flag =~ /^([01]?)$/
654 or return "Illegal manual_flag ". $self->manual_flag;
655 $self->manual_flag($1);
663 Check the pkgpart to make sure it's allowed with the reg_code and/or
664 promo_code of the package (if present) and with the customer's agent.
665 Called from C<insert>, unless we are doing a package change that doesn't
673 # my $error = $self->ut_numbern('pkgpart'); # already done
676 if ( $self->reg_code ) {
678 unless ( grep { $self->pkgpart == $_->pkgpart }
679 map { $_->reg_code_pkg }
680 qsearchs( 'reg_code', { 'code' => $self->reg_code,
681 'agentnum' => $self->cust_main->agentnum })
683 return "Unknown registration code";
686 } elsif ( $self->promo_code ) {
689 qsearchs('part_pkg', {
690 'pkgpart' => $self->pkgpart,
691 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
693 return 'Unknown promotional code' unless $promo_part_pkg;
697 unless ( $disable_agentcheck ) {
699 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
700 return "agent ". $agent->agentnum. ':'. $agent->agent.
701 " can't purchase pkgpart ". $self->pkgpart
702 unless $agent->pkgpart_hashref->{ $self->pkgpart }
703 || $agent->agentnum == $self->part_pkg->agentnum;
706 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
707 return $error if $error;
715 =item cancel [ OPTION => VALUE ... ]
717 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
718 in this package, then cancels the package itself (sets the cancel field to
721 Available options are:
725 =item quiet - can be set true to supress email cancellation notices.
727 =item time - can be set to cancel the package based on a specific future or
728 historical date. Using time ensures that the remaining amount is calculated
729 correctly. Note however that this is an immediate cancel and just changes
730 the date. You are PROBABLY looking to expire the account instead of using
733 =item reason - can be set to a cancellation reason (see L<FS:reason>),
734 either a reasonnum of an existing reason, or passing a hashref will create
735 a new reason. The hashref should have the following keys: typenum - Reason
736 type (see L<FS::reason_type>, reason - Text of the new reason.
738 =item date - can be set to a unix style timestamp to specify when to
741 =item nobill - can be set true to skip billing if it might otherwise be done.
743 =item unused_credit - can be set to 1 to credit the remaining time, or 0 to
744 not credit it. This must be set (by change()) when changing the package
745 to a different pkgpart or location, and probably shouldn't be in any other
746 case. If it's not set, the 'unused_credit_cancel' part_pkg option will
751 If there is an error, returns the error, otherwise returns false.
756 my( $self, %options ) = @_;
759 # pass all suspend/cancel actions to the main package
760 if ( $self->main_pkgnum and !$options{'from_main'} ) {
761 return $self->main_pkg->cancel(%options);
764 my $conf = new FS::Conf;
766 warn "cust_pkg::cancel called with options".
767 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
770 local $SIG{HUP} = 'IGNORE';
771 local $SIG{INT} = 'IGNORE';
772 local $SIG{QUIT} = 'IGNORE';
773 local $SIG{TERM} = 'IGNORE';
774 local $SIG{TSTP} = 'IGNORE';
775 local $SIG{PIPE} = 'IGNORE';
777 my $oldAutoCommit = $FS::UID::AutoCommit;
778 local $FS::UID::AutoCommit = 0;
781 my $old = $self->select_for_update;
783 if ( $old->get('cancel') || $self->get('cancel') ) {
784 dbh->rollback if $oldAutoCommit;
785 return ""; # no error
788 # XXX possibly set cancel_time to the expire date?
789 my $cancel_time = $options{'time'} || time;
790 my $date = $options{'date'} if $options{'date'}; # expire/cancel later
791 $date = '' if ($date && $date <= $cancel_time); # complain instead?
793 #race condition: usage could be ongoing until unprovisioned
794 #resolved by performing a change package instead (which unprovisions) and
796 if ( !$options{nobill} && !$date ) {
797 # && $conf->exists('bill_usage_on_cancel') ) { #calc_cancel checks this
798 my $copy = $self->new({$self->hash});
800 $copy->cust_main->bill( 'pkg_list' => [ $copy ],
802 'time' => $cancel_time );
803 warn "Error billing during cancel, custnum ".
804 #$self->cust_main->custnum. ": $error"
809 if ( $options{'reason'} ) {
810 $error = $self->insert_reason( 'reason' => $options{'reason'},
811 'action' => $date ? 'expire' : 'cancel',
812 'date' => $date ? $date : $cancel_time,
813 'reason_otaker' => $options{'reason_otaker'},
816 dbh->rollback if $oldAutoCommit;
817 return "Error inserting cust_pkg_reason: $error";
821 my %svc_cancel_opt = ();
822 $svc_cancel_opt{'date'} = $date if $date;
823 foreach my $cust_svc (
826 sort { $a->[1] <=> $b->[1] }
827 map { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; }
828 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
830 my $part_svc = $cust_svc->part_svc;
831 next if ( defined($part_svc) and $part_svc->preserve );
832 my $error = $cust_svc->cancel( %svc_cancel_opt );
835 $dbh->rollback if $oldAutoCommit;
836 return 'Error '. ($svc_cancel_opt{'date'} ? 'expiring' : 'canceling' ).
842 # credit remaining time if appropriate
844 if ( exists($options{'unused_credit'}) ) {
845 $do_credit = $options{'unused_credit'};
848 $do_credit = $self->part_pkg->option('unused_credit_cancel', 1);
851 my $error = $self->credit_remaining('cancel', $cancel_time);
853 $dbh->rollback if $oldAutoCommit;
860 my %hash = $self->hash;
861 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
862 my $new = new FS::cust_pkg ( \%hash );
863 $error = $new->replace( $self, options => { $self->options } );
865 $dbh->rollback if $oldAutoCommit;
869 foreach my $supp_pkg ( $self->supplemental_pkgs ) {
870 $error = $supp_pkg->cancel(%options, 'from_main' => 1);
872 $dbh->rollback if $oldAutoCommit;
873 return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
877 foreach my $usage ( $self->cust_pkg_usage ) {
878 $error = $usage->delete;
880 $dbh->rollback if $oldAutoCommit;
881 return "deleting usage pools: $error";
885 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
886 return '' if $date; #no errors
888 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
889 if ( !$options{'quiet'} &&
890 $conf->exists('emailcancel', $self->cust_main->agentnum) &&
892 my $msgnum = $conf->config('cancel_msgnum', $self->cust_main->agentnum);
895 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
896 $error = $msg_template->send( 'cust_main' => $self->cust_main,
901 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
902 'to' => \@invoicing_list,
903 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
904 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
907 #should this do something on errors?
914 =item cancel_if_expired [ NOW_TIMESTAMP ]
916 Cancels this package if its expire date has been reached.
920 sub cancel_if_expired {
922 my $time = shift || time;
923 return '' unless $self->expire && $self->expire <= $time;
924 my $error = $self->cancel;
926 return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
927 $self->custnum. ": $error";
934 "Un-cancels" this package: Orders a new package with the same custnum, pkgpart,
935 locationnum, (other fields?). Attempts to re-provision cancelled services
936 using history information (errors at this stage are not fatal).
938 cust_pkg: pass a scalar reference, will be filled in with the new cust_pkg object
940 svc_fatal: service provisioning errors are fatal
942 svc_errors: pass an array reference, will be filled in with any provisioning errors
944 main_pkgnum: link the package as a supplemental package of this one. For
950 my( $self, %options ) = @_;
952 #in case you try do do $uncancel-date = $cust_pkg->uncacel
953 return '' unless $self->get('cancel');
955 if ( $self->main_pkgnum and !$options{'main_pkgnum'} ) {
956 return $self->main_pkg->uncancel(%options);
963 local $SIG{HUP} = 'IGNORE';
964 local $SIG{INT} = 'IGNORE';
965 local $SIG{QUIT} = 'IGNORE';
966 local $SIG{TERM} = 'IGNORE';
967 local $SIG{TSTP} = 'IGNORE';
968 local $SIG{PIPE} = 'IGNORE';
970 my $oldAutoCommit = $FS::UID::AutoCommit;
971 local $FS::UID::AutoCommit = 0;
975 # insert the new package
978 my $cust_pkg = new FS::cust_pkg {
979 last_bill => ( $options{'last_bill'} || $self->get('last_bill') ),
980 bill => ( $options{'bill'} || $self->get('bill') ),
982 uncancel_pkgnum => $self->pkgnum,
983 main_pkgnum => ($options{'main_pkgnum'} || ''),
984 map { $_ => $self->get($_) } qw(
985 custnum pkgpart locationnum
987 susp adjourn resume expire start_date contract_end dundate
988 change_date change_pkgpart change_locationnum
989 manual_flag no_auto quantity agent_pkgid recur_show_zero setup_show_zero
993 my $error = $cust_pkg->insert(
994 'change' => 1, #supresses any referral credit to a referring customer
995 'allow_pkgpart' => 1, # allow this even if the package def is disabled
998 $dbh->rollback if $oldAutoCommit;
1006 #find historical services within this timeframe before the package cancel
1007 # (incompatible with "time" option to cust_pkg->cancel?)
1008 my $fuzz = 2 * 60; #2 minutes? too much? (might catch separate unprovision)
1009 # too little? (unprovisioing export delay?)
1010 my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz );
1011 my @h_cust_svc = $self->h_cust_svc( $end, $start );
1014 foreach my $h_cust_svc (@h_cust_svc) {
1015 my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start );
1016 #next unless $h_svc_x; #should this happen?
1017 (my $table = $h_svc_x->table) =~ s/^h_//;
1018 require "FS/$table.pm";
1019 my $class = "FS::$table";
1020 my $svc_x = $class->new( {
1021 'pkgnum' => $cust_pkg->pkgnum,
1022 'svcpart' => $h_cust_svc->svcpart,
1023 map { $_ => $h_svc_x->get($_) } fields($table)
1027 if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) {
1028 $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] );
1031 my $svc_error = $svc_x->insert;
1033 if ( $options{svc_fatal} ) {
1034 $dbh->rollback if $oldAutoCommit;
1037 push @svc_errors, $svc_error;
1038 # is this necessary? svc_Common::insert already deletes the
1039 # cust_svc if inserting svc_x fails.
1040 my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum });
1042 my $cs_error = $cust_svc->delete;
1044 $dbh->rollback if $oldAutoCommit;
1050 } #foreach $h_cust_svc
1052 #these are pretty rare, but should handle them
1053 # - dsl_device (mac addresses)
1054 # - phone_device (mac addresses)
1055 # - dsl_note (ikano notes)
1056 # - domain_record (i.e. restore DNS information w/domains)
1057 # - inventory_item(?) (inventory w/un-cancelling service?)
1058 # - nas (svc_broaband nas stuff)
1059 #this stuff is unused in the wild afaik
1060 # - mailinglistmember
1062 # - svc_domain.parent_svcnum?
1063 # - acct_snarf (ancient mail fetching config)
1064 # - cgp_rule (communigate)
1065 # - cust_svc_option (used by our Tron stuff)
1066 # - acct_rt_transaction (used by our time worked stuff)
1069 # also move over any services that didn't unprovision at cancellation
1072 foreach my $cust_svc ( qsearch('cust_svc', { pkgnum => $self->pkgnum } ) ) {
1073 $cust_svc->pkgnum( $cust_pkg->pkgnum );
1074 my $error = $cust_svc->replace;
1076 $dbh->rollback if $oldAutoCommit;
1082 # Uncancel any supplemental packages, and make them supplemental to the
1086 foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1088 $error = $supp_pkg->uncancel(%options, 'main_pkgnum' => $cust_pkg->pkgnum);
1090 $dbh->rollback if $oldAutoCommit;
1091 return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
1099 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1101 ${ $options{cust_pkg} } = $cust_pkg if ref($options{cust_pkg});
1102 @{ $options{svc_errors} } = @svc_errors if ref($options{svc_errors});
1109 Cancels any pending expiration (sets the expire field to null).
1111 If there is an error, returns the error, otherwise returns false.
1116 my( $self, %options ) = @_;
1119 local $SIG{HUP} = 'IGNORE';
1120 local $SIG{INT} = 'IGNORE';
1121 local $SIG{QUIT} = 'IGNORE';
1122 local $SIG{TERM} = 'IGNORE';
1123 local $SIG{TSTP} = 'IGNORE';
1124 local $SIG{PIPE} = 'IGNORE';
1126 my $oldAutoCommit = $FS::UID::AutoCommit;
1127 local $FS::UID::AutoCommit = 0;
1130 my $old = $self->select_for_update;
1132 my $pkgnum = $old->pkgnum;
1133 if ( $old->get('cancel') || $self->get('cancel') ) {
1134 dbh->rollback if $oldAutoCommit;
1135 return "Can't unexpire cancelled package $pkgnum";
1136 # or at least it's pointless
1139 unless ( $old->get('expire') && $self->get('expire') ) {
1140 dbh->rollback if $oldAutoCommit;
1141 return ""; # no error
1144 my %hash = $self->hash;
1145 $hash{'expire'} = '';
1146 my $new = new FS::cust_pkg ( \%hash );
1147 $error = $new->replace( $self, options => { $self->options } );
1149 $dbh->rollback if $oldAutoCommit;
1153 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1159 =item suspend [ OPTION => VALUE ... ]
1161 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1162 package, then suspends the package itself (sets the susp field to now).
1164 Available options are:
1168 =item reason - can be set to a cancellation reason (see L<FS:reason>),
1169 either a reasonnum of an existing reason, or passing a hashref will create
1170 a new reason. The hashref should have the following keys:
1171 - typenum - Reason type (see L<FS::reason_type>
1172 - reason - Text of the new reason.
1174 =item date - can be set to a unix style timestamp to specify when to
1177 =item time - can be set to override the current time, for calculation
1178 of final invoices or unused-time credits
1180 =item resume_date - can be set to a time when the package should be
1181 unsuspended. This may be more convenient than calling C<unsuspend()>
1184 =item from_main - allows a supplemental package to be suspended, rather
1185 than redirecting the method call to its main package. For internal use.
1189 If there is an error, returns the error, otherwise returns false.
1194 my( $self, %options ) = @_;
1197 # pass all suspend/cancel actions to the main package
1198 if ( $self->main_pkgnum and !$options{'from_main'} ) {
1199 return $self->main_pkg->suspend(%options);
1202 local $SIG{HUP} = 'IGNORE';
1203 local $SIG{INT} = 'IGNORE';
1204 local $SIG{QUIT} = 'IGNORE';
1205 local $SIG{TERM} = 'IGNORE';
1206 local $SIG{TSTP} = 'IGNORE';
1207 local $SIG{PIPE} = 'IGNORE';
1209 my $oldAutoCommit = $FS::UID::AutoCommit;
1210 local $FS::UID::AutoCommit = 0;
1213 my $old = $self->select_for_update;
1215 my $pkgnum = $old->pkgnum;
1216 if ( $old->get('cancel') || $self->get('cancel') ) {
1217 dbh->rollback if $oldAutoCommit;
1218 return "Can't suspend cancelled package $pkgnum";
1221 if ( $old->get('susp') || $self->get('susp') ) {
1222 dbh->rollback if $oldAutoCommit;
1223 return ""; # no error # complain on adjourn?
1226 my $suspend_time = $options{'time'} || time;
1227 my $date = $options{date} if $options{date}; # adjourn/suspend later
1228 $date = '' if ($date && $date <= $suspend_time); # complain instead?
1230 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
1231 dbh->rollback if $oldAutoCommit;
1232 return "Package $pkgnum expires before it would be suspended.";
1235 # some false laziness with sub cancel
1236 if ( !$options{nobill} && !$date &&
1237 $self->part_pkg->option('bill_suspend_as_cancel',1) ) {
1238 # kind of a kludge--'bill_suspend_as_cancel' to avoid having to
1239 # make the entire cust_main->bill path recognize 'suspend' and
1240 # 'cancel' separately.
1241 warn "Billing $pkgnum on suspension (at $suspend_time)\n" if $DEBUG;
1242 my $copy = $self->new({$self->hash});
1244 $copy->cust_main->bill( 'pkg_list' => [ $copy ],
1246 'time' => $suspend_time );
1247 warn "Error billing during suspend, custnum ".
1248 #$self->cust_main->custnum. ": $error"
1253 if ( $options{'reason'} ) {
1254 $error = $self->insert_reason( 'reason' => $options{'reason'},
1255 'action' => $date ? 'adjourn' : 'suspend',
1256 'date' => $date ? $date : $suspend_time,
1257 'reason_otaker' => $options{'reason_otaker'},
1260 dbh->rollback if $oldAutoCommit;
1261 return "Error inserting cust_pkg_reason: $error";
1265 my %hash = $self->hash;
1267 $hash{'adjourn'} = $date;
1269 $hash{'susp'} = $suspend_time;
1272 my $resume_date = $options{'resume_date'} || 0;
1273 if ( $resume_date > ($date || $suspend_time) ) {
1274 $hash{'resume'} = $resume_date;
1277 $options{options} ||= {};
1279 my $new = new FS::cust_pkg ( \%hash );
1280 $error = $new->replace( $self, options => { $self->options,
1281 %{ $options{options} },
1285 $dbh->rollback if $oldAutoCommit;
1290 # credit remaining time if appropriate
1291 if ( $self->part_pkg->option('unused_credit_suspend', 1) ) {
1292 my $error = $self->credit_remaining('suspend', $suspend_time);
1294 $dbh->rollback if $oldAutoCommit;
1301 foreach my $cust_svc (
1302 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
1304 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1306 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1307 $dbh->rollback if $oldAutoCommit;
1308 return "Illegal svcdb value in part_svc!";
1311 require "FS/$svcdb.pm";
1313 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1315 $error = $svc->suspend;
1317 $dbh->rollback if $oldAutoCommit;
1320 my( $label, $value ) = $cust_svc->label;
1321 push @labels, "$label: $value";
1325 my $conf = new FS::Conf;
1326 if ( $conf->config('suspend_email_admin') ) {
1328 my $error = send_email(
1329 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
1330 #invoice_from ??? well as good as any
1331 'to' => $conf->config('suspend_email_admin'),
1332 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
1334 "This is an automatic message from your Freeside installation\n",
1335 "informing you that the following customer package has been suspended:\n",
1337 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
1338 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
1339 ( map { "Service : $_\n" } @labels ),
1344 warn "WARNING: can't send suspension admin email (suspending anyway): ".
1352 foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1353 $error = $supp_pkg->suspend(%options, 'from_main' => 1);
1355 $dbh->rollback if $oldAutoCommit;
1356 return "suspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
1360 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1365 =item credit_remaining MODE TIME
1367 Generate a credit for this package for the time remaining in the current
1368 billing period. MODE is either "suspend" or "cancel" (determines the
1369 credit type). TIME is the time of suspension/cancellation. Both arguments
1374 sub credit_remaining {
1375 # Add a credit for remaining service
1376 my ($self, $mode, $time) = @_;
1377 die 'credit_remaining requires suspend or cancel'
1378 unless $mode eq 'suspend' or $mode eq 'cancel';
1379 die 'no suspend/cancel time' unless $time > 0;
1381 my $conf = FS::Conf->new;
1382 my $reason_type = $conf->config($mode.'_credit_type');
1384 my $last_bill = $self->getfield('last_bill') || 0;
1385 my $next_bill = $self->getfield('bill') || 0;
1386 if ( $last_bill > 0 # the package has been billed
1387 and $next_bill > 0 # the package has a next bill date
1388 and $next_bill >= $time # which is in the future
1390 my $remaining_value = $self->calc_remain('time' => $time);
1391 if ( $remaining_value > 0 ) {
1392 warn "Crediting for $remaining_value on package ".$self->pkgnum."\n"
1394 my $error = $self->cust_main->credit(
1396 'Credit for unused time on '. $self->part_pkg->pkg,
1397 'reason_type' => $reason_type,
1399 return "Error crediting customer \$$remaining_value for unused time".
1400 " on ". $self->part_pkg->pkg. ": $error"
1402 } #if $remaining_value
1403 } #if $last_bill, etc.
1407 =item unsuspend [ OPTION => VALUE ... ]
1409 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1410 package, then unsuspends the package itself (clears the susp field and the
1411 adjourn field if it is in the past). If the suspend reason includes an
1412 unsuspension package, that package will be ordered.
1414 Available options are:
1420 Can be set to a date to unsuspend the package in the future (the 'resume'
1423 =item adjust_next_bill
1425 Can be set true to adjust the next bill date forward by
1426 the amount of time the account was inactive. This was set true by default
1427 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
1428 explicitly requested. Price plans for which this makes sense (anniversary-date
1429 based than prorate or subscription) could have an option to enable this
1434 If there is an error, returns the error, otherwise returns false.
1439 my( $self, %opt ) = @_;
1442 # pass all suspend/cancel actions to the main package
1443 if ( $self->main_pkgnum and !$opt{'from_main'} ) {
1444 return $self->main_pkg->unsuspend(%opt);
1447 local $SIG{HUP} = 'IGNORE';
1448 local $SIG{INT} = 'IGNORE';
1449 local $SIG{QUIT} = 'IGNORE';
1450 local $SIG{TERM} = 'IGNORE';
1451 local $SIG{TSTP} = 'IGNORE';
1452 local $SIG{PIPE} = 'IGNORE';
1454 my $oldAutoCommit = $FS::UID::AutoCommit;
1455 local $FS::UID::AutoCommit = 0;
1458 my $old = $self->select_for_update;
1460 my $pkgnum = $old->pkgnum;
1461 if ( $old->get('cancel') || $self->get('cancel') ) {
1462 $dbh->rollback if $oldAutoCommit;
1463 return "Can't unsuspend cancelled package $pkgnum";
1466 unless ( $old->get('susp') && $self->get('susp') ) {
1467 $dbh->rollback if $oldAutoCommit;
1468 return ""; # no error # complain instead?
1471 my $date = $opt{'date'};
1472 if ( $date and $date > time ) { # return an error if $date <= time?
1474 if ( $old->get('expire') && $old->get('expire') < $date ) {
1475 $dbh->rollback if $oldAutoCommit;
1476 return "Package $pkgnum expires before it would be unsuspended.";
1479 my $new = new FS::cust_pkg { $self->hash };
1480 $new->set('resume', $date);
1481 $error = $new->replace($self, options => $self->options);
1484 $dbh->rollback if $oldAutoCommit;
1488 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1496 foreach my $cust_svc (
1497 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
1499 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1501 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1502 $dbh->rollback if $oldAutoCommit;
1503 return "Illegal svcdb value in part_svc!";
1506 require "FS/$svcdb.pm";
1508 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1510 $error = $svc->unsuspend;
1512 $dbh->rollback if $oldAutoCommit;
1515 my( $label, $value ) = $cust_svc->label;
1516 push @labels, "$label: $value";
1521 my $cust_pkg_reason = $self->last_cust_pkg_reason('susp');
1522 my $reason = $cust_pkg_reason ? $cust_pkg_reason->reason : '';
1524 my %hash = $self->hash;
1525 my $inactive = time - $hash{'susp'};
1527 my $conf = new FS::Conf;
1529 if ( $inactive > 0 &&
1530 ( $hash{'bill'} || $hash{'setup'} ) &&
1531 ( $opt{'adjust_next_bill'} ||
1532 $conf->exists('unsuspend-always_adjust_next_bill_date') ||
1533 $self->part_pkg->option('unsuspend_adjust_bill', 1) )
1536 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive;
1541 $hash{'adjourn'} = '' if $hash{'adjourn'} and $hash{'adjourn'} < time;
1542 $hash{'resume'} = '' if !$hash{'adjourn'};
1543 my $new = new FS::cust_pkg ( \%hash );
1544 $error = $new->replace( $self, options => { $self->options } );
1546 $dbh->rollback if $oldAutoCommit;
1552 if ( $reason && $reason->unsuspend_pkgpart ) {
1553 my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart)
1554 or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart.
1556 my $start_date = $self->cust_main->next_bill_date
1557 if $reason->unsuspend_hold;
1560 $unsusp_pkg = FS::cust_pkg->new({
1561 'custnum' => $self->custnum,
1562 'pkgpart' => $reason->unsuspend_pkgpart,
1563 'start_date' => $start_date,
1564 'locationnum' => $self->locationnum,
1565 # discount? probably not...
1568 $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg );
1572 $dbh->rollback if $oldAutoCommit;
1577 if ( $conf->config('unsuspend_email_admin') ) {
1579 my $error = send_email(
1580 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
1581 #invoice_from ??? well as good as any
1582 'to' => $conf->config('unsuspend_email_admin'),
1583 'subject' => 'FREESIDE NOTIFICATION: Customer package unsuspended', 'body' => [
1584 "This is an automatic message from your Freeside installation\n",
1585 "informing you that the following customer package has been unsuspended:\n",
1587 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
1588 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
1589 ( map { "Service : $_\n" } @labels ),
1591 "An unsuspension fee was charged: ".
1592 $unsusp_pkg->part_pkg->pkg_comment."\n"
1599 warn "WARNING: can't send unsuspension admin email (unsuspending anyway): ".
1605 foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1606 $error = $supp_pkg->unsuspend(%opt, 'from_main' => 1);
1608 $dbh->rollback if $oldAutoCommit;
1609 return "unsuspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
1613 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1620 Cancels any pending suspension (sets the adjourn field to null).
1622 If there is an error, returns the error, otherwise returns false.
1627 my( $self, %options ) = @_;
1630 local $SIG{HUP} = 'IGNORE';
1631 local $SIG{INT} = 'IGNORE';
1632 local $SIG{QUIT} = 'IGNORE';
1633 local $SIG{TERM} = 'IGNORE';
1634 local $SIG{TSTP} = 'IGNORE';
1635 local $SIG{PIPE} = 'IGNORE';
1637 my $oldAutoCommit = $FS::UID::AutoCommit;
1638 local $FS::UID::AutoCommit = 0;
1641 my $old = $self->select_for_update;
1643 my $pkgnum = $old->pkgnum;
1644 if ( $old->get('cancel') || $self->get('cancel') ) {
1645 dbh->rollback if $oldAutoCommit;
1646 return "Can't unadjourn cancelled package $pkgnum";
1647 # or at least it's pointless
1650 if ( $old->get('susp') || $self->get('susp') ) {
1651 dbh->rollback if $oldAutoCommit;
1652 return "Can't unadjourn suspended package $pkgnum";
1653 # perhaps this is arbitrary
1656 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1657 dbh->rollback if $oldAutoCommit;
1658 return ""; # no error
1661 my %hash = $self->hash;
1662 $hash{'adjourn'} = '';
1663 $hash{'resume'} = '';
1664 my $new = new FS::cust_pkg ( \%hash );
1665 $error = $new->replace( $self, options => { $self->options } );
1667 $dbh->rollback if $oldAutoCommit;
1671 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1678 =item change HASHREF | OPTION => VALUE ...
1680 Changes this package: cancels it and creates a new one, with a different
1681 pkgpart or locationnum or both. All services are transferred to the new
1682 package (no change will be made if this is not possible).
1684 Options may be passed as a list of key/value pairs or as a hash reference.
1691 New locationnum, to change the location for this package.
1695 New FS::cust_location object, to create a new location and assign it
1700 New pkgpart (see L<FS::part_pkg>).
1704 New refnum (see L<FS::part_referral>).
1708 Set to true to transfer billing dates (start_date, setup, last_bill, bill,
1709 susp, adjourn, cancel, expire, and contract_end) to the new package.
1713 At least one of locationnum, cust_location, pkgpart, refnum must be specified
1714 (otherwise, what's the point?)
1716 Returns either the new FS::cust_pkg object or a scalar error.
1720 my $err_or_new_cust_pkg = $old_cust_pkg->change
1724 #some false laziness w/order
1727 my $opt = ref($_[0]) ? shift : { @_ };
1729 # my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1732 my $conf = new FS::Conf;
1734 # Transactionize this whole mess
1735 local $SIG{HUP} = 'IGNORE';
1736 local $SIG{INT} = 'IGNORE';
1737 local $SIG{QUIT} = 'IGNORE';
1738 local $SIG{TERM} = 'IGNORE';
1739 local $SIG{TSTP} = 'IGNORE';
1740 local $SIG{PIPE} = 'IGNORE';
1742 my $oldAutoCommit = $FS::UID::AutoCommit;
1743 local $FS::UID::AutoCommit = 0;
1752 #$hash{$_} = $self->$_() foreach qw( last_bill bill );
1754 #$hash{$_} = $self->$_() foreach qw( setup );
1756 $hash{'setup'} = $time if $self->setup;
1758 $hash{'change_date'} = $time;
1759 $hash{"change_$_"} = $self->$_()
1760 foreach qw( pkgnum pkgpart locationnum );
1762 if ( $opt->{'cust_location'} &&
1763 ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) {
1765 if ( ! $opt->{'cust_location'}->locationnum ) {
1767 $error = $opt->{'cust_location'}->insert;
1769 $dbh->rollback if $oldAutoCommit;
1770 return "inserting cust_location (transaction rolled back): $error";
1773 $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1777 # whether to override pkgpart checking on the new package
1778 my $allow_pkgpart = 1;
1779 if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) {
1784 my $unused_credit = 0;
1785 my $keep_dates = $opt->{'keep_dates'};
1786 # Special case. If the pkgpart is changing, and the customer is
1787 # going to be credited for remaining time, don't keep setup, bill,
1788 # or last_bill dates, and DO pass the flag to cancel() to credit
1790 if ( $opt->{'pkgpart'}
1791 and $opt->{'pkgpart'} != $self->pkgpart
1792 and $self->part_pkg->option('unused_credit_change', 1) ) {
1795 $hash{$_} = '' foreach qw(setup bill last_bill);
1798 if ( $keep_dates ) {
1799 foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire
1800 resume start_date contract_end ) ) {
1801 $hash{$date} = $self->getfield($date);
1804 # allow $opt->{'locationnum'} = '' to specifically set it to null
1805 # (i.e. customer default location)
1806 $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
1808 # usually this doesn't matter. the two cases where it does are:
1809 # 1. unused_credit_change + pkgpart change + setup fee on the new package
1811 # 2. (more importantly) changing a package before it's billed
1812 $hash{'waive_setup'} = $self->waive_setup;
1814 # Create the new package.
1815 my $cust_pkg = new FS::cust_pkg {
1816 custnum => $self->custnum,
1817 pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ),
1818 refnum => ( $opt->{'refnum'} || $self->refnum ),
1819 locationnum => ( $opt->{'locationnum'} ),
1822 $error = $cust_pkg->insert( 'change' => 1,
1823 'allow_pkgpart' => $allow_pkgpart );
1825 $dbh->rollback if $oldAutoCommit;
1829 # Transfer services and cancel old package.
1831 $error = $self->transfer($cust_pkg);
1832 if ($error and $error == 0) {
1833 # $old_pkg->transfer failed.
1834 $dbh->rollback if $oldAutoCommit;
1838 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1839 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1840 $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1841 if ($error and $error == 0) {
1842 # $old_pkg->transfer failed.
1843 $dbh->rollback if $oldAutoCommit;
1849 # Transfers were successful, but we still had services left on the old
1850 # package. We can't change the package under this circumstances, so abort.
1851 $dbh->rollback if $oldAutoCommit;
1852 return "Unable to transfer all services from package ". $self->pkgnum;
1855 #reset usage if changing pkgpart
1856 # AND usage rollover is off (otherwise adds twice, now and at package bill)
1857 if ($self->pkgpart != $cust_pkg->pkgpart) {
1858 my $part_pkg = $cust_pkg->part_pkg;
1859 $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1863 if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
1866 $dbh->rollback if $oldAutoCommit;
1867 return "Error setting usage values: $error";
1870 # if NOT changing pkgpart, transfer any usage pools over
1871 foreach my $usage ($self->cust_pkg_usage) {
1872 $usage->set('pkgnum', $cust_pkg->pkgnum);
1873 $error = $usage->replace;
1875 $dbh->rollback if $oldAutoCommit;
1876 return "Error transferring usage pools: $error";
1881 # Order any supplemental packages.
1882 my $part_pkg = $cust_pkg->part_pkg;
1883 my @old_supp_pkgs = $self->supplemental_pkgs;
1885 foreach my $link ($part_pkg->supp_part_pkg_link) {
1887 foreach (@old_supp_pkgs) {
1888 if ($_->pkgpart == $link->dst_pkgpart) {
1890 $_->pkgpart(0); # so that it can't match more than once
1894 # false laziness with FS::cust_main::Packages::order_pkg
1895 my $new = FS::cust_pkg->new({
1896 pkgpart => $link->dst_pkgpart,
1897 pkglinknum => $link->pkglinknum,
1898 custnum => $self->custnum,
1899 main_pkgnum => $cust_pkg->pkgnum,
1900 locationnum => $cust_pkg->locationnum,
1901 start_date => $cust_pkg->start_date,
1902 order_date => $cust_pkg->order_date,
1903 expire => $cust_pkg->expire,
1904 adjourn => $cust_pkg->adjourn,
1905 contract_end => $cust_pkg->contract_end,
1906 refnum => $cust_pkg->refnum,
1907 discountnum => $cust_pkg->discountnum,
1908 waive_setup => $cust_pkg->waive_setup,
1910 if ( $old and $opt->{'keep_dates'} ) {
1911 foreach (qw(setup bill last_bill)) {
1912 $new->set($_, $old->get($_));
1915 $error = $new->insert( allow_pkgpart => $allow_pkgpart );
1918 $error ||= $old->transfer($new);
1920 if ( $error and $error > 0 ) {
1921 # no reason why this should ever fail, but still...
1922 $error = "Unable to transfer all services from supplemental package ".
1926 $dbh->rollback if $oldAutoCommit;
1929 push @new_supp_pkgs, $new;
1932 #Good to go, cancel old package. Notify 'cancel' of whether to credit
1934 #Don't allow billing the package (preceding period packages and/or
1935 #outstanding usage) if we are keeping dates (i.e. location changing),
1936 #because the new package will be billed for the same date range.
1937 #Supplemental packages are also canceled here.
1938 $error = $self->cancel(
1940 unused_credit => $unused_credit,
1941 nobill => $keep_dates
1944 $dbh->rollback if $oldAutoCommit;
1948 if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
1950 my $error = $cust_pkg->cust_main->bill(
1951 'pkg_list' => [ $cust_pkg, @new_supp_pkgs ]
1954 $dbh->rollback if $oldAutoCommit;
1959 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1965 =item set_quantity QUANTITY
1967 Change the package's quantity field. This is the one package property
1968 that can safely be changed without canceling and reordering the package
1969 (because it doesn't affect tax eligibility). Returns an error or an
1976 $self = $self->replace_old; # just to make sure
1978 ($qty =~ /^\d+$/ and $qty > 0) or return "bad package quantity $qty";
1979 $self->set('quantity' => $qty);
1983 use Storable 'thaw';
1985 sub process_bulk_cust_pkg {
1987 my $param = thaw(decode_base64(shift));
1988 warn Dumper($param) if $DEBUG;
1990 my $old_part_pkg = qsearchs('part_pkg',
1991 { pkgpart => $param->{'old_pkgpart'} });
1992 my $new_part_pkg = qsearchs('part_pkg',
1993 { pkgpart => $param->{'new_pkgpart'} });
1994 die "Must select a new package type\n" unless $new_part_pkg;
1995 #my $keep_dates = $param->{'keep_dates'} || 0;
1996 my $keep_dates = 1; # there is no good reason to turn this off
1998 local $SIG{HUP} = 'IGNORE';
1999 local $SIG{INT} = 'IGNORE';
2000 local $SIG{QUIT} = 'IGNORE';
2001 local $SIG{TERM} = 'IGNORE';
2002 local $SIG{TSTP} = 'IGNORE';
2003 local $SIG{PIPE} = 'IGNORE';
2005 my $oldAutoCommit = $FS::UID::AutoCommit;
2006 local $FS::UID::AutoCommit = 0;
2009 my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
2012 foreach my $old_cust_pkg ( @cust_pkgs ) {
2014 $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
2015 if ( $old_cust_pkg->getfield('cancel') ) {
2016 warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
2017 $old_cust_pkg->pkgnum."\n"
2021 warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
2023 my $error = $old_cust_pkg->change(
2024 'pkgpart' => $param->{'new_pkgpart'},
2025 'keep_dates' => $keep_dates
2027 if ( !ref($error) ) { # change returns the cust_pkg on success
2029 die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
2032 $dbh->commit if $oldAutoCommit;
2038 Returns the last bill date, or if there is no last bill date, the setup date.
2039 Useful for billing metered services.
2045 return $self->setfield('last_bill', $_[0]) if @_;
2046 return $self->getfield('last_bill') if $self->getfield('last_bill');
2047 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
2048 'edate' => $self->bill, } );
2049 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
2052 =item last_cust_pkg_reason ACTION
2054 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
2055 Returns false if there is no reason or the package is not currenly ACTION'd
2056 ACTION is one of adjourn, susp, cancel, or expire.
2060 sub last_cust_pkg_reason {
2061 my ( $self, $action ) = ( shift, shift );
2062 my $date = $self->get($action);
2064 'table' => 'cust_pkg_reason',
2065 'hashref' => { 'pkgnum' => $self->pkgnum,
2066 'action' => substr(uc($action), 0, 1),
2069 'order_by' => 'ORDER BY num DESC LIMIT 1',
2073 =item last_reason ACTION
2075 Returns the most recent ACTION FS::reason associated with the package.
2076 Returns false if there is no reason or the package is not currenly ACTION'd
2077 ACTION is one of adjourn, susp, cancel, or expire.
2082 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
2083 $cust_pkg_reason->reason
2084 if $cust_pkg_reason;
2089 Returns the definition for this billing item, as an FS::part_pkg object (see
2096 return $self->{'_pkgpart'} if $self->{'_pkgpart'};
2097 cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
2098 qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
2103 Returns the cancelled package this package was changed from, if any.
2109 return '' unless $self->change_pkgnum;
2110 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
2115 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
2122 $self->part_pkg->calc_setup($self, @_);
2127 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
2134 $self->part_pkg->calc_recur($self, @_);
2139 Calls the I<base_recur> of the FS::part_pkg object associated with this billing
2146 $self->part_pkg->base_recur($self, @_);
2151 Calls the I<calc_remain> of the FS::part_pkg object associated with this
2158 $self->part_pkg->calc_remain($self, @_);
2163 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
2170 $self->part_pkg->calc_cancel($self, @_);
2175 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
2181 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
2184 =item cust_pkg_detail [ DETAILTYPE ]
2186 Returns any customer package details for this package (see
2187 L<FS::cust_pkg_detail>).
2189 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
2193 sub cust_pkg_detail {
2195 my %hash = ( 'pkgnum' => $self->pkgnum );
2196 $hash{detailtype} = shift if @_;
2198 'table' => 'cust_pkg_detail',
2199 'hashref' => \%hash,
2200 'order_by' => 'ORDER BY weight, pkgdetailnum',
2204 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
2206 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
2208 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
2210 If there is an error, returns the error, otherwise returns false.
2214 sub set_cust_pkg_detail {
2215 my( $self, $detailtype, @details ) = @_;
2217 local $SIG{HUP} = 'IGNORE';
2218 local $SIG{INT} = 'IGNORE';
2219 local $SIG{QUIT} = 'IGNORE';
2220 local $SIG{TERM} = 'IGNORE';
2221 local $SIG{TSTP} = 'IGNORE';
2222 local $SIG{PIPE} = 'IGNORE';
2224 my $oldAutoCommit = $FS::UID::AutoCommit;
2225 local $FS::UID::AutoCommit = 0;
2228 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
2229 my $error = $current->delete;
2231 $dbh->rollback if $oldAutoCommit;
2232 return "error removing old detail: $error";
2236 foreach my $detail ( @details ) {
2237 my $cust_pkg_detail = new FS::cust_pkg_detail {
2238 'pkgnum' => $self->pkgnum,
2239 'detailtype' => $detailtype,
2240 'detail' => $detail,
2242 my $error = $cust_pkg_detail->insert;
2244 $dbh->rollback if $oldAutoCommit;
2245 return "error adding new detail: $error";
2250 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2257 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
2261 #false laziness w/cust_bill.pm
2265 'table' => 'cust_event',
2266 'addl_from' => 'JOIN part_event USING ( eventpart )',
2267 'hashref' => { 'tablenum' => $self->pkgnum },
2268 'extra_sql' => " AND eventtable = 'cust_pkg' ",
2272 =item num_cust_event
2274 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
2278 #false laziness w/cust_bill.pm
2279 sub num_cust_event {
2282 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
2283 " WHERE tablenum = ? AND eventtable = 'cust_pkg'";
2284 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
2285 $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
2286 $sth->fetchrow_arrayref->[0];
2289 =item cust_svc [ SVCPART ] (old, deprecated usage)
2291 =item cust_svc [ OPTION => VALUE ... ] (current usage)
2293 Returns the services for this package, as FS::cust_svc objects (see
2294 L<FS::cust_svc>). Available options are svcpart and svcdb. If either is
2295 spcififed, returns only the matching services.
2302 return () unless $self->num_cust_svc(@_);
2305 if ( @_ && $_[0] =~ /^\d+/ ) {
2306 $opt{svcpart} = shift;
2307 } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2314 'table' => 'cust_svc',
2315 'hashref' => { 'pkgnum' => $self->pkgnum },
2317 if ( $opt{svcpart} ) {
2318 $search{hashref}->{svcpart} = $opt{'svcpart'};
2320 if ( $opt{'svcdb'} ) {
2321 $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
2322 $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
2325 cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
2327 #if ( $self->{'_svcnum'} ) {
2328 # values %{ $self->{'_svcnum'}->cache };
2330 $self->_sort_cust_svc( [ qsearch(\%search) ] );
2335 =item overlimit [ SVCPART ]
2337 Returns the services for this package which have exceeded their
2338 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
2339 is specified, return only the matching services.
2345 return () unless $self->num_cust_svc(@_);
2346 grep { $_->overlimit } $self->cust_svc(@_);
2349 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2351 Returns historical services for this package created before END TIMESTAMP and
2352 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
2353 (see L<FS::h_cust_svc>). If MODE is 'I' (for 'invoice'), services with the
2354 I<pkg_svc.hidden> flag will be omitted.
2360 warn "$me _h_cust_svc called on $self\n"
2363 my ($end, $start, $mode) = @_;
2364 my @cust_svc = $self->_sort_cust_svc(
2365 [ qsearch( 'h_cust_svc',
2366 { 'pkgnum' => $self->pkgnum, },
2367 FS::h_cust_svc->sql_h_search(@_),
2370 if ( defined($mode) && $mode eq 'I' ) {
2371 my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
2372 return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
2378 sub _sort_cust_svc {
2379 my( $self, $arrayref ) = @_;
2382 sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] };
2387 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
2388 'svcpart' => $_->svcpart } );
2390 $pkg_svc ? $pkg_svc->primary_svc : '',
2391 $pkg_svc ? $pkg_svc->quantity : 0,
2398 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
2400 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
2402 Returns the number of services for this package. Available options are svcpart
2403 and svcdb. If either is spcififed, returns only the matching services.
2410 return $self->{'_num_cust_svc'}
2412 && exists($self->{'_num_cust_svc'})
2413 && $self->{'_num_cust_svc'} =~ /\d/;
2415 cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
2419 if ( @_ && $_[0] =~ /^\d+/ ) {
2420 $opt{svcpart} = shift;
2421 } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2427 my $select = 'SELECT COUNT(*) FROM cust_svc ';
2428 my $where = ' WHERE pkgnum = ? ';
2429 my @param = ($self->pkgnum);
2431 if ( $opt{'svcpart'} ) {
2432 $where .= ' AND svcpart = ? ';
2433 push @param, $opt{'svcpart'};
2435 if ( $opt{'svcdb'} ) {
2436 $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
2437 $where .= ' AND svcdb = ? ';
2438 push @param, $opt{'svcdb'};
2441 my $sth = dbh->prepare("$select $where") or die dbh->errstr;
2442 $sth->execute(@param) or die $sth->errstr;
2443 $sth->fetchrow_arrayref->[0];
2446 =item available_part_svc
2448 Returns a list of FS::part_svc objects representing services included in this
2449 package but not yet provisioned. Each FS::part_svc object also has an extra
2450 field, I<num_avail>, which specifies the number of available services.
2454 sub available_part_svc {
2457 my $pkg_quantity = $self->quantity || 1;
2459 grep { $_->num_avail > 0 }
2461 my $part_svc = $_->part_svc;
2462 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
2463 $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
2465 # more evil encapsulation breakage
2466 if($part_svc->{'Hash'}{'num_avail'} > 0) {
2467 my @exports = $part_svc->part_export_did;
2468 $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
2473 $self->part_pkg->pkg_svc;
2476 =item part_svc [ OPTION => VALUE ... ]
2478 Returns a list of FS::part_svc objects representing provisioned and available
2479 services included in this package. Each FS::part_svc object also has the
2480 following extra fields:
2484 =item num_cust_svc (count)
2486 =item num_avail (quantity - count)
2488 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
2492 Accepts one option: summarize_size. If specified and non-zero, will omit the
2493 extra cust_pkg_svc option for objects where num_cust_svc is this size or
2499 #label -> ($cust_svc->label)[1]
2505 my $pkg_quantity = $self->quantity || 1;
2507 #XXX some sort of sort order besides numeric by svcpart...
2508 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
2510 my $part_svc = $pkg_svc->part_svc;
2511 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2512 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
2513 $part_svc->{'Hash'}{'num_avail'} =
2514 max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
2515 $part_svc->{'Hash'}{'cust_pkg_svc'} =
2516 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
2517 unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
2518 && $num_cust_svc >= $opt{summarize_size};
2519 $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
2521 } $self->part_pkg->pkg_svc;
2524 push @part_svc, map {
2526 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2527 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
2528 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
2529 $part_svc->{'Hash'}{'cust_pkg_svc'} =
2530 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
2532 } $self->extra_part_svc;
2538 =item extra_part_svc
2540 Returns a list of FS::part_svc objects corresponding to services in this
2541 package which are still provisioned but not (any longer) available in the
2546 sub extra_part_svc {
2549 my $pkgnum = $self->pkgnum;
2550 #my $pkgpart = $self->pkgpart;
2553 # 'table' => 'part_svc',
2556 # "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
2557 # WHERE pkg_svc.svcpart = part_svc.svcpart
2558 # AND pkg_svc.pkgpart = ?
2561 # AND 0 < ( SELECT COUNT(*) FROM cust_svc
2562 # LEFT JOIN cust_pkg USING ( pkgnum )
2563 # WHERE cust_svc.svcpart = part_svc.svcpart
2566 # 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
2569 #seems to benchmark slightly faster... (or did?)
2571 my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
2572 my $pkgparts = join(',', @pkgparts);
2575 #'select' => 'DISTINCT ON (svcpart) part_svc.*',
2576 #MySQL doesn't grok DISINCT ON
2577 'select' => 'DISTINCT part_svc.*',
2578 'table' => 'part_svc',
2580 "LEFT JOIN pkg_svc ON ( pkg_svc.svcpart = part_svc.svcpart
2581 AND pkg_svc.pkgpart IN ($pkgparts)
2584 LEFT JOIN cust_svc ON ( cust_svc.svcpart = part_svc.svcpart )
2585 LEFT JOIN cust_pkg USING ( pkgnum )
2588 'extra_sql' => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
2589 'extra_param' => [ [$self->pkgnum=>'int'] ],
2595 Returns a short status string for this package, currently:
2599 =item not yet billed
2601 =item one-time charge
2616 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
2618 return 'cancelled' if $self->get('cancel');
2619 return 'suspended' if $self->susp;
2620 return 'not yet billed' unless $self->setup;
2621 return 'one-time charge' if $freq =~ /^(0|$)/;
2625 =item ucfirst_status
2627 Returns the status with the first character capitalized.
2631 sub ucfirst_status {
2632 ucfirst(shift->status);
2637 Class method that returns the list of possible status strings for packages
2638 (see L<the status method|/status>). For example:
2640 @statuses = FS::cust_pkg->statuses();
2644 tie my %statuscolor, 'Tie::IxHash',
2645 'not yet billed' => '009999', #teal? cyan?
2646 'one-time charge' => '000000',
2647 'active' => '00CC00',
2648 'suspended' => 'FF9900',
2649 'cancelled' => 'FF0000',
2653 my $self = shift; #could be class...
2654 #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
2655 # # mayble split btw one-time vs. recur
2661 Returns a hex triplet color string for this package's status.
2667 $statuscolor{$self->status};
2672 Returns a label for this package. (Currently "pkgnum: pkg - comment" or
2673 "pkg - comment" depending on user preference).
2679 my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
2680 $label = $self->pkgnum. ": $label"
2681 if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
2685 =item pkg_label_long
2687 Returns a long label for this package, adding the primary service's label to
2692 sub pkg_label_long {
2694 my $label = $self->pkg_label;
2695 my $cust_svc = $self->primary_cust_svc;
2696 $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
2702 Returns a customer-localized label for this package.
2708 $self->part_pkg->pkg_locale( $self->cust_main->locale );
2711 =item primary_cust_svc
2713 Returns a primary service (as FS::cust_svc object) if one can be identified.
2717 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
2719 sub primary_cust_svc {
2722 my @cust_svc = $self->cust_svc;
2724 return '' unless @cust_svc; #no serivces - irrelevant then
2726 return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
2728 # primary service as specified in the package definition
2729 # or exactly one service definition with quantity one
2730 my $svcpart = $self->part_pkg->svcpart;
2731 @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
2732 return $cust_svc[0] if scalar(@cust_svc) == 1;
2734 #couldn't identify one thing..
2740 Returns a list of lists, calling the label method for all services
2741 (see L<FS::cust_svc>) of this billing item.
2747 map { [ $_->label ] } $self->cust_svc;
2750 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2752 Like the labels method, but returns historical information on services that
2753 were active as of END_TIMESTAMP and (optionally) not cancelled before
2754 START_TIMESTAMP. If MODE is 'I' (for 'invoice'), services with the
2755 I<pkg_svc.hidden> flag will be omitted.
2757 Returns a list of lists, calling the label method for all (historical) services
2758 (see L<FS::h_cust_svc>) of this billing item.
2764 warn "$me _h_labels called on $self\n"
2766 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
2771 Like labels, except returns a simple flat list, and shortens long
2772 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2773 identical services to one line that lists the service label and the number of
2774 individual services rather than individual items.
2779 shift->_labels_short( 'labels', @_ );
2782 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
2784 Like h_labels, except returns a simple flat list, and shortens long
2785 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2786 identical services to one line that lists the service label and the number of
2787 individual services rather than individual items.
2791 sub h_labels_short {
2792 shift->_labels_short( 'h_labels', @_ );
2796 my( $self, $method ) = ( shift, shift );
2798 warn "$me _labels_short called on $self with $method method\n"
2801 my $conf = new FS::Conf;
2802 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
2804 warn "$me _labels_short populating \%labels\n"
2808 #tie %labels, 'Tie::IxHash';
2809 push @{ $labels{$_->[0]} }, $_->[1]
2810 foreach $self->$method(@_);
2812 warn "$me _labels_short populating \@labels\n"
2816 foreach my $label ( keys %labels ) {
2818 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
2819 my $num = scalar(@values);
2820 warn "$me _labels_short $num items for $label\n"
2823 if ( $num > $max_same_services ) {
2824 warn "$me _labels_short more than $max_same_services, so summarizing\n"
2826 push @labels, "$label ($num)";
2828 if ( $conf->exists('cust_bill-consolidate_services') ) {
2829 warn "$me _labels_short consolidating services\n"
2831 # push @labels, "$label: ". join(', ', @values);
2833 my $detail = "$label: ";
2834 $detail .= shift(@values). ', '
2836 && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
2838 push @labels, $detail;
2840 warn "$me _labels_short done consolidating services\n"
2843 warn "$me _labels_short adding service data\n"
2845 push @labels, map { "$label: $_" } @values;
2856 Returns the parent customer object (see L<FS::cust_main>).
2862 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
2867 Returns the balance for this specific package, when using
2868 experimental package balance.
2874 $self->cust_main->balance_pkgnum( $self->pkgnum );
2877 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
2881 Returns the location object, if any (see L<FS::cust_location>).
2883 =item cust_location_or_main
2885 If this package is associated with a location, returns the locaiton (see
2886 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
2888 =item location_label [ OPTION => VALUE ... ]
2890 Returns the label of the location object (see L<FS::cust_location>).
2894 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
2896 =item tax_locationnum
2898 Returns the foreign key to a L<FS::cust_location> object for calculating
2899 tax on this package, as determined by the C<tax-pkg_address> and
2900 C<tax-ship_address> configuration flags.
2904 sub tax_locationnum {
2906 my $conf = FS::Conf->new;
2907 if ( $conf->exists('tax-pkg_address') ) {
2908 return $self->locationnum;
2910 elsif ( $conf->exists('tax-ship_address') ) {
2911 return $self->cust_main->ship_locationnum;
2914 return $self->cust_main->bill_locationnum;
2920 Returns the L<FS::cust_location> object for tax_locationnum.
2926 FS::cust_location->by_key( $self->tax_locationnum )
2929 =item seconds_since TIMESTAMP
2931 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
2932 package have been online since TIMESTAMP, according to the session monitor.
2934 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
2935 L<Time::Local> and L<Date::Parse> for conversion functions.
2940 my($self, $since) = @_;
2943 foreach my $cust_svc (
2944 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
2946 $seconds += $cust_svc->seconds_since($since);
2953 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
2955 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
2956 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
2959 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2960 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
2966 sub seconds_since_sqlradacct {
2967 my($self, $start, $end) = @_;
2971 foreach my $cust_svc (
2973 my $part_svc = $_->part_svc;
2974 $part_svc->svcdb eq 'svc_acct'
2975 && scalar($part_svc->part_export_usage);
2978 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
2985 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
2987 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
2988 in this package for sessions ending between TIMESTAMP_START (inclusive) and
2992 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2993 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
2998 sub attribute_since_sqlradacct {
2999 my($self, $start, $end, $attrib) = @_;
3003 foreach my $cust_svc (
3005 my $part_svc = $_->part_svc;
3006 $part_svc->svcdb eq 'svc_acct'
3007 && scalar($part_svc->part_export_usage);
3010 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
3022 my( $self, $value ) = @_;
3023 if ( defined($value) ) {
3024 $self->setfield('quantity', $value);
3026 $self->getfield('quantity') || 1;
3029 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
3031 Transfers as many services as possible from this package to another package.
3033 The destination package can be specified by pkgnum by passing an FS::cust_pkg
3034 object. The destination package must already exist.
3036 Services are moved only if the destination allows services with the correct
3037 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
3038 this option with caution! No provision is made for export differences
3039 between the old and new service definitions. Probably only should be used
3040 when your exports for all service definitions of a given svcdb are identical.
3041 (attempt a transfer without it first, to move all possible svcpart-matching
3044 Any services that can't be moved remain in the original package.
3046 Returns an error, if there is one; otherwise, returns the number of services
3047 that couldn't be moved.
3052 my ($self, $dest_pkgnum, %opt) = @_;
3058 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
3059 $dest = $dest_pkgnum;
3060 $dest_pkgnum = $dest->pkgnum;
3062 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
3065 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
3067 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
3068 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
3071 foreach my $cust_svc ($dest->cust_svc) {
3072 $target{$cust_svc->svcpart}--;
3075 my %svcpart2svcparts = ();
3076 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3077 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
3078 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
3079 next if exists $svcpart2svcparts{$svcpart};
3080 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
3081 $svcpart2svcparts{$svcpart} = [
3083 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
3085 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
3086 'svcpart' => $_ } );
3088 $pkg_svc ? $pkg_svc->primary_svc : '',
3089 $pkg_svc ? $pkg_svc->quantity : 0,
3093 grep { $_ != $svcpart }
3095 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
3097 warn "alternates for svcpart $svcpart: ".
3098 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
3103 foreach my $cust_svc ($self->cust_svc) {
3104 if($target{$cust_svc->svcpart} > 0
3105 or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
3106 $target{$cust_svc->svcpart}--;
3107 my $new = new FS::cust_svc { $cust_svc->hash };
3108 $new->pkgnum($dest_pkgnum);
3109 my $error = $new->replace($cust_svc);
3110 return $error if $error;
3111 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3113 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
3114 warn "alternates to consider: ".
3115 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
3117 my @alternate = grep {
3118 warn "considering alternate svcpart $_: ".
3119 "$target{$_} available in new package\n"
3122 } @{$svcpart2svcparts{$cust_svc->svcpart}};
3124 warn "alternate(s) found\n" if $DEBUG;
3125 my $change_svcpart = $alternate[0];
3126 $target{$change_svcpart}--;
3127 my $new = new FS::cust_svc { $cust_svc->hash };
3128 $new->svcpart($change_svcpart);
3129 $new->pkgnum($dest_pkgnum);
3130 my $error = $new->replace($cust_svc);
3131 return $error if $error;
3144 This method is deprecated. See the I<depend_jobnum> option to the insert and
3145 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
3152 local $SIG{HUP} = 'IGNORE';
3153 local $SIG{INT} = 'IGNORE';
3154 local $SIG{QUIT} = 'IGNORE';
3155 local $SIG{TERM} = 'IGNORE';
3156 local $SIG{TSTP} = 'IGNORE';
3157 local $SIG{PIPE} = 'IGNORE';
3159 my $oldAutoCommit = $FS::UID::AutoCommit;
3160 local $FS::UID::AutoCommit = 0;
3163 foreach my $cust_svc ( $self->cust_svc ) {
3164 #false laziness w/svc_Common::insert
3165 my $svc_x = $cust_svc->svc_x;
3166 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
3167 my $error = $part_export->export_insert($svc_x);
3169 $dbh->rollback if $oldAutoCommit;
3175 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3182 Associates this package with a (suspension or cancellation) reason (see
3183 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
3186 Available options are:
3192 can be set to a cancellation reason (see L<FS:reason>), 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<FS::reason_type>, reason - Text of the new reason.
3196 the access_user (see L<FS::access_user>) providing the reason
3204 the action (cancel, susp, adjourn, expire) associated with the reason
3208 If there is an error, returns the error, otherwise returns false.
3213 my ($self, %options) = @_;
3215 my $otaker = $options{reason_otaker} ||
3216 $FS::CurrentUser::CurrentUser->username;
3219 if ( $options{'reason'} =~ /^(\d+)$/ ) {
3223 } elsif ( ref($options{'reason'}) ) {
3225 return 'Enter a new reason (or select an existing one)'
3226 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
3228 my $reason = new FS::reason({
3229 'reason_type' => $options{'reason'}->{'typenum'},
3230 'reason' => $options{'reason'}->{'reason'},
3232 my $error = $reason->insert;
3233 return $error if $error;
3235 $reasonnum = $reason->reasonnum;
3238 return "Unparsable reason: ". $options{'reason'};
3241 my $cust_pkg_reason =
3242 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
3243 'reasonnum' => $reasonnum,
3244 'otaker' => $otaker,
3245 'action' => substr(uc($options{'action'}),0,1),
3246 'date' => $options{'date'}
3251 $cust_pkg_reason->insert;
3254 =item insert_discount
3256 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
3257 inserting a new discount on the fly (see L<FS::discount>).
3259 Available options are:
3267 If there is an error, returns the error, otherwise returns false.
3271 sub insert_discount {
3272 #my ($self, %options) = @_;
3275 my $cust_pkg_discount = new FS::cust_pkg_discount {
3276 'pkgnum' => $self->pkgnum,
3277 'discountnum' => $self->discountnum,
3279 'end_date' => '', #XXX
3280 #for the create a new discount case
3281 '_type' => $self->discountnum__type,
3282 'amount' => $self->discountnum_amount,
3283 'percent' => $self->discountnum_percent,
3284 'months' => $self->discountnum_months,
3285 'setup' => $self->discountnum_setup,
3286 #'disabled' => $self->discountnum_disabled,
3289 $cust_pkg_discount->insert;
3292 =item set_usage USAGE_VALUE_HASHREF
3294 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3295 to which they should be set (see L<FS::svc_acct>). Currently seconds,
3296 upbytes, downbytes, and totalbytes are appropriate keys.
3298 All svc_accts which are part of this package have their values reset.
3303 my ($self, $valueref, %opt) = @_;
3305 #only svc_acct can set_usage for now
3306 foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3307 my $svc_x = $cust_svc->svc_x;
3308 $svc_x->set_usage($valueref, %opt)
3309 if $svc_x->can("set_usage");
3313 =item recharge USAGE_VALUE_HASHREF
3315 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3316 to which they should be set (see L<FS::svc_acct>). Currently seconds,
3317 upbytes, downbytes, and totalbytes are appropriate keys.
3319 All svc_accts which are part of this package have their values incremented.
3324 my ($self, $valueref) = @_;
3326 #only svc_acct can set_usage for now
3327 foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3328 my $svc_x = $cust_svc->svc_x;
3329 $svc_x->recharge($valueref)
3330 if $svc_x->can("recharge");
3334 =item cust_pkg_discount
3338 sub cust_pkg_discount {
3340 qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
3343 =item cust_pkg_discount_active
3347 sub cust_pkg_discount_active {
3349 grep { $_->status eq 'active' } $self->cust_pkg_discount;
3352 =item cust_pkg_usage
3354 Returns a list of all voice usage counters attached to this package.
3358 sub cust_pkg_usage {
3360 qsearch('cust_pkg_usage', { pkgnum => $self->pkgnum });
3363 =item apply_usage OPTIONS
3365 Takes the following options:
3366 - cdr: a call detail record (L<FS::cdr>)
3367 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
3368 - minutes: the maximum number of minutes to be charged
3370 Finds available usage minutes for a call of this class, and subtracts
3371 up to that many minutes from the usage pool. If the usage pool is empty,
3372 and the C<cdr-minutes_priority> global config option is set, minutes may
3373 be taken from other calls as well. Either way, an allocation record will
3374 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the
3375 number of minutes of usage applied to the call.
3380 my ($self, %opt) = @_;
3381 my $cdr = $opt{cdr};
3382 my $rate_detail = $opt{rate_detail};
3383 my $minutes = $opt{minutes};
3384 my $classnum = $rate_detail->classnum;
3385 my $pkgnum = $self->pkgnum;
3386 my $custnum = $self->custnum;
3388 local $SIG{HUP} = 'IGNORE';
3389 local $SIG{INT} = 'IGNORE';
3390 local $SIG{QUIT} = 'IGNORE';
3391 local $SIG{TERM} = 'IGNORE';
3392 local $SIG{TSTP} = 'IGNORE';
3393 local $SIG{PIPE} = 'IGNORE';
3395 my $oldAutoCommit = $FS::UID::AutoCommit;
3396 local $FS::UID::AutoCommit = 0;
3398 my $order = FS::Conf->new->config('cdr-minutes_priority');
3402 $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
3404 $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
3406 my @usage_recs = qsearch({
3407 'table' => 'cust_pkg_usage',
3408 'addl_from' => ' JOIN part_pkg_usage USING (pkgusagepart)'.
3409 ' JOIN cust_pkg USING (pkgnum)'.
3410 ' JOIN part_pkg_usage_class USING (pkgusagepart)',
3411 'select' => 'cust_pkg_usage.*',
3412 'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
3413 " ( cust_pkg.custnum = $custnum AND ".
3414 " part_pkg_usage.shared IS NOT NULL ) ) AND ".
3415 $is_classnum . ' AND '.
3416 " cust_pkg_usage.minutes > 0",
3417 'order_by' => " ORDER BY priority ASC",
3420 my $orig_minutes = $minutes;
3422 while (!$error and $minutes > 0 and @usage_recs) {
3423 my $cust_pkg_usage = shift @usage_recs;
3424 $cust_pkg_usage->select_for_update;
3425 my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
3426 pkgusagenum => $cust_pkg_usage->pkgusagenum,
3427 acctid => $cdr->acctid,
3428 minutes => min($cust_pkg_usage->minutes, $minutes),
3430 $cust_pkg_usage->set('minutes',
3431 sprintf('%.0f', $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes)
3433 $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
3434 $minutes -= $cdr_cust_pkg_usage->minutes;
3436 if ( $order and $minutes > 0 and !$error ) {
3437 # then try to steal minutes from another call
3439 'table' => 'cdr_cust_pkg_usage',
3440 'addl_from' => ' JOIN cust_pkg_usage USING (pkgusagenum)'.
3441 ' JOIN part_pkg_usage USING (pkgusagepart)'.
3442 ' JOIN cust_pkg USING (pkgnum)'.
3443 ' JOIN part_pkg_usage_class USING (pkgusagepart)'.
3444 ' JOIN cdr USING (acctid)',
3445 'select' => 'cdr_cust_pkg_usage.*',
3446 'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
3447 " ( cust_pkg.pkgnum = $pkgnum OR ".
3448 " ( cust_pkg.custnum = $custnum AND ".
3449 " part_pkg_usage.shared IS NOT NULL ) ) AND ".
3450 " part_pkg_usage_class.classnum = $classnum",
3451 'order_by' => ' ORDER BY part_pkg_usage.priority ASC',
3453 if ( $order eq 'time' ) {
3454 # find CDRs that are using minutes, but have a later startdate
3456 my $startdate = $cdr->startdate;
3457 if ($startdate !~ /^\d+$/) {
3458 die "bad cdr startdate '$startdate'";
3460 $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
3461 # minimize needless reshuffling
3462 $search{'order_by'} .= ', cdr.startdate DESC';
3464 # XXX may not work correctly with rate_time schedules. Could
3465 # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I
3467 $search{'addl_from'} .=
3468 ' JOIN rate_detail'.
3469 ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
3470 if ( $order eq 'rate_high' ) {
3471 $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
3472 $rate_detail->min_charge;
3473 $search{'order_by'} .= ', rate_detail.min_charge ASC';
3474 } elsif ( $order eq 'rate_low' ) {
3475 $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
3476 $rate_detail->min_charge;
3477 $search{'order_by'} .= ', rate_detail.min_charge DESC';
3479 # this should really never happen
3480 die "invalid cdr-minutes_priority value '$order'\n";
3483 my @cdr_usage_recs = qsearch(\%search);
3485 while (!$error and @cdr_usage_recs and $minutes > 0) {
3486 my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
3487 my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
3488 my $old_cdr = $cdr_cust_pkg_usage->cdr;
3489 $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
3490 $cdr_cust_pkg_usage->select_for_update;
3491 $old_cdr->select_for_update;
3492 $cust_pkg_usage->select_for_update;
3493 # in case someone else stole the usage from this CDR
3494 # while waiting for the lock...
3495 next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
3496 # steal the usage allocation and flag the old CDR for reprocessing
3497 $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
3498 # if the allocation is more minutes than we need, adjust it...
3499 my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
3501 $cdr_cust_pkg_usage->set('minutes', $minutes);
3502 $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
3503 $error = $cust_pkg_usage->replace;
3505 #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
3506 $error ||= $cdr_cust_pkg_usage->replace;
3507 # deduct the stolen minutes
3508 $minutes -= $cdr_cust_pkg_usage->minutes;
3510 # after all minute-stealing is done, reset the affected CDRs
3511 foreach (values %reproc_cdrs) {
3512 $error ||= $_->set_status('');
3513 # XXX or should we just call $cdr->rate right here?
3514 # it's not like we can create a loop this way, since the min_charge
3515 # or call time has to go monotonically in one direction.
3516 # we COULD get some very deep recursions going, though...
3518 } # if $order and $minutes
3521 die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
3523 $dbh->commit if $oldAutoCommit;
3524 return $orig_minutes - $minutes;
3528 =item supplemental_pkgs
3530 Returns a list of all packages supplemental to this one.
3534 sub supplemental_pkgs {
3536 qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
3541 Returns the package that this one is supplemental to, if any.
3547 if ( $self->main_pkgnum ) {
3548 return FS::cust_pkg->by_key($self->main_pkgnum);
3555 =head1 CLASS METHODS
3561 Returns an SQL expression identifying recurring packages.
3565 sub recurring_sql { "
3566 '0' != ( select freq from part_pkg
3567 where cust_pkg.pkgpart = part_pkg.pkgpart )
3572 Returns an SQL expression identifying one-time packages.
3577 '0' = ( select freq from part_pkg
3578 where cust_pkg.pkgpart = part_pkg.pkgpart )
3583 Returns an SQL expression identifying ordered packages (recurring packages not
3589 $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
3594 Returns an SQL expression identifying active packages.
3599 $_[0]->recurring_sql. "
3600 AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
3601 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3602 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
3605 =item not_yet_billed_sql
3607 Returns an SQL expression identifying packages which have not yet been billed.
3611 sub not_yet_billed_sql { "
3612 ( cust_pkg.setup IS NULL OR cust_pkg.setup = 0 )
3613 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3614 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
3619 Returns an SQL expression identifying inactive packages (one-time packages
3620 that are otherwise unsuspended/uncancelled).
3624 sub inactive_sql { "
3625 ". $_[0]->onetime_sql(). "
3626 AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
3627 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3628 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
3634 Returns an SQL expression identifying suspended packages.
3638 sub suspended_sql { susp_sql(@_); }
3640 #$_[0]->recurring_sql(). ' AND '.
3642 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3643 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
3650 Returns an SQL exprression identifying cancelled packages.
3654 sub cancelled_sql { cancel_sql(@_); }
3656 #$_[0]->recurring_sql(). ' AND '.
3657 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
3662 Returns an SQL expression to give the package status as a string.
3668 WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
3669 WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
3670 WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
3671 WHEN ".onetime_sql()." THEN 'one-time charge'
3676 =item search HASHREF
3680 Returns a qsearch hash expression to search for parameters specified in HASHREF.
3681 Valid parameters are
3689 active, inactive, suspended, cancel (or cancelled)
3693 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
3697 boolean selects custom packages
3703 pkgpart or arrayref or hashref of pkgparts
3707 arrayref of beginning and ending epoch date
3711 arrayref of beginning and ending epoch date
3715 arrayref of beginning and ending epoch date
3719 arrayref of beginning and ending epoch date
3723 arrayref of beginning and ending epoch date
3727 arrayref of beginning and ending epoch date
3731 arrayref of beginning and ending epoch date
3735 pkgnum or APKG_pkgnum
3739 a value suited to passing to FS::UI::Web::cust_header
3743 specifies the user for agent virtualization
3747 boolean; if true, returns only packages with more than 0 FCC phone lines.
3749 =item state, country
3751 Limit to packages with a service location in the specified state and country.
3752 For FCC 477 reporting, mostly.
3759 my ($class, $params) = @_;
3766 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
3768 "cust_main.agentnum = $1";
3775 if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
3777 "cust_pkg.custnum = $1";
3784 if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
3786 "cust_pkg.pkgbatch = '$1'";
3793 if ( $params->{'magic'} eq 'active'
3794 || $params->{'status'} eq 'active' ) {
3796 push @where, FS::cust_pkg->active_sql();
3798 } elsif ( $params->{'magic'} =~ /^not[ _]yet[ _]billed$/
3799 || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
3801 push @where, FS::cust_pkg->not_yet_billed_sql();
3803 } elsif ( $params->{'magic'} =~ /^(one-time charge|inactive)/
3804 || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
3806 push @where, FS::cust_pkg->inactive_sql();
3808 } elsif ( $params->{'magic'} eq 'suspended'
3809 || $params->{'status'} eq 'suspended' ) {
3811 push @where, FS::cust_pkg->suspended_sql();
3813 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
3814 || $params->{'status'} =~ /^cancell?ed$/ ) {
3816 push @where, FS::cust_pkg->cancelled_sql();
3821 # parse package class
3824 if ( exists($params->{'classnum'}) ) {
3827 if ( ref($params->{'classnum'}) ) {
3829 if ( ref($params->{'classnum'}) eq 'HASH' ) {
3830 @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
3831 } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
3832 @classnum = @{ $params->{'classnum'} };
3834 die 'unhandled classnum ref '. $params->{'classnum'};
3838 } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
3845 my @nums = grep $_, @classnum;
3846 push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
3847 my $null = scalar( grep { $_ eq '' } @classnum );
3848 push @c_where, 'part_pkg.classnum IS NULL' if $null;
3850 if ( scalar(@c_where) == 1 ) {
3851 push @where, @c_where;
3852 } elsif ( @c_where ) {
3853 push @where, ' ( '. join(' OR ', @c_where). ' ) ';
3862 # parse package report options
3865 my @report_option = ();
3866 if ( exists($params->{'report_option'}) ) {
3867 if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
3868 @report_option = @{ $params->{'report_option'} };
3869 } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
3870 @report_option = split(',', $1);
3875 if (@report_option) {
3876 # this will result in the empty set for the dangling comma case as it should
3878 map{ "0 < ( SELECT count(*) FROM part_pkg_option
3879 WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3880 AND optionname = 'report_option_$_'
3881 AND optionvalue = '1' )"
3885 foreach my $any ( grep /^report_option_any/, keys %$params ) {
3887 my @report_option_any = ();
3888 if ( ref($params->{$any}) eq 'ARRAY' ) {
3889 @report_option_any = @{ $params->{$any} };
3890 } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
3891 @report_option_any = split(',', $1);
3894 if (@report_option_any) {
3895 # this will result in the empty set for the dangling comma case as it should
3896 push @where, ' ( '. join(' OR ',
3897 map{ "0 < ( SELECT count(*) FROM part_pkg_option
3898 WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3899 AND optionname = 'report_option_$_'
3900 AND optionvalue = '1' )"
3901 } @report_option_any
3911 push @where, "part_pkg.custom = 'Y'" if $params->{custom};
3917 push @where, "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)"
3918 if $params->{fcc_line};
3924 if ( exists($params->{'censustract'}) ) {
3925 $params->{'censustract'} =~ /^([.\d]*)$/;
3926 my $censustract = "cust_location.censustract = '$1'";
3927 $censustract .= ' OR cust_location.censustract is NULL' unless $1;
3928 push @where, "( $censustract )";
3932 # parse censustract2
3934 if ( exists($params->{'censustract2'})
3935 && $params->{'censustract2'} =~ /^(\d*)$/
3939 push @where, "cust_location.censustract LIKE '$1%'";
3942 "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
3947 # parse country/state
3949 for (qw(state country)) { # parsing rules are the same for these
3950 if ( exists($params->{$_})
3951 && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
3953 # XXX post-2.3 only--before that, state/country may be in cust_main
3954 push @where, "cust_location.$_ = '$1'";
3962 if ( ref($params->{'pkgpart'}) ) {
3965 if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
3966 @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
3967 } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
3968 @pkgpart = @{ $params->{'pkgpart'} };
3970 die 'unhandled pkgpart ref '. $params->{'pkgpart'};
3973 @pkgpart = grep /^(\d+)$/, @pkgpart;
3975 push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
3977 } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
3978 push @where, "pkgpart = $1";
3987 #false laziness w/report_cust_pkg.html
3990 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
3991 'active' => { 'susp'=>1, 'cancel'=>1 },
3992 'suspended' => { 'cancel' => 1 },
3997 if( exists($params->{'active'} ) ) {
3998 # This overrides all the other date-related fields
3999 my($beginning, $ending) = @{$params->{'active'}};
4001 "cust_pkg.setup IS NOT NULL",
4002 "cust_pkg.setup <= $ending",
4003 "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
4004 "NOT (".FS::cust_pkg->onetime_sql . ")";
4007 foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
4009 next unless exists($params->{$field});
4011 my($beginning, $ending) = @{$params->{$field}};
4013 next if $beginning == 0 && $ending == 4294967295;
4016 "cust_pkg.$field IS NOT NULL",
4017 "cust_pkg.$field >= $beginning",
4018 "cust_pkg.$field <= $ending";
4020 $orderby ||= "ORDER BY cust_pkg.$field";
4025 $orderby ||= 'ORDER BY bill';
4028 # parse magic, legacy, etc.
4031 if ( $params->{'magic'} &&
4032 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
4035 $orderby = 'ORDER BY pkgnum';
4037 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
4038 push @where, "pkgpart = $1";
4041 } elsif ( $params->{'query'} eq 'pkgnum' ) {
4043 $orderby = 'ORDER BY pkgnum';
4045 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
4047 $orderby = 'ORDER BY pkgnum';
4050 SELECT count(*) FROM pkg_svc
4051 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
4052 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
4053 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
4054 AND cust_svc.svcpart = pkg_svc.svcpart
4061 # setup queries, links, subs, etc. for the search
4064 # here is the agent virtualization
4065 if ($params->{CurrentUser}) {
4067 qsearchs('access_user', { username => $params->{CurrentUser} });
4070 push @where, $access_user->agentnums_sql('table'=>'cust_main');
4075 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
4078 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
4080 my $addl_from = 'LEFT JOIN part_pkg USING ( pkgpart ) '.
4081 'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
4082 'LEFT JOIN cust_location USING ( locationnum ) '.
4083 FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
4087 if ( $params->{'select_zip5'} ) {
4088 my $zip = 'cust_location.zip';
4090 $select = "DISTINCT substr($zip,1,5) as zip";
4091 $orderby = "ORDER BY substr($zip,1,5)";
4092 $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
4094 $select = join(', ',
4096 ( map "part_pkg.$_", qw( pkg freq ) ),
4097 'pkg_class.classname',
4098 'cust_main.custnum AS cust_main_custnum',
4099 FS::UI::Web::cust_sql_fields(
4100 $params->{'cust_fields'}
4103 $count_query = 'SELECT COUNT(*)';
4106 $count_query .= " FROM cust_pkg $addl_from $extra_sql";
4109 'table' => 'cust_pkg',
4111 'select' => $select,
4112 'extra_sql' => $extra_sql,
4113 'order_by' => $orderby,
4114 'addl_from' => $addl_from,
4115 'count_query' => $count_query,
4122 Returns a list of two package counts. The first is a count of packages
4123 based on the supplied criteria and the second is the count of residential
4124 packages with those same criteria. Criteria are specified as in the search
4130 my ($class, $params) = @_;
4132 my $sql_query = $class->search( $params );
4134 my $count_sql = delete($sql_query->{'count_query'});
4135 $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
4136 or die "couldn't parse count_sql";
4138 my $count_sth = dbh->prepare($count_sql)
4139 or die "Error preparing $count_sql: ". dbh->errstr;
4141 or die "Error executing $count_sql: ". $count_sth->errstr;
4142 my $count_arrayref = $count_sth->fetchrow_arrayref;
4144 return ( @$count_arrayref );
4148 =item tax_locationnum_sql
4150 Returns an SQL expression for the tax location for a package, based
4151 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
4155 sub tax_locationnum_sql {
4156 my $conf = FS::Conf->new;
4157 if ( $conf->exists('tax-pkg_address') ) {
4158 'cust_pkg.locationnum';
4160 elsif ( $conf->exists('tax-ship_address') ) {
4161 'cust_main.ship_locationnum';
4164 'cust_main.bill_locationnum';
4170 Returns a list: the first item is an SQL fragment identifying matching
4171 packages/customers via location (taking into account shipping and package
4172 address taxation, if enabled), and subsequent items are the parameters to
4173 substitute for the placeholders in that fragment.
4178 my($class, %opt) = @_;
4179 my $ornull = $opt{'ornull'};
4181 my $conf = new FS::Conf;
4183 # '?' placeholders in _location_sql_where
4184 my $x = $ornull ? 3 : 2;
4195 if ( $conf->exists('tax-ship_address') ) {
4198 ( ( ship_last IS NULL OR ship_last = '' )
4199 AND ". _location_sql_where('cust_main', '', $ornull ). "
4201 OR ( ship_last IS NOT NULL AND ship_last != ''
4202 AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
4205 # AND payby != 'COMP'
4207 @main_param = ( @bill_param, @bill_param );
4211 $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
4212 @main_param = @bill_param;
4218 if ( $conf->exists('tax-pkg_address') ) {
4220 my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
4223 ( cust_pkg.locationnum IS NULL AND $main_where )
4224 OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where )
4227 @param = ( @main_param, @bill_param );
4231 $where = $main_where;
4232 @param = @main_param;
4240 #subroutine, helper for location_sql
4241 sub _location_sql_where {
4243 my $prefix = @_ ? shift : '';
4244 my $ornull = @_ ? shift : '';
4246 # $ornull = $ornull ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
4248 $ornull = $ornull ? ' OR ? IS NULL ' : '';
4250 my $or_empty_city = " OR ( ? = '' AND $table.${prefix}city IS NULL )";
4251 my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL )";
4252 my $or_empty_state = " OR ( ? = '' AND $table.${prefix}state IS NULL )";
4254 my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
4256 # ( $table.${prefix}city = ? $or_empty_city $ornull )
4258 ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4259 AND ( $table.${prefix}city = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4260 AND ( $table.${prefix}county = ? $or_empty_county $ornull )
4261 AND ( $table.${prefix}state = ? $or_empty_state $ornull )
4262 AND $table.${prefix}country = ?
4267 my( $self, $what ) = @_;
4269 my $what_show_zero = $what. '_show_zero';
4270 length($self->$what_show_zero())
4271 ? ($self->$what_show_zero() eq 'Y')
4272 : $self->part_pkg->$what_show_zero();
4279 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
4281 CUSTNUM is a customer (see L<FS::cust_main>)
4283 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4284 L<FS::part_pkg>) to order for this customer. Duplicates are of course
4287 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
4288 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
4289 new billing items. An error is returned if this is not possible (see
4290 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
4293 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
4294 newly-created cust_pkg objects.
4296 REFNUM, if specified, will specify the FS::pkg_referral record to be created
4297 and inserted. Multiple FS::pkg_referral records can be created by
4298 setting I<refnum> to an array reference of refnums or a hash reference with
4299 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
4300 record will be created corresponding to cust_main.refnum.
4305 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
4307 my $conf = new FS::Conf;
4309 # Transactionize this whole mess
4310 local $SIG{HUP} = 'IGNORE';
4311 local $SIG{INT} = 'IGNORE';
4312 local $SIG{QUIT} = 'IGNORE';
4313 local $SIG{TERM} = 'IGNORE';
4314 local $SIG{TSTP} = 'IGNORE';
4315 local $SIG{PIPE} = 'IGNORE';
4317 my $oldAutoCommit = $FS::UID::AutoCommit;
4318 local $FS::UID::AutoCommit = 0;
4322 # my $cust_main = qsearchs('cust_main', { custnum => $custnum });
4323 # return "Customer not found: $custnum" unless $cust_main;
4325 warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
4328 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
4331 my $change = scalar(@old_cust_pkg) != 0;
4334 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
4336 warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
4337 " to pkgpart ". $pkgparts->[0]. "\n"
4340 my $err_or_cust_pkg =
4341 $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
4342 'refnum' => $refnum,
4345 unless (ref($err_or_cust_pkg)) {
4346 $dbh->rollback if $oldAutoCommit;
4347 return $err_or_cust_pkg;
4350 push @$return_cust_pkg, $err_or_cust_pkg;
4351 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4356 # Create the new packages.
4357 foreach my $pkgpart (@$pkgparts) {
4359 warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
4361 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
4362 pkgpart => $pkgpart,
4366 $error = $cust_pkg->insert( 'change' => $change );
4367 push @$return_cust_pkg, $cust_pkg;
4369 foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
4370 my $supp_pkg = FS::cust_pkg->new({
4371 custnum => $custnum,
4372 pkgpart => $link->dst_pkgpart,
4374 main_pkgnum => $cust_pkg->pkgnum,
4377 $error ||= $supp_pkg->insert( 'change' => $change );
4378 push @$return_cust_pkg, $supp_pkg;
4382 $dbh->rollback if $oldAutoCommit;
4387 # $return_cust_pkg now contains refs to all of the newly
4390 # Transfer services and cancel old packages.
4391 foreach my $old_pkg (@old_cust_pkg) {
4393 warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
4396 foreach my $new_pkg (@$return_cust_pkg) {
4397 $error = $old_pkg->transfer($new_pkg);
4398 if ($error and $error == 0) {
4399 # $old_pkg->transfer failed.
4400 $dbh->rollback if $oldAutoCommit;
4405 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
4406 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
4407 foreach my $new_pkg (@$return_cust_pkg) {
4408 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
4409 if ($error and $error == 0) {
4410 # $old_pkg->transfer failed.
4411 $dbh->rollback if $oldAutoCommit;
4418 # Transfers were successful, but we went through all of the
4419 # new packages and still had services left on the old package.
4420 # We can't cancel the package under the circumstances, so abort.
4421 $dbh->rollback if $oldAutoCommit;
4422 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
4424 $error = $old_pkg->cancel( quiet=>1 );
4430 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4434 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
4436 A bulk change method to change packages for multiple customers.
4438 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4439 L<FS::part_pkg>) to order for each customer. Duplicates are of course
4442 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
4443 replace. The services (see L<FS::cust_svc>) are moved to the
4444 new billing items. An error is returned if this is not possible (see
4447 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
4448 newly-created cust_pkg objects.
4453 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
4455 # Transactionize this whole mess
4456 local $SIG{HUP} = 'IGNORE';
4457 local $SIG{INT} = 'IGNORE';
4458 local $SIG{QUIT} = 'IGNORE';
4459 local $SIG{TERM} = 'IGNORE';
4460 local $SIG{TSTP} = 'IGNORE';
4461 local $SIG{PIPE} = 'IGNORE';
4463 my $oldAutoCommit = $FS::UID::AutoCommit;
4464 local $FS::UID::AutoCommit = 0;
4468 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
4471 while(scalar(@old_cust_pkg)) {
4473 my $custnum = $old_cust_pkg[0]->custnum;
4474 my (@remove) = map { $_->pkgnum }
4475 grep { $_->custnum == $custnum } @old_cust_pkg;
4476 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
4478 my $error = order $custnum, $pkgparts, \@remove, \@return;
4480 push @errors, $error
4482 push @$return_cust_pkg, @return;
4485 if (scalar(@errors)) {
4486 $dbh->rollback if $oldAutoCommit;
4487 return join(' / ', @errors);
4490 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4494 # Used by FS::Upgrade to migrate to a new database.
4495 sub _upgrade_data { # class method
4496 my ($class, %opts) = @_;
4497 $class->_upgrade_otaker(%opts);
4499 # RT#10139, bug resulting in contract_end being set when it shouldn't
4500 'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
4501 # RT#10830, bad calculation of prorate date near end of year
4502 # the date range for bill is December 2009, and we move it forward
4503 # one year if it's before the previous bill date (which it should
4505 'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
4506 AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg
4507 WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
4508 # RT6628, add order_date to cust_pkg
4509 'update cust_pkg set order_date = (select history_date from h_cust_pkg
4510 where h_cust_pkg.pkgnum = cust_pkg.pkgnum and
4511 history_action = \'insert\') where order_date is null',
4513 foreach my $sql (@statements) {
4514 my $sth = dbh->prepare($sql);
4515 $sth->execute or die $sth->errstr;
4523 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
4525 In sub order, the @pkgparts array (passed by reference) is clobbered.
4527 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
4528 method to pass dates to the recur_prog expression, it should do so.
4530 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
4531 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
4532 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
4533 configuration values. Probably need a subroutine which decides what to do
4534 based on whether or not we've fetched the user yet, rather than a hash. See
4535 FS::UID and the TODO.
4537 Now that things are transactional should the check in the insert method be
4542 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
4543 L<FS::pkg_svc>, schema.html from the base documentation