4 use vars qw(@ISA $disable_agentcheck $DEBUG);
5 use Scalar::Util qw( blessed );
6 use List::Util qw(max);
8 use FS::UID qw( getotaker dbh );
9 use FS::Misc qw( send_email );
10 use FS::Record qw( qsearch qsearchs );
12 use FS::cust_main_Mixin;
16 use FS::cust_location;
18 use FS::cust_bill_pkg;
19 use FS::cust_pkg_detail;
24 use FS::cust_pkg_reason;
28 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
30 # because they load configuration by setting FS::UID::callback (see TODO)
36 # for sending cancel emails in sub cancel
39 @ISA = qw( FS::m2m_Common FS::cust_main_Mixin FS::option_Common FS::Record );
43 $disable_agentcheck = 0;
47 my ( $hashref, $cache ) = @_;
48 #if ( $hashref->{'pkgpart'} ) {
49 if ( $hashref->{'pkg'} ) {
50 # #@{ $self->{'_pkgnum'} } = ();
51 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
52 # $self->{'_pkgpart'} = $subcache;
53 # #push @{ $self->{'_pkgnum'} },
54 # FS::part_pkg->new_or_cached($hashref, $subcache);
55 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
57 if ( exists $hashref->{'svcnum'} ) {
58 #@{ $self->{'_pkgnum'} } = ();
59 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
60 $self->{'_svcnum'} = $subcache;
61 #push @{ $self->{'_pkgnum'} },
62 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
68 FS::cust_pkg - Object methods for cust_pkg objects
74 $record = new FS::cust_pkg \%hash;
75 $record = new FS::cust_pkg { 'column' => 'value' };
77 $error = $record->insert;
79 $error = $new_record->replace($old_record);
81 $error = $record->delete;
83 $error = $record->check;
85 $error = $record->cancel;
87 $error = $record->suspend;
89 $error = $record->unsuspend;
91 $part_pkg = $record->part_pkg;
93 @labels = $record->labels;
95 $seconds = $record->seconds_since($timestamp);
97 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
98 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
102 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
103 inherits from FS::Record. The following fields are currently supported:
109 Primary key (assigned automatically for new billing items)
113 Customer (see L<FS::cust_main>)
117 Billing item definition (see L<FS::part_pkg>)
121 Optional link to package location (see L<FS::location>)
129 date (next bill date)
153 order taker (assigned automatically if null, see L<FS::UID>)
157 If this field is set to 1, disables the automatic
158 unsuspension of this package when using the B<unsuspendauto> config option.
162 If not set, defaults to 1
166 Date of change from previous package
176 =item change_locationnum
182 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
183 are specified as UNIX timestamps; see L<perlfunc/"time">. Also see
184 L<Time::Local> and L<Date::Parse> for conversion functions.
192 Create a new billing item. To add the item to the database, see L<"insert">.
196 sub table { 'cust_pkg'; }
197 sub cust_linked { $_[0]->cust_main_custnum; }
198 sub cust_unlinked_msg {
200 "WARNING: can't find cust_main.custnum ". $self->custnum.
201 ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
204 =item insert [ OPTION => VALUE ... ]
206 Adds this billing item to the database ("Orders" the item). If there is an
207 error, returns the error, otherwise returns false.
209 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
210 will be used to look up the package definition and agent restrictions will be
213 If the additional field I<refnum> is defined, an FS::pkg_referral record will
214 be created and inserted. Multiple FS::pkg_referral records can be created by
215 setting I<refnum> to an array reference of refnums or a hash reference with
216 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
217 record will be created corresponding to cust_main.refnum.
219 The following options are available:
225 If set true, supresses any referral credit to a referring customer.
229 cust_pkg_option records will be created
236 my( $self, %options ) = @_;
238 local $SIG{HUP} = 'IGNORE';
239 local $SIG{INT} = 'IGNORE';
240 local $SIG{QUIT} = 'IGNORE';
241 local $SIG{TERM} = 'IGNORE';
242 local $SIG{TSTP} = 'IGNORE';
243 local $SIG{PIPE} = 'IGNORE';
245 my $oldAutoCommit = $FS::UID::AutoCommit;
246 local $FS::UID::AutoCommit = 0;
249 my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
251 $dbh->rollback if $oldAutoCommit;
255 $self->refnum($self->cust_main->refnum) unless $self->refnum;
256 $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
257 $self->process_m2m( 'link_table' => 'pkg_referral',
258 'target_table' => 'part_referral',
259 'params' => $self->refnum,
262 #if ( $self->reg_code ) {
263 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
264 # $error = $reg_code->delete;
266 # $dbh->rollback if $oldAutoCommit;
271 my $conf = new FS::Conf;
273 if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
274 my $queue = new FS::queue {
275 'job' => 'FS::cust_main::queueable_print',
277 $error = $queue->insert(
278 'custnum' => $self->custnum,
279 'template' => 'welcome_letter',
283 warn "can't send welcome letter: $error";
288 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
295 This method now works but you probably shouldn't use it.
297 You don't want to delete billing items, because there would then be no record
298 the customer ever purchased the item. Instead, see the cancel method.
303 # return "Can't delete cust_pkg records!";
306 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
308 Replaces the OLD_RECORD with this one in the database. If there is an error,
309 returns the error, otherwise returns false.
311 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
313 Changing pkgpart may have disasterous effects. See the order subroutine.
315 setup and bill are normally updated by calling the bill method of a customer
316 object (see L<FS::cust_main>).
318 suspend is normally updated by the suspend and unsuspend methods.
320 cancel is normally updated by the cancel method (and also the order subroutine
323 Available options are:
329 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.
333 the access_user (see L<FS::access_user>) providing the reason
337 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
346 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
351 ( ref($_[0]) eq 'HASH' )
355 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
356 return "Can't change otaker!" if $old->otaker ne $new->otaker;
359 #return "Can't change setup once it exists!"
360 # if $old->getfield('setup') &&
361 # $old->getfield('setup') != $new->getfield('setup');
363 #some logic for bill, susp, cancel?
365 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
367 local $SIG{HUP} = 'IGNORE';
368 local $SIG{INT} = 'IGNORE';
369 local $SIG{QUIT} = 'IGNORE';
370 local $SIG{TERM} = 'IGNORE';
371 local $SIG{TSTP} = 'IGNORE';
372 local $SIG{PIPE} = 'IGNORE';
374 my $oldAutoCommit = $FS::UID::AutoCommit;
375 local $FS::UID::AutoCommit = 0;
378 foreach my $method ( qw(adjourn expire) ) { # How many reasons?
379 if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
380 my $error = $new->insert_reason(
381 'reason' => $options->{'reason'},
382 'date' => $new->$method,
384 'reason_otaker' => $options->{'reason_otaker'},
387 dbh->rollback if $oldAutoCommit;
388 return "Error inserting cust_pkg_reason: $error";
393 #save off and freeze RADIUS attributes for any associated svc_acct records
395 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
397 #also check for specific exports?
398 # to avoid spurious modify export events
399 @svc_acct = map { $_->svc_x }
400 grep { $_->part_svc->svcdb eq 'svc_acct' }
403 $_->snapshot foreach @svc_acct;
407 my $error = $new->SUPER::replace($old,
408 $options->{options} ? $options->{options} : ()
411 $dbh->rollback if $oldAutoCommit;
415 #for prepaid packages,
416 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
417 foreach my $old_svc_acct ( @svc_acct ) {
418 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
419 my $s_error = $new_svc_acct->replace($old_svc_acct);
421 $dbh->rollback if $oldAutoCommit;
426 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
433 Checks all fields to make sure this is a valid billing item. If there is an
434 error, returns the error, otherwise returns false. Called by the insert and
442 $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
445 $self->ut_numbern('pkgnum')
446 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
447 || $self->ut_numbern('pkgpart')
448 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
449 || $self->ut_numbern('setup')
450 || $self->ut_numbern('bill')
451 || $self->ut_numbern('susp')
452 || $self->ut_numbern('cancel')
453 || $self->ut_numbern('adjourn')
454 || $self->ut_numbern('expire')
456 return $error if $error;
458 if ( $self->reg_code ) {
460 unless ( grep { $self->pkgpart == $_->pkgpart }
461 map { $_->reg_code_pkg }
462 qsearchs( 'reg_code', { 'code' => $self->reg_code,
463 'agentnum' => $self->cust_main->agentnum })
465 return "Unknown registration code";
468 } elsif ( $self->promo_code ) {
471 qsearchs('part_pkg', {
472 'pkgpart' => $self->pkgpart,
473 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
475 return 'Unknown promotional code' unless $promo_part_pkg;
479 unless ( $disable_agentcheck ) {
481 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
482 my $pkgpart_href = $agent->pkgpart_hashref;
483 return "agent ". $agent->agentnum.
484 " can't purchase pkgpart ". $self->pkgpart
485 unless $pkgpart_href->{ $self->pkgpart };
488 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
489 return $error if $error;
493 $self->otaker(getotaker) unless $self->otaker;
494 $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
497 if ( $self->dbdef_table->column('manual_flag') ) {
498 $self->manual_flag('') if $self->manual_flag eq ' ';
499 $self->manual_flag =~ /^([01]?)$/
500 or return "Illegal manual_flag ". $self->manual_flag;
501 $self->manual_flag($1);
507 =item cancel [ OPTION => VALUE ... ]
509 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
510 in this package, then cancels the package itself (sets the cancel field to
513 Available options are:
517 =item quiet - can be set true to supress email cancellation notices.
519 =item time - can be set to cancel the package based on a specific future or historical date. Using time ensures that the remaining amount is calculated correctly. Note however that this is an immediate cancel and just changes the date. You are PROBABLY looking to expire the account instead of using this.
521 =item reason - 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.
523 =item date - can be set to a unix style timestamp to specify when to cancel (expire)
527 If there is an error, returns the error, otherwise returns false.
532 my( $self, %options ) = @_;
535 warn "cust_pkg::cancel called with options".
536 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
539 local $SIG{HUP} = 'IGNORE';
540 local $SIG{INT} = 'IGNORE';
541 local $SIG{QUIT} = 'IGNORE';
542 local $SIG{TERM} = 'IGNORE';
543 local $SIG{TSTP} = 'IGNORE';
544 local $SIG{PIPE} = 'IGNORE';
546 my $oldAutoCommit = $FS::UID::AutoCommit;
547 local $FS::UID::AutoCommit = 0;
550 my $old = $self->select_for_update;
552 if ( $old->get('cancel') || $self->get('cancel') ) {
553 dbh->rollback if $oldAutoCommit;
554 return ""; # no error
557 my $date = $options{date} if $options{date}; # expire/cancel later
558 $date = '' if ($date && $date <= time); # complain instead?
560 my $cancel_time = $options{'time'} || time;
562 if ( $options{'reason'} ) {
563 $error = $self->insert_reason( 'reason' => $options{'reason'},
564 'action' => $date ? 'expire' : 'cancel',
565 'date' => $date ? $date : $cancel_time,
566 'reason_otaker' => $options{'reason_otaker'},
569 dbh->rollback if $oldAutoCommit;
570 return "Error inserting cust_pkg_reason: $error";
576 foreach my $cust_svc (
579 sort { $a->[1] <=> $b->[1] }
580 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
581 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
584 my $error = $cust_svc->cancel;
587 $dbh->rollback if $oldAutoCommit;
588 return "Error cancelling cust_svc: $error";
592 # Add a credit for remaining service
593 my $remaining_value = $self->calc_remain(time=>$cancel_time);
594 if ( $remaining_value > 0 && !$options{'no_credit'} ) {
595 my $conf = new FS::Conf;
596 my $error = $self->cust_main->credit(
598 'Credit for unused time on '. $self->part_pkg->pkg,
599 'reason_type' => $conf->config('cancel_credit_type'),
602 $dbh->rollback if $oldAutoCommit;
603 return "Error crediting customer \$$remaining_value for unused time on".
604 $self->part_pkg->pkg. ": $error";
609 my %hash = $self->hash;
610 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
611 my $new = new FS::cust_pkg ( \%hash );
612 $error = $new->replace( $self, options => { $self->options } );
614 $dbh->rollback if $oldAutoCommit;
618 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
619 return '' if $date; #no errors
621 my $conf = new FS::Conf;
622 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
623 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
624 my $conf = new FS::Conf;
625 my $error = send_email(
626 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
627 'to' => \@invoicing_list,
628 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
629 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
631 #should this do something on errors?
638 =item cancel_if_expired [ NOW_TIMESTAMP ]
640 Cancels this package if its expire date has been reached.
644 sub cancel_if_expired {
646 my $time = shift || time;
647 return '' unless $self->expire && $self->expire <= $time;
648 my $error = $self->cancel;
650 return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
651 $self->custnum. ": $error";
658 Cancels any pending expiration (sets the expire field to null).
660 If there is an error, returns the error, otherwise returns false.
665 my( $self, %options ) = @_;
668 local $SIG{HUP} = 'IGNORE';
669 local $SIG{INT} = 'IGNORE';
670 local $SIG{QUIT} = 'IGNORE';
671 local $SIG{TERM} = 'IGNORE';
672 local $SIG{TSTP} = 'IGNORE';
673 local $SIG{PIPE} = 'IGNORE';
675 my $oldAutoCommit = $FS::UID::AutoCommit;
676 local $FS::UID::AutoCommit = 0;
679 my $old = $self->select_for_update;
681 my $pkgnum = $old->pkgnum;
682 if ( $old->get('cancel') || $self->get('cancel') ) {
683 dbh->rollback if $oldAutoCommit;
684 return "Can't unexpire cancelled package $pkgnum";
685 # or at least it's pointless
688 unless ( $old->get('expire') && $self->get('expire') ) {
689 dbh->rollback if $oldAutoCommit;
690 return ""; # no error
693 my %hash = $self->hash;
694 $hash{'expire'} = '';
695 my $new = new FS::cust_pkg ( \%hash );
696 $error = $new->replace( $self, options => { $self->options } );
698 $dbh->rollback if $oldAutoCommit;
702 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
708 =item suspend [ OPTION => VALUE ... ]
710 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
711 package, then suspends the package itself (sets the susp field to now).
713 Available options are:
717 =item reason - 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.
719 =item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
723 If there is an error, returns the error, otherwise returns false.
728 my( $self, %options ) = @_;
731 local $SIG{HUP} = 'IGNORE';
732 local $SIG{INT} = 'IGNORE';
733 local $SIG{QUIT} = 'IGNORE';
734 local $SIG{TERM} = 'IGNORE';
735 local $SIG{TSTP} = 'IGNORE';
736 local $SIG{PIPE} = 'IGNORE';
738 my $oldAutoCommit = $FS::UID::AutoCommit;
739 local $FS::UID::AutoCommit = 0;
742 my $old = $self->select_for_update;
744 my $pkgnum = $old->pkgnum;
745 if ( $old->get('cancel') || $self->get('cancel') ) {
746 dbh->rollback if $oldAutoCommit;
747 return "Can't suspend cancelled package $pkgnum";
750 if ( $old->get('susp') || $self->get('susp') ) {
751 dbh->rollback if $oldAutoCommit;
752 return ""; # no error # complain on adjourn?
755 my $date = $options{date} if $options{date}; # adjourn/suspend later
756 $date = '' if ($date && $date <= time); # complain instead?
758 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
759 dbh->rollback if $oldAutoCommit;
760 return "Package $pkgnum expires before it would be suspended.";
763 my $suspend_time = $options{'time'} || time;
765 if ( $options{'reason'} ) {
766 $error = $self->insert_reason( 'reason' => $options{'reason'},
767 'action' => $date ? 'adjourn' : 'suspend',
768 'date' => $date ? $date : $suspend_time,
769 'reason_otaker' => $options{'reason_otaker'},
772 dbh->rollback if $oldAutoCommit;
773 return "Error inserting cust_pkg_reason: $error";
781 foreach my $cust_svc (
782 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
784 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
786 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
787 $dbh->rollback if $oldAutoCommit;
788 return "Illegal svcdb value in part_svc!";
791 require "FS/$svcdb.pm";
793 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
795 $error = $svc->suspend;
797 $dbh->rollback if $oldAutoCommit;
800 my( $label, $value ) = $cust_svc->label;
801 push @labels, "$label: $value";
805 my $conf = new FS::Conf;
806 if ( $conf->config('suspend_email_admin') ) {
808 my $error = send_email(
809 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
810 #invoice_from ??? well as good as any
811 'to' => $conf->config('suspend_email_admin'),
812 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
814 "This is an automatic message from your Freeside installation\n",
815 "informing you that the following customer package has been suspended:\n",
817 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
818 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
819 ( map { "Service : $_\n" } @labels ),
824 warn "WARNING: can't send suspension admin email (suspending anyway): ".
832 my %hash = $self->hash;
834 $hash{'adjourn'} = $date;
836 $hash{'susp'} = $suspend_time;
838 my $new = new FS::cust_pkg ( \%hash );
839 $error = $new->replace( $self, options => { $self->options } );
841 $dbh->rollback if $oldAutoCommit;
845 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
850 =item unsuspend [ OPTION => VALUE ... ]
852 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
853 package, then unsuspends the package itself (clears the susp field and the
854 adjourn field if it is in the past).
856 Available options are:
860 =item adjust_next_bill
862 Can be set true to adjust the next bill date forward by
863 the amount of time the account was inactive. This was set true by default
864 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
865 explicitly requested. Price plans for which this makes sense (anniversary-date
866 based than prorate or subscription) could have an option to enable this
871 If there is an error, returns the error, otherwise returns false.
876 my( $self, %opt ) = @_;
879 local $SIG{HUP} = 'IGNORE';
880 local $SIG{INT} = 'IGNORE';
881 local $SIG{QUIT} = 'IGNORE';
882 local $SIG{TERM} = 'IGNORE';
883 local $SIG{TSTP} = 'IGNORE';
884 local $SIG{PIPE} = 'IGNORE';
886 my $oldAutoCommit = $FS::UID::AutoCommit;
887 local $FS::UID::AutoCommit = 0;
890 my $old = $self->select_for_update;
892 my $pkgnum = $old->pkgnum;
893 if ( $old->get('cancel') || $self->get('cancel') ) {
894 dbh->rollback if $oldAutoCommit;
895 return "Can't unsuspend cancelled package $pkgnum";
898 unless ( $old->get('susp') && $self->get('susp') ) {
899 dbh->rollback if $oldAutoCommit;
900 return ""; # no error # complain instead?
903 foreach my $cust_svc (
904 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
906 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
908 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
909 $dbh->rollback if $oldAutoCommit;
910 return "Illegal svcdb value in part_svc!";
913 require "FS/$svcdb.pm";
915 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
917 $error = $svc->unsuspend;
919 $dbh->rollback if $oldAutoCommit;
926 my %hash = $self->hash;
927 my $inactive = time - $hash{'susp'};
929 my $conf = new FS::Conf;
931 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
932 if ( $opt{'adjust_next_bill'}
933 || $conf->exists('unsuspend-always_adjust_next_bill_date') )
934 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
937 $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
938 my $new = new FS::cust_pkg ( \%hash );
939 $error = $new->replace( $self, options => { $self->options } );
941 $dbh->rollback if $oldAutoCommit;
945 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
952 Cancels any pending suspension (sets the adjourn field to null).
954 If there is an error, returns the error, otherwise returns false.
959 my( $self, %options ) = @_;
962 local $SIG{HUP} = 'IGNORE';
963 local $SIG{INT} = 'IGNORE';
964 local $SIG{QUIT} = 'IGNORE';
965 local $SIG{TERM} = 'IGNORE';
966 local $SIG{TSTP} = 'IGNORE';
967 local $SIG{PIPE} = 'IGNORE';
969 my $oldAutoCommit = $FS::UID::AutoCommit;
970 local $FS::UID::AutoCommit = 0;
973 my $old = $self->select_for_update;
975 my $pkgnum = $old->pkgnum;
976 if ( $old->get('cancel') || $self->get('cancel') ) {
977 dbh->rollback if $oldAutoCommit;
978 return "Can't unadjourn cancelled package $pkgnum";
979 # or at least it's pointless
982 if ( $old->get('susp') || $self->get('susp') ) {
983 dbh->rollback if $oldAutoCommit;
984 return "Can't unadjourn suspended package $pkgnum";
985 # perhaps this is arbitrary
988 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
989 dbh->rollback if $oldAutoCommit;
990 return ""; # no error
993 my %hash = $self->hash;
994 $hash{'adjourn'} = '';
995 my $new = new FS::cust_pkg ( \%hash );
996 $error = $new->replace( $self, options => { $self->options } );
998 $dbh->rollback if $oldAutoCommit;
1002 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1009 =item change HASHREF | OPTION => VALUE ...
1011 Changes this package: cancels it and creates a new one, with a different
1012 pkgpart or locationnum or both. All services are transferred to the new
1013 package (no change will be made if this is not possible).
1015 Options may be passed as a list of key/value pairs or as a hash reference.
1022 New locationnum, to change the location for this package.
1026 New FS::cust_location object, to create a new location and assign it
1031 New pkgpart (see L<FS::part_pkg>).
1035 New refnum (see L<FS::part_referral>).
1039 At least one option must be specified (otherwise, what's the point?)
1041 Returns either the new FS::cust_pkg object or a scalar error.
1045 my $err_or_new_cust_pkg = $old_cust_pkg->change
1049 #some false laziness w/order
1052 my $opt = ref($_[0]) ? shift : { @_ };
1054 # my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1057 my $conf = new FS::Conf;
1059 # Transactionize this whole mess
1060 local $SIG{HUP} = 'IGNORE';
1061 local $SIG{INT} = 'IGNORE';
1062 local $SIG{QUIT} = 'IGNORE';
1063 local $SIG{TERM} = 'IGNORE';
1064 local $SIG{TSTP} = 'IGNORE';
1065 local $SIG{PIPE} = 'IGNORE';
1067 my $oldAutoCommit = $FS::UID::AutoCommit;
1068 local $FS::UID::AutoCommit = 0;
1077 #$hash{$_} = $self->$_() foreach qw( last_bill bill );
1079 #$hash{$_} = $self->$_() foreach qw( setup );
1081 $hash{'setup'} = $time if $self->setup;
1083 $hash{'change_date'} = $time;
1084 $hash{"change_$_"} = $self->$_()
1085 foreach qw( pkgnum pkgpart locationnum );
1087 if ( $opt->{'cust_location'} &&
1088 ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) {
1089 $error = $opt->{'cust_location'}->insert;
1091 $dbh->rollback if $oldAutoCommit;
1092 return "inserting cust_location (transaction rolled back): $error";
1094 $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1097 # Create the new package.
1098 my $cust_pkg = new FS::cust_pkg {
1099 custnum => $self->custnum,
1100 pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ),
1101 refnum => ( $opt->{'refnum'} || $self->refnum ),
1102 locationnum => ( $opt->{'locationnum'} || $self->locationnum ),
1106 $error = $cust_pkg->insert( 'change' => 1 );
1108 $dbh->rollback if $oldAutoCommit;
1112 # Transfer services and cancel old package.
1114 $error = $self->transfer($cust_pkg);
1115 if ($error and $error == 0) {
1116 # $old_pkg->transfer failed.
1117 $dbh->rollback if $oldAutoCommit;
1121 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1122 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1123 $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1124 if ($error and $error == 0) {
1125 # $old_pkg->transfer failed.
1126 $dbh->rollback if $oldAutoCommit;
1132 # Transfers were successful, but we still had services left on the old
1133 # package. We can't change the package under this circumstances, so abort.
1134 $dbh->rollback if $oldAutoCommit;
1135 return "Unable to transfer all services from package ". $self->pkgnum;
1138 #reset usage if changing pkgpart
1139 if ($self->pkgpart != $cust_pkg->pkgpart) {
1140 my $part_pkg = $cust_pkg->part_pkg;
1141 $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1145 if $part_pkg->can('reset_usage');
1148 $dbh->rollback if $oldAutoCommit;
1149 return "Error setting usage values: $error";
1153 #Good to go, cancel old package.
1154 $error = $self->cancel( quiet=>1 );
1160 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1167 Returns the last bill date, or if there is no last bill date, the setup date.
1168 Useful for billing metered services.
1174 return $self->setfield('last_bill', $_[0]) if @_;
1175 return $self->getfield('last_bill') if $self->getfield('last_bill');
1176 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1177 'edate' => $self->bill, } );
1178 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1181 =item last_cust_pkg_reason ACTION
1183 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1184 Returns false if there is no reason or the package is not currenly ACTION'd
1185 ACTION is one of adjourn, susp, cancel, or expire.
1189 sub last_cust_pkg_reason {
1190 my ( $self, $action ) = ( shift, shift );
1191 my $date = $self->get($action);
1193 'table' => 'cust_pkg_reason',
1194 'hashref' => { 'pkgnum' => $self->pkgnum,
1195 'action' => substr(uc($action), 0, 1),
1198 'order_by' => 'ORDER BY num DESC LIMIT 1',
1202 =item last_reason ACTION
1204 Returns the most recent ACTION FS::reason associated with the package.
1205 Returns false if there is no reason or the package is not currenly ACTION'd
1206 ACTION is one of adjourn, susp, cancel, or expire.
1211 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1212 $cust_pkg_reason->reason
1213 if $cust_pkg_reason;
1218 Returns the definition for this billing item, as an FS::part_pkg object (see
1226 cluck "part_pkg called" if $DEBUG > 1 && ! $self->{'_pkgpart'};
1227 #exists( $self->{'_pkgpart'} )
1229 ? $self->{'_pkgpart'}
1230 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1235 Returns the cancelled package this package was changed from, if any.
1241 return '' unless $self->change_pkgnum;
1242 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1247 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1254 $self->part_pkg->calc_setup($self, @_);
1259 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1266 $self->part_pkg->calc_recur($self, @_);
1271 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1278 $self->part_pkg->calc_remain($self, @_);
1283 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1290 $self->part_pkg->calc_cancel($self, @_);
1295 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1301 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1304 =item cust_pkg_detail [ DETAILTYPE ]
1306 Returns any customer package details for this package (see
1307 L<FS::cust_pkg_detail>).
1309 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1313 sub cust_pkg_detail {
1315 my %hash = ( 'pkgnum' => $self->pkgnum );
1316 $hash{detailtype} = shift if @_;
1318 'table' => 'cust_pkg_detail',
1319 'hashref' => \%hash,
1320 'order_by' => 'ORDER BY weight, pkgdetailnum',
1324 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1326 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1328 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1330 If there is an error, returns the error, otherwise returns false.
1334 sub set_cust_pkg_detail {
1335 my( $self, $detailtype, @details ) = @_;
1337 local $SIG{HUP} = 'IGNORE';
1338 local $SIG{INT} = 'IGNORE';
1339 local $SIG{QUIT} = 'IGNORE';
1340 local $SIG{TERM} = 'IGNORE';
1341 local $SIG{TSTP} = 'IGNORE';
1342 local $SIG{PIPE} = 'IGNORE';
1344 my $oldAutoCommit = $FS::UID::AutoCommit;
1345 local $FS::UID::AutoCommit = 0;
1348 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1349 my $error = $current->delete;
1351 $dbh->rollback if $oldAutoCommit;
1352 return "error removing old detail: $error";
1356 foreach my $detail ( @details ) {
1357 my $cust_pkg_detail = new FS::cust_pkg_detail {
1358 'pkgnum' => $self->pkgnum,
1359 'detailtype' => $detailtype,
1360 'detail' => $detail,
1362 my $error = $cust_pkg_detail->insert;
1364 $dbh->rollback if $oldAutoCommit;
1365 return "error adding new detail: $error";
1370 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1377 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
1381 #false laziness w/cust_bill.pm
1385 'table' => 'cust_event',
1386 'addl_from' => 'JOIN part_event USING ( eventpart )',
1387 'hashref' => { 'tablenum' => $self->pkgnum },
1388 'extra_sql' => " AND eventtable = 'cust_pkg' ",
1392 =item num_cust_event
1394 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
1398 #false laziness w/cust_bill.pm
1399 sub num_cust_event {
1402 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
1403 " WHERE tablenum = ? AND eventtable = 'cust_pkg'";
1404 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
1405 $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
1406 $sth->fetchrow_arrayref->[0];
1409 =item cust_svc [ SVCPART ]
1411 Returns the services for this package, as FS::cust_svc objects (see
1412 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1421 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1422 'svcpart' => shift, } );
1425 #if ( $self->{'_svcnum'} ) {
1426 # values %{ $self->{'_svcnum'}->cache };
1428 $self->_sort_cust_svc(
1429 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1435 =item overlimit [ SVCPART ]
1437 Returns the services for this package which have exceeded their
1438 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1439 is specified, return only the matching services.
1445 grep { $_->overlimit } $self->cust_svc;
1448 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1450 Returns historical services for this package created before END TIMESTAMP and
1451 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1452 (see L<FS::h_cust_svc>).
1459 $self->_sort_cust_svc(
1460 [ qsearch( 'h_cust_svc',
1461 { 'pkgnum' => $self->pkgnum, },
1462 FS::h_cust_svc->sql_h_search(@_),
1468 sub _sort_cust_svc {
1469 my( $self, $arrayref ) = @_;
1472 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1474 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1475 'svcpart' => $_->svcpart } );
1477 $pkg_svc ? $pkg_svc->primary_svc : '',
1478 $pkg_svc ? $pkg_svc->quantity : 0,
1485 =item num_cust_svc [ SVCPART ]
1487 Returns the number of provisioned services for this package. If a svcpart is
1488 specified, counts only the matching services.
1494 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1495 $sql .= ' AND svcpart = ?' if @_;
1496 my $sth = dbh->prepare($sql) or die dbh->errstr;
1497 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1498 $sth->fetchrow_arrayref->[0];
1501 =item available_part_svc
1503 Returns a list of FS::part_svc objects representing services included in this
1504 package but not yet provisioned. Each FS::part_svc object also has an extra
1505 field, I<num_avail>, which specifies the number of available services.
1509 sub available_part_svc {
1511 grep { $_->num_avail > 0 }
1513 my $part_svc = $_->part_svc;
1514 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1515 $_->quantity - $self->num_cust_svc($_->svcpart);
1518 $self->part_pkg->pkg_svc;
1523 Returns a list of FS::part_svc objects representing provisioned and available
1524 services included in this package. Each FS::part_svc object also has the
1525 following extra fields:
1529 =item num_cust_svc (count)
1531 =item num_avail (quantity - count)
1533 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1536 label -> ($cust_svc->label)[1]
1545 #XXX some sort of sort order besides numeric by svcpart...
1546 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1548 my $part_svc = $pkg_svc->part_svc;
1549 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1550 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1551 $part_svc->{'Hash'}{'num_avail'} =
1552 max( 0, $pkg_svc->quantity - $num_cust_svc );
1553 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1555 } $self->part_pkg->pkg_svc;
1558 push @part_svc, map {
1560 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1561 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1562 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1563 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1565 } $self->extra_part_svc;
1571 =item extra_part_svc
1573 Returns a list of FS::part_svc objects corresponding to services in this
1574 package which are still provisioned but not (any longer) available in the
1579 sub extra_part_svc {
1582 my $pkgnum = $self->pkgnum;
1583 my $pkgpart = $self->pkgpart;
1586 # 'table' => 'part_svc',
1589 # "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1590 # WHERE pkg_svc.svcpart = part_svc.svcpart
1591 # AND pkg_svc.pkgpart = ?
1594 # AND 0 < ( SELECT COUNT(*) FROM cust_svc
1595 # LEFT JOIN cust_pkg USING ( pkgnum )
1596 # WHERE cust_svc.svcpart = part_svc.svcpart
1599 # 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1602 #seems to benchmark slightly faster...
1604 'select' => 'DISTINCT ON (svcpart) part_svc.*',
1605 'table' => 'part_svc',
1607 'LEFT JOIN pkg_svc ON ( pkg_svc.svcpart = part_svc.svcpart
1608 AND pkg_svc.pkgpart = ?
1611 LEFT JOIN cust_svc ON ( cust_svc.svcpart = part_svc.svcpart )
1612 LEFT JOIN cust_pkg USING ( pkgnum )
1615 'extra_sql' => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
1616 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1622 Returns a short status string for this package, currently:
1626 =item not yet billed
1628 =item one-time charge
1643 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1645 return 'cancelled' if $self->get('cancel');
1646 return 'suspended' if $self->susp;
1647 return 'not yet billed' unless $self->setup;
1648 return 'one-time charge' if $freq =~ /^(0|$)/;
1654 Class method that returns the list of possible status strings for packages
1655 (see L<the status method|/status>). For example:
1657 @statuses = FS::cust_pkg->statuses();
1661 tie my %statuscolor, 'Tie::IxHash',
1662 'not yet billed' => '000000',
1663 'one-time charge' => '000000',
1664 'active' => '00CC00',
1665 'suspended' => 'FF9900',
1666 'cancelled' => 'FF0000',
1670 my $self = shift; #could be class...
1671 grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1672 # mayble split btw one-time vs. recur
1678 Returns a hex triplet color string for this package's status.
1684 $statuscolor{$self->status};
1689 Returns a list of lists, calling the label method for all services
1690 (see L<FS::cust_svc>) of this billing item.
1696 map { [ $_->label ] } $self->cust_svc;
1699 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1701 Like the labels method, but returns historical information on services that
1702 were active as of END_TIMESTAMP and (optionally) not cancelled before
1705 Returns a list of lists, calling the label method for all (historical) services
1706 (see L<FS::h_cust_svc>) of this billing item.
1712 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1715 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1717 Like h_labels, except returns a simple flat list, and shortens long
1718 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1719 identical services to one line that lists the service label and the number of
1720 individual services rather than individual items.
1724 sub h_labels_short {
1727 my $conf = new FS::Conf;
1728 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1731 #tie %labels, 'Tie::IxHash';
1732 push @{ $labels{$_->[0]} }, $_->[1]
1733 foreach $self->h_labels(@_);
1735 foreach my $label ( keys %labels ) {
1737 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1738 my $num = scalar(@values);
1739 if ( $num > $max_same_services ) {
1740 push @labels, "$label ($num)";
1742 push @labels, map { "$label: $_" } @values;
1752 Returns the parent customer object (see L<FS::cust_main>).
1758 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1763 Returns the location object, if any (see L<FS::cust_location>).
1769 return '' unless $self->locationnum;
1770 qsearchs( 'cust_location', { 'locationnum' => $self->locationnum } );
1773 =item cust_location_or_main
1775 If this package is associated with a location, returns the locaiton (see
1776 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
1780 sub cust_location_or_main {
1782 $self->cust_location || $self->cust_main;
1785 =item seconds_since TIMESTAMP
1787 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1788 package have been online since TIMESTAMP, according to the session monitor.
1790 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1791 L<Time::Local> and L<Date::Parse> for conversion functions.
1796 my($self, $since) = @_;
1799 foreach my $cust_svc (
1800 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1802 $seconds += $cust_svc->seconds_since($since);
1809 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1811 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1812 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1815 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1816 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1822 sub seconds_since_sqlradacct {
1823 my($self, $start, $end) = @_;
1827 foreach my $cust_svc (
1829 my $part_svc = $_->part_svc;
1830 $part_svc->svcdb eq 'svc_acct'
1831 && scalar($part_svc->part_export('sqlradius'));
1834 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1841 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1843 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1844 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1848 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1849 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1854 sub attribute_since_sqlradacct {
1855 my($self, $start, $end, $attrib) = @_;
1859 foreach my $cust_svc (
1861 my $part_svc = $_->part_svc;
1862 $part_svc->svcdb eq 'svc_acct'
1863 && scalar($part_svc->part_export('sqlradius'));
1866 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1878 my( $self, $value ) = @_;
1879 if ( defined($value) ) {
1880 $self->setfield('quantity', $value);
1882 $self->getfield('quantity') || 1;
1885 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1887 Transfers as many services as possible from this package to another package.
1889 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1890 object. The destination package must already exist.
1892 Services are moved only if the destination allows services with the correct
1893 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1894 this option with caution! No provision is made for export differences
1895 between the old and new service definitions. Probably only should be used
1896 when your exports for all service definitions of a given svcdb are identical.
1897 (attempt a transfer without it first, to move all possible svcpart-matching
1900 Any services that can't be moved remain in the original package.
1902 Returns an error, if there is one; otherwise, returns the number of services
1903 that couldn't be moved.
1908 my ($self, $dest_pkgnum, %opt) = @_;
1914 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1915 $dest = $dest_pkgnum;
1916 $dest_pkgnum = $dest->pkgnum;
1918 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1921 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1923 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1924 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1927 foreach my $cust_svc ($dest->cust_svc) {
1928 $target{$cust_svc->svcpart}--;
1931 my %svcpart2svcparts = ();
1932 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1933 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1934 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1935 next if exists $svcpart2svcparts{$svcpart};
1936 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1937 $svcpart2svcparts{$svcpart} = [
1939 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1941 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1942 'svcpart' => $_ } );
1944 $pkg_svc ? $pkg_svc->primary_svc : '',
1945 $pkg_svc ? $pkg_svc->quantity : 0,
1949 grep { $_ != $svcpart }
1951 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1953 warn "alternates for svcpart $svcpart: ".
1954 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1959 foreach my $cust_svc ($self->cust_svc) {
1960 if($target{$cust_svc->svcpart} > 0) {
1961 $target{$cust_svc->svcpart}--;
1962 my $new = new FS::cust_svc { $cust_svc->hash };
1963 $new->pkgnum($dest_pkgnum);
1964 my $error = $new->replace($cust_svc);
1965 return $error if $error;
1966 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1968 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1969 warn "alternates to consider: ".
1970 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1972 my @alternate = grep {
1973 warn "considering alternate svcpart $_: ".
1974 "$target{$_} available in new package\n"
1977 } @{$svcpart2svcparts{$cust_svc->svcpart}};
1979 warn "alternate(s) found\n" if $DEBUG;
1980 my $change_svcpart = $alternate[0];
1981 $target{$change_svcpart}--;
1982 my $new = new FS::cust_svc { $cust_svc->hash };
1983 $new->svcpart($change_svcpart);
1984 $new->pkgnum($dest_pkgnum);
1985 my $error = $new->replace($cust_svc);
1986 return $error if $error;
1999 This method is deprecated. See the I<depend_jobnum> option to the insert and
2000 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2007 local $SIG{HUP} = 'IGNORE';
2008 local $SIG{INT} = 'IGNORE';
2009 local $SIG{QUIT} = 'IGNORE';
2010 local $SIG{TERM} = 'IGNORE';
2011 local $SIG{TSTP} = 'IGNORE';
2012 local $SIG{PIPE} = 'IGNORE';
2014 my $oldAutoCommit = $FS::UID::AutoCommit;
2015 local $FS::UID::AutoCommit = 0;
2018 foreach my $cust_svc ( $self->cust_svc ) {
2019 #false laziness w/svc_Common::insert
2020 my $svc_x = $cust_svc->svc_x;
2021 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2022 my $error = $part_export->export_insert($svc_x);
2024 $dbh->rollback if $oldAutoCommit;
2030 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2037 =head1 CLASS METHODS
2043 Returns an SQL expression identifying recurring packages.
2047 sub recurring_sql { "
2048 '0' != ( select freq from part_pkg
2049 where cust_pkg.pkgpart = part_pkg.pkgpart )
2054 Returns an SQL expression identifying one-time packages.
2059 '0' = ( select freq from part_pkg
2060 where cust_pkg.pkgpart = part_pkg.pkgpart )
2065 Returns an SQL expression identifying active packages.
2070 ". $_[0]->recurring_sql(). "
2071 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2072 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2077 Returns an SQL expression identifying inactive packages (one-time packages
2078 that are otherwise unsuspended/uncancelled).
2082 sub inactive_sql { "
2083 ". $_[0]->onetime_sql(). "
2084 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2085 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2091 Returns an SQL expression identifying suspended packages.
2095 sub suspended_sql { susp_sql(@_); }
2097 #$_[0]->recurring_sql(). ' AND '.
2099 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2100 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
2107 Returns an SQL exprression identifying cancelled packages.
2111 sub cancelled_sql { cancel_sql(@_); }
2113 #$_[0]->recurring_sql(). ' AND '.
2114 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
2117 =item search_sql HASHREF
2121 Returns a qsearch hash expression to search for parameters specified in HASHREF.
2122 Valid parameters are
2130 active, inactive, suspended, cancel (or cancelled)
2134 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
2144 arrayref of beginning and ending epoch date
2148 arrayref of beginning and ending epoch date
2152 arrayref of beginning and ending epoch date
2156 arrayref of beginning and ending epoch date
2160 arrayref of beginning and ending epoch date
2164 arrayref of beginning and ending epoch date
2168 arrayref of beginning and ending epoch date
2172 pkgnum or APKG_pkgnum
2176 a value suited to passing to FS::UI::Web::cust_header
2180 specifies the user for agent virtualization
2187 my ($class, $params) = @_;
2194 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2196 "cust_main.agentnum = $1";
2203 if ( $params->{'magic'} eq 'active'
2204 || $params->{'status'} eq 'active' ) {
2206 push @where, FS::cust_pkg->active_sql();
2208 } elsif ( $params->{'magic'} eq 'inactive'
2209 || $params->{'status'} eq 'inactive' ) {
2211 push @where, FS::cust_pkg->inactive_sql();
2213 } elsif ( $params->{'magic'} eq 'suspended'
2214 || $params->{'status'} eq 'suspended' ) {
2216 push @where, FS::cust_pkg->suspended_sql();
2218 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
2219 || $params->{'status'} =~ /^cancell?ed$/ ) {
2221 push @where, FS::cust_pkg->cancelled_sql();
2223 } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
2225 push @where, FS::cust_pkg->inactive_sql();
2230 # parse package class
2233 #false lazinessish w/graph/cust_bill_pkg.cgi
2236 if ( exists($params->{'classnum'})
2237 && $params->{'classnum'} =~ /^(\d*)$/
2241 if ( $classnum ) { #a specific class
2242 push @where, "classnum = $classnum";
2244 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
2245 #die "classnum $classnum not found!" unless $pkg_class[0];
2246 #$title .= $pkg_class[0]->classname.' ';
2248 } elsif ( $classnum eq '' ) { #the empty class
2250 push @where, "classnum IS NULL";
2251 #$title .= 'Empty class ';
2252 #@pkg_class = ( '(empty class)' );
2253 } elsif ( $classnum eq '0' ) {
2254 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
2255 #push @pkg_class, '(empty class)';
2257 die "illegal classnum";
2266 my $pkgpart = join (' OR pkgpart=',
2267 grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
2268 push @where, '(pkgpart=' . $pkgpart . ')' if $pkgpart;
2276 #false laziness w/report_cust_pkg.html
2279 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
2280 'active' => { 'susp'=>1, 'cancel'=>1 },
2281 'suspended' => { 'cancel' => 1 },
2286 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
2288 next unless exists($params->{$field});
2290 my($beginning, $ending) = @{$params->{$field}};
2292 next if $beginning == 0 && $ending == 4294967295;
2295 "cust_pkg.$field IS NOT NULL",
2296 "cust_pkg.$field >= $beginning",
2297 "cust_pkg.$field <= $ending";
2299 $orderby ||= "ORDER BY cust_pkg.$field";
2303 $orderby ||= 'ORDER BY bill';
2306 # parse magic, legacy, etc.
2309 if ( $params->{'magic'} &&
2310 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
2313 $orderby = 'ORDER BY pkgnum';
2315 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2316 push @where, "pkgpart = $1";
2319 } elsif ( $params->{'query'} eq 'pkgnum' ) {
2321 $orderby = 'ORDER BY pkgnum';
2323 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2325 $orderby = 'ORDER BY pkgnum';
2328 SELECT count(*) FROM pkg_svc
2329 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
2330 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2331 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
2332 AND cust_svc.svcpart = pkg_svc.svcpart
2339 # setup queries, links, subs, etc. for the search
2342 # here is the agent virtualization
2343 if ($params->{CurrentUser}) {
2345 qsearchs('access_user', { username => $params->{CurrentUser} });
2348 push @where, $access_user->agentnums_sql('table'=>'cust_main');
2353 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
2356 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2358 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
2359 'LEFT JOIN part_pkg USING ( pkgpart ) '.
2360 'LEFT JOIN pkg_class USING ( classnum ) ';
2362 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2365 'table' => 'cust_pkg',
2367 'select' => join(', ',
2369 ( map "part_pkg.$_", qw( pkg freq ) ),
2370 'pkg_class.classname',
2371 'cust_main.custnum as cust_main_custnum',
2372 FS::UI::Web::cust_sql_fields(
2373 $params->{'cust_fields'}
2376 'extra_sql' => "$extra_sql $orderby",
2377 'addl_from' => $addl_from,
2378 'count_query' => $count_query,
2385 Returns a list: the first item is an SQL fragment identifying matching
2386 packages/customers via location (taking into account shipping and package
2387 address taxation, if enabled), and subsequent items are the parameters to
2388 substitute for the placeholders in that fragment.
2393 my($class, %opt) = @_;
2394 my $ornull = $opt{'ornull'};
2396 my $conf = new FS::Conf;
2398 # '?' placeholders in _location_sql_where
2401 @bill_param = qw( county county state state state country );
2403 @bill_param = qw( county state state country );
2405 unshift @bill_param, 'county'; # unless $nec;
2409 if ( $conf->exists('tax-ship_address') ) {
2412 ( ( ship_last IS NULL OR ship_last = '' )
2413 AND ". _location_sql_where('cust_main', '', $ornull ). "
2415 OR ( ship_last IS NOT NULL AND ship_last != ''
2416 AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
2419 # AND payby != 'COMP'
2421 @main_param = ( @bill_param, @bill_param );
2425 $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
2426 @main_param = @bill_param;
2432 if ( $conf->exists('tax-pkg_address') ) {
2434 my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
2437 ( cust_pkg.locationnum IS NULL AND $main_where )
2438 OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where )
2441 @param = ( @main_param, @bill_param );
2445 $where = $main_where;
2446 @param = @main_param;
2454 #subroutine, helper for location_sql
2455 sub _location_sql_where {
2457 my $prefix = @_ ? shift : '';
2458 my $ornull = @_ ? shift : '';
2460 # $ornull = $ornull ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
2462 $ornull = $ornull ? ' OR ? IS NULL ' : '';
2464 my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
2465 my $or_empty_state = " OR ( ? = '' AND $table.${prefix}state IS NULL ) ";
2468 ( $table.${prefix}county = ? $or_empty_county $ornull )
2469 AND ( $table.${prefix}state = ? $or_empty_state $ornull )
2470 AND $table.${prefix}country = ?
2478 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
2480 CUSTNUM is a customer (see L<FS::cust_main>)
2482 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2483 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2486 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2487 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
2488 new billing items. An error is returned if this is not possible (see
2489 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
2492 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2493 newly-created cust_pkg objects.
2495 REFNUM, if specified, will specify the FS::pkg_referral record to be created
2496 and inserted. Multiple FS::pkg_referral records can be created by
2497 setting I<refnum> to an array reference of refnums or a hash reference with
2498 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
2499 record will be created corresponding to cust_main.refnum.
2504 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
2506 my $conf = new FS::Conf;
2508 # Transactionize this whole mess
2509 local $SIG{HUP} = 'IGNORE';
2510 local $SIG{INT} = 'IGNORE';
2511 local $SIG{QUIT} = 'IGNORE';
2512 local $SIG{TERM} = 'IGNORE';
2513 local $SIG{TSTP} = 'IGNORE';
2514 local $SIG{PIPE} = 'IGNORE';
2516 my $oldAutoCommit = $FS::UID::AutoCommit;
2517 local $FS::UID::AutoCommit = 0;
2521 # my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2522 # return "Customer not found: $custnum" unless $cust_main;
2524 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2527 my $change = scalar(@old_cust_pkg) != 0;
2530 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2532 my $err_or_cust_pkg =
2533 $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
2534 'refnum' => $refnum,
2537 unless (ref($err_or_cust_pkg)) {
2538 $dbh->rollback if $oldAutoCommit;
2539 return $err_or_cust_pkg;
2542 push @$return_cust_pkg, $err_or_cust_pkg;
2547 # Create the new packages.
2548 foreach my $pkgpart (@$pkgparts) {
2549 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2550 pkgpart => $pkgpart,
2554 $error = $cust_pkg->insert( 'change' => $change );
2556 $dbh->rollback if $oldAutoCommit;
2559 push @$return_cust_pkg, $cust_pkg;
2561 # $return_cust_pkg now contains refs to all of the newly
2564 # Transfer services and cancel old packages.
2565 foreach my $old_pkg (@old_cust_pkg) {
2567 foreach my $new_pkg (@$return_cust_pkg) {
2568 $error = $old_pkg->transfer($new_pkg);
2569 if ($error and $error == 0) {
2570 # $old_pkg->transfer failed.
2571 $dbh->rollback if $oldAutoCommit;
2576 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2577 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2578 foreach my $new_pkg (@$return_cust_pkg) {
2579 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2580 if ($error and $error == 0) {
2581 # $old_pkg->transfer failed.
2582 $dbh->rollback if $oldAutoCommit;
2589 # Transfers were successful, but we went through all of the
2590 # new packages and still had services left on the old package.
2591 # We can't cancel the package under the circumstances, so abort.
2592 $dbh->rollback if $oldAutoCommit;
2593 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2595 $error = $old_pkg->cancel( quiet=>1 );
2601 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2605 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2607 A bulk change method to change packages for multiple customers.
2609 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2610 L<FS::part_pkg>) to order for each customer. Duplicates are of course
2613 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2614 replace. The services (see L<FS::cust_svc>) are moved to the
2615 new billing items. An error is returned if this is not possible (see
2618 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2619 newly-created cust_pkg objects.
2624 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2626 # Transactionize this whole mess
2627 local $SIG{HUP} = 'IGNORE';
2628 local $SIG{INT} = 'IGNORE';
2629 local $SIG{QUIT} = 'IGNORE';
2630 local $SIG{TERM} = 'IGNORE';
2631 local $SIG{TSTP} = 'IGNORE';
2632 local $SIG{PIPE} = 'IGNORE';
2634 my $oldAutoCommit = $FS::UID::AutoCommit;
2635 local $FS::UID::AutoCommit = 0;
2639 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2642 while(scalar(@old_cust_pkg)) {
2644 my $custnum = $old_cust_pkg[0]->custnum;
2645 my (@remove) = map { $_->pkgnum }
2646 grep { $_->custnum == $custnum } @old_cust_pkg;
2647 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2649 my $error = order $custnum, $pkgparts, \@remove, \@return;
2651 push @errors, $error
2653 push @$return_cust_pkg, @return;
2656 if (scalar(@errors)) {
2657 $dbh->rollback if $oldAutoCommit;
2658 return join(' / ', @errors);
2661 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2667 Associates this package with a (suspension or cancellation) reason (see
2668 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2671 Available options are:
2677 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.
2681 the access_user (see L<FS::access_user>) providing the reason
2689 the action (cancel, susp, adjourn, expire) associated with the reason
2693 If there is an error, returns the error, otherwise returns false.
2698 my ($self, %options) = @_;
2700 my $otaker = $options{reason_otaker} ||
2701 $FS::CurrentUser::CurrentUser->username;
2704 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2708 } elsif ( ref($options{'reason'}) ) {
2710 return 'Enter a new reason (or select an existing one)'
2711 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2713 my $reason = new FS::reason({
2714 'reason_type' => $options{'reason'}->{'typenum'},
2715 'reason' => $options{'reason'}->{'reason'},
2717 my $error = $reason->insert;
2718 return $error if $error;
2720 $reasonnum = $reason->reasonnum;
2723 return "Unparsable reason: ". $options{'reason'};
2726 my $cust_pkg_reason =
2727 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2728 'reasonnum' => $reasonnum,
2729 'otaker' => $otaker,
2730 'action' => substr(uc($options{'action'}),0,1),
2731 'date' => $options{'date'}
2736 $cust_pkg_reason->insert;
2739 =item set_usage USAGE_VALUE_HASHREF
2741 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2742 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2743 upbytes, downbytes, and totalbytes are appropriate keys.
2745 All svc_accts which are part of this package have their values reset.
2750 my ($self, $valueref, %opt) = @_;
2752 foreach my $cust_svc ($self->cust_svc){
2753 my $svc_x = $cust_svc->svc_x;
2754 $svc_x->set_usage($valueref, %opt)
2755 if $svc_x->can("set_usage");
2759 =item recharge USAGE_VALUE_HASHREF
2761 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2762 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2763 upbytes, downbytes, and totalbytes are appropriate keys.
2765 All svc_accts which are part of this package have their values incremented.
2770 my ($self, $valueref) = @_;
2772 foreach my $cust_svc ($self->cust_svc){
2773 my $svc_x = $cust_svc->svc_x;
2774 $svc_x->recharge($valueref)
2775 if $svc_x->can("recharge");
2783 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
2785 In sub order, the @pkgparts array (passed by reference) is clobbered.
2787 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
2788 method to pass dates to the recur_prog expression, it should do so.
2790 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
2791 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
2792 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
2793 configuration values. Probably need a subroutine which decides what to do
2794 based on whether or not we've fetched the user yet, rather than a hash. See
2795 FS::UID and the TODO.
2797 Now that things are transactional should the check in the insert method be
2802 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
2803 L<FS::pkg_svc>, schema.html from the base documentation