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;
19 use FS::cust_bill_pkg;
20 use FS::cust_pkg_detail;
25 use FS::cust_pkg_reason;
29 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
31 # because they load configuration by setting FS::UID::callback (see TODO)
37 # for sending cancel emails in sub cancel
40 @ISA = qw( FS::m2m_Common FS::cust_main_Mixin FS::option_Common FS::Record );
44 $disable_agentcheck = 0;
48 my ( $hashref, $cache ) = @_;
49 #if ( $hashref->{'pkgpart'} ) {
50 if ( $hashref->{'pkg'} ) {
51 # #@{ $self->{'_pkgnum'} } = ();
52 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
53 # $self->{'_pkgpart'} = $subcache;
54 # #push @{ $self->{'_pkgnum'} },
55 # FS::part_pkg->new_or_cached($hashref, $subcache);
56 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
58 if ( exists $hashref->{'svcnum'} ) {
59 #@{ $self->{'_pkgnum'} } = ();
60 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
61 $self->{'_svcnum'} = $subcache;
62 #push @{ $self->{'_pkgnum'} },
63 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
69 FS::cust_pkg - Object methods for cust_pkg objects
75 $record = new FS::cust_pkg \%hash;
76 $record = new FS::cust_pkg { 'column' => 'value' };
78 $error = $record->insert;
80 $error = $new_record->replace($old_record);
82 $error = $record->delete;
84 $error = $record->check;
86 $error = $record->cancel;
88 $error = $record->suspend;
90 $error = $record->unsuspend;
92 $part_pkg = $record->part_pkg;
94 @labels = $record->labels;
96 $seconds = $record->seconds_since($timestamp);
98 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
99 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
103 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
104 inherits from FS::Record. The following fields are currently supported:
110 Primary key (assigned automatically for new billing items)
114 Customer (see L<FS::cust_main>)
118 Billing item definition (see L<FS::part_pkg>)
122 Optional link to package location (see L<FS::location>)
130 date (next bill date)
154 order taker (assigned automatically if null, see L<FS::UID>)
158 If this field is set to 1, disables the automatic
159 unsuspension of this package when using the B<unsuspendauto> config option.
163 If not set, defaults to 1
167 Date of change from previous package
179 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
180 are specified as UNIX timestamps; see L<perlfunc/"time">. Also see
181 L<Time::Local> and L<Date::Parse> for conversion functions.
189 Create a new billing item. To add the item to the database, see L<"insert">.
193 sub table { 'cust_pkg'; }
194 sub cust_linked { $_[0]->cust_main_custnum; }
195 sub cust_unlinked_msg {
197 "WARNING: can't find cust_main.custnum ". $self->custnum.
198 ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
201 =item insert [ OPTION => VALUE ... ]
203 Adds this billing item to the database ("Orders" the item). If there is an
204 error, returns the error, otherwise returns false.
206 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
207 will be used to look up the package definition and agent restrictions will be
210 If the additional field I<refnum> is defined, an FS::pkg_referral record will
211 be created and inserted. Multiple FS::pkg_referral records can be created by
212 setting I<refnum> to an array reference of refnums or a hash reference with
213 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
214 record will be created corresponding to cust_main.refnum.
216 The following options are available:
222 If set true, supresses any referral credit to a referring customer.
226 cust_pkg_option records will be created
233 my( $self, %options ) = @_;
235 local $SIG{HUP} = 'IGNORE';
236 local $SIG{INT} = 'IGNORE';
237 local $SIG{QUIT} = 'IGNORE';
238 local $SIG{TERM} = 'IGNORE';
239 local $SIG{TSTP} = 'IGNORE';
240 local $SIG{PIPE} = 'IGNORE';
242 my $oldAutoCommit = $FS::UID::AutoCommit;
243 local $FS::UID::AutoCommit = 0;
246 my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
248 $dbh->rollback if $oldAutoCommit;
252 $self->refnum($self->cust_main->refnum) unless $self->refnum;
253 $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
254 $self->process_m2m( 'link_table' => 'pkg_referral',
255 'target_table' => 'part_referral',
256 'params' => $self->refnum,
259 #if ( $self->reg_code ) {
260 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
261 # $error = $reg_code->delete;
263 # $dbh->rollback if $oldAutoCommit;
268 my $conf = new FS::Conf;
270 if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
271 my $queue = new FS::queue {
272 'job' => 'FS::cust_main::queueable_print',
274 $error = $queue->insert(
275 'custnum' => $self->custnum,
276 'template' => 'welcome_letter',
280 warn "can't send welcome letter: $error";
285 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
292 This method now works but you probably shouldn't use it.
294 You don't want to delete billing items, because there would then be no record
295 the customer ever purchased the item. Instead, see the cancel method.
300 # return "Can't delete cust_pkg records!";
303 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
305 Replaces the OLD_RECORD with this one in the database. If there is an error,
306 returns the error, otherwise returns false.
308 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
310 Changing pkgpart may have disasterous effects. See the order subroutine.
312 setup and bill are normally updated by calling the bill method of a customer
313 object (see L<FS::cust_main>).
315 suspend is normally updated by the suspend and unsuspend methods.
317 cancel is normally updated by the cancel method (and also the order subroutine
320 Available options are:
326 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.
330 the access_user (see L<FS::access_user>) providing the reason
334 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
343 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
348 ( ref($_[0]) eq 'HASH' )
352 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
353 return "Can't change otaker!" if $old->otaker ne $new->otaker;
356 #return "Can't change setup once it exists!"
357 # if $old->getfield('setup') &&
358 # $old->getfield('setup') != $new->getfield('setup');
360 #some logic for bill, susp, cancel?
362 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
364 local $SIG{HUP} = 'IGNORE';
365 local $SIG{INT} = 'IGNORE';
366 local $SIG{QUIT} = 'IGNORE';
367 local $SIG{TERM} = 'IGNORE';
368 local $SIG{TSTP} = 'IGNORE';
369 local $SIG{PIPE} = 'IGNORE';
371 my $oldAutoCommit = $FS::UID::AutoCommit;
372 local $FS::UID::AutoCommit = 0;
375 foreach my $method ( qw(adjourn expire) ) { # How many reasons?
376 if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
377 my $error = $new->insert_reason(
378 'reason' => $options->{'reason'},
379 'date' => $new->$method,
381 'reason_otaker' => $options->{'reason_otaker'},
384 dbh->rollback if $oldAutoCommit;
385 return "Error inserting cust_pkg_reason: $error";
390 #save off and freeze RADIUS attributes for any associated svc_acct records
392 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
394 #also check for specific exports?
395 # to avoid spurious modify export events
396 @svc_acct = map { $_->svc_x }
397 grep { $_->part_svc->svcdb eq 'svc_acct' }
400 $_->snapshot foreach @svc_acct;
404 my $error = $new->SUPER::replace($old,
405 $options->{options} ? $options->{options} : ()
408 $dbh->rollback if $oldAutoCommit;
412 #for prepaid packages,
413 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
414 foreach my $old_svc_acct ( @svc_acct ) {
415 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
416 my $s_error = $new_svc_acct->replace($old_svc_acct);
418 $dbh->rollback if $oldAutoCommit;
423 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
430 Checks all fields to make sure this is a valid billing item. If there is an
431 error, returns the error, otherwise returns false. Called by the insert and
440 $self->ut_numbern('pkgnum')
441 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
442 || $self->ut_numbern('pkgpart')
443 || $self->ut_foreign_keyn('locationnum', 'location', 'locationnum')
444 || $self->ut_numbern('setup')
445 || $self->ut_numbern('bill')
446 || $self->ut_numbern('susp')
447 || $self->ut_numbern('cancel')
448 || $self->ut_numbern('adjourn')
449 || $self->ut_numbern('expire')
451 return $error if $error;
453 if ( $self->reg_code ) {
455 unless ( grep { $self->pkgpart == $_->pkgpart }
456 map { $_->reg_code_pkg }
457 qsearchs( 'reg_code', { 'code' => $self->reg_code,
458 'agentnum' => $self->cust_main->agentnum })
460 return "Unknown registration code";
463 } elsif ( $self->promo_code ) {
466 qsearchs('part_pkg', {
467 'pkgpart' => $self->pkgpart,
468 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
470 return 'Unknown promotional code' unless $promo_part_pkg;
474 unless ( $disable_agentcheck ) {
476 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
477 my $pkgpart_href = $agent->pkgpart_hashref;
478 return "agent ". $agent->agentnum.
479 " can't purchase pkgpart ". $self->pkgpart
480 unless $pkgpart_href->{ $self->pkgpart };
483 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
484 return $error if $error;
488 $self->otaker(getotaker) unless $self->otaker;
489 $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
492 if ( $self->dbdef_table->column('manual_flag') ) {
493 $self->manual_flag('') if $self->manual_flag eq ' ';
494 $self->manual_flag =~ /^([01]?)$/
495 or return "Illegal manual_flag ". $self->manual_flag;
496 $self->manual_flag($1);
502 =item cancel [ OPTION => VALUE ... ]
504 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
505 in this package, then cancels the package itself (sets the cancel field to
508 Available options are:
512 =item quiet - can be set true to supress email cancellation notices.
514 =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.
516 =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.
518 =item date - can be set to a unix style timestamp to specify when to cancel (expire)
522 If there is an error, returns the error, otherwise returns false.
527 my( $self, %options ) = @_;
530 warn "cust_pkg::cancel called with options".
531 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
534 local $SIG{HUP} = 'IGNORE';
535 local $SIG{INT} = 'IGNORE';
536 local $SIG{QUIT} = 'IGNORE';
537 local $SIG{TERM} = 'IGNORE';
538 local $SIG{TSTP} = 'IGNORE';
539 local $SIG{PIPE} = 'IGNORE';
541 my $oldAutoCommit = $FS::UID::AutoCommit;
542 local $FS::UID::AutoCommit = 0;
545 my $old = $self->select_for_update;
547 if ( $old->get('cancel') || $self->get('cancel') ) {
548 dbh->rollback if $oldAutoCommit;
549 return ""; # no error
552 my $date = $options{date} if $options{date}; # expire/cancel later
553 $date = '' if ($date && $date <= time); # complain instead?
555 my $cancel_time = $options{'time'} || time;
557 if ( $options{'reason'} ) {
558 $error = $self->insert_reason( 'reason' => $options{'reason'},
559 'action' => $date ? 'expire' : 'cancel',
560 'date' => $date ? $date : $cancel_time,
561 'reason_otaker' => $options{'reason_otaker'},
564 dbh->rollback if $oldAutoCommit;
565 return "Error inserting cust_pkg_reason: $error";
571 foreach my $cust_svc (
574 sort { $a->[1] <=> $b->[1] }
575 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
576 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
579 my $error = $cust_svc->cancel;
582 $dbh->rollback if $oldAutoCommit;
583 return "Error cancelling cust_svc: $error";
587 # Add a credit for remaining service
588 my $remaining_value = $self->calc_remain(time=>$cancel_time);
589 if ( $remaining_value > 0 && !$options{'no_credit'} ) {
590 my $conf = new FS::Conf;
591 my $error = $self->cust_main->credit(
593 'Credit for unused time on '. $self->part_pkg->pkg,
594 'reason_type' => $conf->config('cancel_credit_type'),
597 $dbh->rollback if $oldAutoCommit;
598 return "Error crediting customer \$$remaining_value for unused time on".
599 $self->part_pkg->pkg. ": $error";
604 my %hash = $self->hash;
605 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
606 my $new = new FS::cust_pkg ( \%hash );
607 $error = $new->replace( $self, options => { $self->options } );
609 $dbh->rollback if $oldAutoCommit;
613 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
614 return '' if $date; #no errors
616 my $conf = new FS::Conf;
617 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
618 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
619 my $conf = new FS::Conf;
620 my $error = send_email(
621 'from' => $conf->config('invoice_from'),
622 'to' => \@invoicing_list,
623 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
624 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
626 #should this do something on errors?
633 =item cancel_if_expired [ NOW_TIMESTAMP ]
635 Cancels this package if its expire date has been reached.
639 sub cancel_if_expired {
641 my $time = shift || time;
642 return '' unless $self->expire && $self->expire <= $time;
643 my $error = $self->cancel;
645 return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
646 $self->custnum. ": $error";
653 Cancels any pending expiration (sets the expire field to null).
655 If there is an error, returns the error, otherwise returns false.
660 my( $self, %options ) = @_;
663 local $SIG{HUP} = 'IGNORE';
664 local $SIG{INT} = 'IGNORE';
665 local $SIG{QUIT} = 'IGNORE';
666 local $SIG{TERM} = 'IGNORE';
667 local $SIG{TSTP} = 'IGNORE';
668 local $SIG{PIPE} = 'IGNORE';
670 my $oldAutoCommit = $FS::UID::AutoCommit;
671 local $FS::UID::AutoCommit = 0;
674 my $old = $self->select_for_update;
676 my $pkgnum = $old->pkgnum;
677 if ( $old->get('cancel') || $self->get('cancel') ) {
678 dbh->rollback if $oldAutoCommit;
679 return "Can't unexpire cancelled package $pkgnum";
680 # or at least it's pointless
683 unless ( $old->get('expire') && $self->get('expire') ) {
684 dbh->rollback if $oldAutoCommit;
685 return ""; # no error
688 my %hash = $self->hash;
689 $hash{'expire'} = '';
690 my $new = new FS::cust_pkg ( \%hash );
691 $error = $new->replace( $self, options => { $self->options } );
693 $dbh->rollback if $oldAutoCommit;
697 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
703 =item suspend [ OPTION => VALUE ... ]
705 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
706 package, then suspends the package itself (sets the susp field to now).
708 Available options are:
712 =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.
714 =item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
718 If there is an error, returns the error, otherwise returns false.
723 my( $self, %options ) = @_;
726 local $SIG{HUP} = 'IGNORE';
727 local $SIG{INT} = 'IGNORE';
728 local $SIG{QUIT} = 'IGNORE';
729 local $SIG{TERM} = 'IGNORE';
730 local $SIG{TSTP} = 'IGNORE';
731 local $SIG{PIPE} = 'IGNORE';
733 my $oldAutoCommit = $FS::UID::AutoCommit;
734 local $FS::UID::AutoCommit = 0;
737 my $old = $self->select_for_update;
739 my $pkgnum = $old->pkgnum;
740 if ( $old->get('cancel') || $self->get('cancel') ) {
741 dbh->rollback if $oldAutoCommit;
742 return "Can't suspend cancelled package $pkgnum";
745 if ( $old->get('susp') || $self->get('susp') ) {
746 dbh->rollback if $oldAutoCommit;
747 return ""; # no error # complain on adjourn?
750 my $date = $options{date} if $options{date}; # adjourn/suspend later
751 $date = '' if ($date && $date <= time); # complain instead?
753 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
754 dbh->rollback if $oldAutoCommit;
755 return "Package $pkgnum expires before it would be suspended.";
758 my $suspend_time = $options{'time'} || time;
760 if ( $options{'reason'} ) {
761 $error = $self->insert_reason( 'reason' => $options{'reason'},
762 'action' => $date ? 'adjourn' : 'suspend',
763 'date' => $date ? $date : $suspend_time,
764 'reason_otaker' => $options{'reason_otaker'},
767 dbh->rollback if $oldAutoCommit;
768 return "Error inserting cust_pkg_reason: $error";
776 foreach my $cust_svc (
777 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
779 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
781 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
782 $dbh->rollback if $oldAutoCommit;
783 return "Illegal svcdb value in part_svc!";
786 require "FS/$svcdb.pm";
788 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
790 $error = $svc->suspend;
792 $dbh->rollback if $oldAutoCommit;
795 my( $label, $value ) = $cust_svc->label;
796 push @labels, "$label: $value";
800 my $conf = new FS::Conf;
801 if ( $conf->config('suspend_email_admin') ) {
803 my $error = send_email(
804 'from' => $conf->config('invoice_from'), #??? well as good as any
805 'to' => $conf->config('suspend_email_admin'),
806 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
808 "This is an automatic message from your Freeside installation\n",
809 "informing you that the following customer package has been suspended:\n",
811 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
812 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
813 ( map { "Service : $_\n" } @labels ),
818 warn "WARNING: can't send suspension admin email (suspending anyway): ".
826 my %hash = $self->hash;
828 $hash{'adjourn'} = $date;
830 $hash{'susp'} = $suspend_time;
832 my $new = new FS::cust_pkg ( \%hash );
833 $error = $new->replace( $self, options => { $self->options } );
835 $dbh->rollback if $oldAutoCommit;
839 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
844 =item unsuspend [ OPTION => VALUE ... ]
846 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
847 package, then unsuspends the package itself (clears the susp field and the
848 adjourn field if it is in the past).
850 Available options are:
854 =item adjust_next_bill
856 Can be set true to adjust the next bill date forward by
857 the amount of time the account was inactive. This was set true by default
858 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
859 explicitly requested. Price plans for which this makes sense (anniversary-date
860 based than prorate or subscription) could have an option to enable this
865 If there is an error, returns the error, otherwise returns false.
870 my( $self, %opt ) = @_;
873 local $SIG{HUP} = 'IGNORE';
874 local $SIG{INT} = 'IGNORE';
875 local $SIG{QUIT} = 'IGNORE';
876 local $SIG{TERM} = 'IGNORE';
877 local $SIG{TSTP} = 'IGNORE';
878 local $SIG{PIPE} = 'IGNORE';
880 my $oldAutoCommit = $FS::UID::AutoCommit;
881 local $FS::UID::AutoCommit = 0;
884 my $old = $self->select_for_update;
886 my $pkgnum = $old->pkgnum;
887 if ( $old->get('cancel') || $self->get('cancel') ) {
888 dbh->rollback if $oldAutoCommit;
889 return "Can't unsuspend cancelled package $pkgnum";
892 unless ( $old->get('susp') && $self->get('susp') ) {
893 dbh->rollback if $oldAutoCommit;
894 return ""; # no error # complain instead?
897 foreach my $cust_svc (
898 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
900 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
902 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
903 $dbh->rollback if $oldAutoCommit;
904 return "Illegal svcdb value in part_svc!";
907 require "FS/$svcdb.pm";
909 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
911 $error = $svc->unsuspend;
913 $dbh->rollback if $oldAutoCommit;
920 my %hash = $self->hash;
921 my $inactive = time - $hash{'susp'};
923 my $conf = new FS::Conf;
925 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
926 if ( $opt{'adjust_next_bill'}
927 || $conf->config('unsuspend-always_adjust_next_bill_date') )
928 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
931 $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
932 my $new = new FS::cust_pkg ( \%hash );
933 $error = $new->replace( $self, options => { $self->options } );
935 $dbh->rollback if $oldAutoCommit;
939 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
946 Cancels any pending suspension (sets the adjourn field to null).
948 If there is an error, returns the error, otherwise returns false.
953 my( $self, %options ) = @_;
956 local $SIG{HUP} = 'IGNORE';
957 local $SIG{INT} = 'IGNORE';
958 local $SIG{QUIT} = 'IGNORE';
959 local $SIG{TERM} = 'IGNORE';
960 local $SIG{TSTP} = 'IGNORE';
961 local $SIG{PIPE} = 'IGNORE';
963 my $oldAutoCommit = $FS::UID::AutoCommit;
964 local $FS::UID::AutoCommit = 0;
967 my $old = $self->select_for_update;
969 my $pkgnum = $old->pkgnum;
970 if ( $old->get('cancel') || $self->get('cancel') ) {
971 dbh->rollback if $oldAutoCommit;
972 return "Can't unadjourn cancelled package $pkgnum";
973 # or at least it's pointless
976 if ( $old->get('susp') || $self->get('susp') ) {
977 dbh->rollback if $oldAutoCommit;
978 return "Can't unadjourn suspended package $pkgnum";
979 # perhaps this is arbitrary
982 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
983 dbh->rollback if $oldAutoCommit;
984 return ""; # no error
987 my %hash = $self->hash;
988 $hash{'adjourn'} = '';
989 my $new = new FS::cust_pkg ( \%hash );
990 $error = $new->replace( $self, options => { $self->options } );
992 $dbh->rollback if $oldAutoCommit;
996 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1004 Returns the last bill date, or if there is no last bill date, the setup date.
1005 Useful for billing metered services.
1011 return $self->setfield('last_bill', $_[0]) if @_;
1012 return $self->getfield('last_bill') if $self->getfield('last_bill');
1013 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1014 'edate' => $self->bill, } );
1015 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1018 =item last_cust_pkg_reason ACTION
1020 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1021 Returns false if there is no reason or the package is not currenly ACTION'd
1022 ACTION is one of adjourn, susp, cancel, or expire.
1026 sub last_cust_pkg_reason {
1027 my ( $self, $action ) = ( shift, shift );
1028 my $date = $self->get($action);
1030 'table' => 'cust_pkg_reason',
1031 'hashref' => { 'pkgnum' => $self->pkgnum,
1032 'action' => substr(uc($action), 0, 1),
1035 'order_by' => 'ORDER BY num DESC LIMIT 1',
1039 =item last_reason ACTION
1041 Returns the most recent ACTION FS::reason associated with the package.
1042 Returns false if there is no reason or the package is not currenly ACTION'd
1043 ACTION is one of adjourn, susp, cancel, or expire.
1048 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1049 $cust_pkg_reason->reason
1050 if $cust_pkg_reason;
1055 Returns the definition for this billing item, as an FS::part_pkg object (see
1062 #exists( $self->{'_pkgpart'} )
1064 ? $self->{'_pkgpart'}
1065 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1070 Returns the cancelled package this package was changed from, if any.
1076 return '' unless $self->change_pkgnum;
1077 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1082 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1089 $self->part_pkg->calc_setup($self, @_);
1094 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1101 $self->part_pkg->calc_recur($self, @_);
1106 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1113 $self->part_pkg->calc_remain($self, @_);
1118 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1125 $self->part_pkg->calc_cancel($self, @_);
1130 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1136 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1139 =item cust_pkg_detail [ DETAILTYPE ]
1141 Returns any customer package details for this package (see
1142 L<FS::cust_pkg_detail>).
1144 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1148 sub cust_pkg_detail {
1150 my %hash = ( 'pkgnum' => $self->pkgnum );
1151 $hash{detailtype} = shift if @_;
1153 'table' => 'cust_pkg_detail',
1154 'hashref' => \%hash,
1155 'order_by' => 'ORDER BY weight, pkgdetailnum',
1159 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1161 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1163 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1165 If there is an error, returns the error, otherwise returns false.
1169 sub set_cust_pkg_detail {
1170 my( $self, $detailtype, @details ) = @_;
1172 local $SIG{HUP} = 'IGNORE';
1173 local $SIG{INT} = 'IGNORE';
1174 local $SIG{QUIT} = 'IGNORE';
1175 local $SIG{TERM} = 'IGNORE';
1176 local $SIG{TSTP} = 'IGNORE';
1177 local $SIG{PIPE} = 'IGNORE';
1179 my $oldAutoCommit = $FS::UID::AutoCommit;
1180 local $FS::UID::AutoCommit = 0;
1183 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1184 my $error = $current->delete;
1186 $dbh->rollback if $oldAutoCommit;
1187 return "error removing old detail: $error";
1191 foreach my $detail ( @details ) {
1192 my $cust_pkg_detail = new FS::cust_pkg_detail {
1193 'pkgnum' => $self->pkgnum,
1194 'detailtype' => $detailtype,
1195 'detail' => $detail,
1197 my $error = $cust_pkg_detail->insert;
1199 $dbh->rollback if $oldAutoCommit;
1200 return "error adding new detail: $error";
1205 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1212 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
1216 #false laziness w/cust_bill.pm
1220 'table' => 'cust_event',
1221 'addl_from' => 'JOIN part_event USING ( eventpart )',
1222 'hashref' => { 'tablenum' => $self->pkgnum },
1223 'extra_sql' => " AND eventtable = 'cust_pkg' ",
1227 =item num_cust_event
1229 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
1233 #false laziness w/cust_bill.pm
1234 sub num_cust_event {
1237 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
1238 " WHERE tablenum = ? AND eventtable = 'cust_pkg'";
1239 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
1240 $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
1241 $sth->fetchrow_arrayref->[0];
1244 =item cust_svc [ SVCPART ]
1246 Returns the services for this package, as FS::cust_svc objects (see
1247 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1256 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1257 'svcpart' => shift, } );
1260 #if ( $self->{'_svcnum'} ) {
1261 # values %{ $self->{'_svcnum'}->cache };
1263 $self->_sort_cust_svc(
1264 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1270 =item overlimit [ SVCPART ]
1272 Returns the services for this package which have exceeded their
1273 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1274 is specified, return only the matching services.
1280 grep { $_->overlimit } $self->cust_svc;
1283 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1285 Returns historical services for this package created before END TIMESTAMP and
1286 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1287 (see L<FS::h_cust_svc>).
1294 $self->_sort_cust_svc(
1295 [ qsearch( 'h_cust_svc',
1296 { 'pkgnum' => $self->pkgnum, },
1297 FS::h_cust_svc->sql_h_search(@_),
1303 sub _sort_cust_svc {
1304 my( $self, $arrayref ) = @_;
1307 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1309 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1310 'svcpart' => $_->svcpart } );
1312 $pkg_svc ? $pkg_svc->primary_svc : '',
1313 $pkg_svc ? $pkg_svc->quantity : 0,
1320 =item num_cust_svc [ SVCPART ]
1322 Returns the number of provisioned services for this package. If a svcpart is
1323 specified, counts only the matching services.
1329 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1330 $sql .= ' AND svcpart = ?' if @_;
1331 my $sth = dbh->prepare($sql) or die dbh->errstr;
1332 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1333 $sth->fetchrow_arrayref->[0];
1336 =item available_part_svc
1338 Returns a list of FS::part_svc objects representing services included in this
1339 package but not yet provisioned. Each FS::part_svc object also has an extra
1340 field, I<num_avail>, which specifies the number of available services.
1344 sub available_part_svc {
1346 grep { $_->num_avail > 0 }
1348 my $part_svc = $_->part_svc;
1349 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1350 $_->quantity - $self->num_cust_svc($_->svcpart);
1353 $self->part_pkg->pkg_svc;
1358 Returns a list of FS::part_svc objects representing provisioned and available
1359 services included in this package. Each FS::part_svc object also has the
1360 following extra fields:
1364 =item num_cust_svc (count)
1366 =item num_avail (quantity - count)
1368 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1371 label -> ($cust_svc->label)[1]
1380 #XXX some sort of sort order besides numeric by svcpart...
1381 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1383 my $part_svc = $pkg_svc->part_svc;
1384 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1385 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1386 $part_svc->{'Hash'}{'num_avail'} =
1387 max( 0, $pkg_svc->quantity - $num_cust_svc );
1388 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1390 } $self->part_pkg->pkg_svc;
1393 push @part_svc, map {
1395 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1396 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1397 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1398 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1400 } $self->extra_part_svc;
1406 =item extra_part_svc
1408 Returns a list of FS::part_svc objects corresponding to services in this
1409 package which are still provisioned but not (any longer) available in the
1414 sub extra_part_svc {
1417 my $pkgnum = $self->pkgnum;
1418 my $pkgpart = $self->pkgpart;
1421 'table' => 'part_svc',
1423 'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1424 WHERE pkg_svc.svcpart = part_svc.svcpart
1425 AND pkg_svc.pkgpart = $pkgpart
1428 AND 0 < ( SELECT count(*)
1430 LEFT JOIN cust_pkg using ( pkgnum )
1431 WHERE cust_svc.svcpart = part_svc.svcpart
1432 AND pkgnum = $pkgnum
1439 Returns a short status string for this package, currently:
1443 =item not yet billed
1445 =item one-time charge
1460 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1462 return 'cancelled' if $self->get('cancel');
1463 return 'suspended' if $self->susp;
1464 return 'not yet billed' unless $self->setup;
1465 return 'one-time charge' if $freq =~ /^(0|$)/;
1471 Class method that returns the list of possible status strings for packages
1472 (see L<the status method|/status>). For example:
1474 @statuses = FS::cust_pkg->statuses();
1478 tie my %statuscolor, 'Tie::IxHash',
1479 'not yet billed' => '000000',
1480 'one-time charge' => '000000',
1481 'active' => '00CC00',
1482 'suspended' => 'FF9900',
1483 'cancelled' => 'FF0000',
1487 my $self = shift; #could be class...
1488 grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1489 # mayble split btw one-time vs. recur
1495 Returns a hex triplet color string for this package's status.
1501 $statuscolor{$self->status};
1506 Returns a list of lists, calling the label method for all services
1507 (see L<FS::cust_svc>) of this billing item.
1513 map { [ $_->label ] } $self->cust_svc;
1516 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1518 Like the labels method, but returns historical information on services that
1519 were active as of END_TIMESTAMP and (optionally) not cancelled before
1522 Returns a list of lists, calling the label method for all (historical) services
1523 (see L<FS::h_cust_svc>) of this billing item.
1529 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1532 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1534 Like h_labels, except returns a simple flat list, and shortens long
1535 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1536 identical services to one line that lists the service label and the number of
1537 individual services rather than individual items.
1541 sub h_labels_short {
1544 my $conf = new FS::Conf;
1545 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1548 #tie %labels, 'Tie::IxHash';
1549 push @{ $labels{$_->[0]} }, $_->[1]
1550 foreach $self->h_labels(@_);
1552 foreach my $label ( keys %labels ) {
1554 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1555 my $num = scalar(@values);
1556 if ( $num > $max_same_services ) {
1557 push @labels, "$label ($num)";
1559 push @labels, map { "$label: $_" } @values;
1569 Returns the parent customer object (see L<FS::cust_main>).
1575 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1580 Returns the location object, if any (see L<FS::cust_location>).
1586 return '' unless $self->locationnum;
1587 qsearchs( 'cust_main', { 'locationnum' => $self->locationnum } );
1590 =item cust_location_or_main
1592 If this package is associated with a location, returns the locaiton (see
1593 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
1597 sub cust_location_or_main {
1599 $self->cust_location || $self->cust_main;
1602 =item seconds_since TIMESTAMP
1604 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1605 package have been online since TIMESTAMP, according to the session monitor.
1607 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1608 L<Time::Local> and L<Date::Parse> for conversion functions.
1613 my($self, $since) = @_;
1616 foreach my $cust_svc (
1617 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1619 $seconds += $cust_svc->seconds_since($since);
1626 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1628 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1629 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1632 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1633 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1639 sub seconds_since_sqlradacct {
1640 my($self, $start, $end) = @_;
1644 foreach my $cust_svc (
1646 my $part_svc = $_->part_svc;
1647 $part_svc->svcdb eq 'svc_acct'
1648 && scalar($part_svc->part_export('sqlradius'));
1651 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1658 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1660 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1661 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1665 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1666 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1671 sub attribute_since_sqlradacct {
1672 my($self, $start, $end, $attrib) = @_;
1676 foreach my $cust_svc (
1678 my $part_svc = $_->part_svc;
1679 $part_svc->svcdb eq 'svc_acct'
1680 && scalar($part_svc->part_export('sqlradius'));
1683 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1695 my( $self, $value ) = @_;
1696 if ( defined($value) ) {
1697 $self->setfield('quantity', $value);
1699 $self->getfield('quantity') || 1;
1702 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1704 Transfers as many services as possible from this package to another package.
1706 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1707 object. The destination package must already exist.
1709 Services are moved only if the destination allows services with the correct
1710 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1711 this option with caution! No provision is made for export differences
1712 between the old and new service definitions. Probably only should be used
1713 when your exports for all service definitions of a given svcdb are identical.
1714 (attempt a transfer without it first, to move all possible svcpart-matching
1717 Any services that can't be moved remain in the original package.
1719 Returns an error, if there is one; otherwise, returns the number of services
1720 that couldn't be moved.
1725 my ($self, $dest_pkgnum, %opt) = @_;
1731 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1732 $dest = $dest_pkgnum;
1733 $dest_pkgnum = $dest->pkgnum;
1735 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1738 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1740 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1741 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1744 foreach my $cust_svc ($dest->cust_svc) {
1745 $target{$cust_svc->svcpart}--;
1748 my %svcpart2svcparts = ();
1749 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1750 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1751 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1752 next if exists $svcpart2svcparts{$svcpart};
1753 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1754 $svcpart2svcparts{$svcpart} = [
1756 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1758 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1759 'svcpart' => $_ } );
1761 $pkg_svc ? $pkg_svc->primary_svc : '',
1762 $pkg_svc ? $pkg_svc->quantity : 0,
1766 grep { $_ != $svcpart }
1768 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1770 warn "alternates for svcpart $svcpart: ".
1771 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1776 foreach my $cust_svc ($self->cust_svc) {
1777 if($target{$cust_svc->svcpart} > 0) {
1778 $target{$cust_svc->svcpart}--;
1779 my $new = new FS::cust_svc { $cust_svc->hash };
1780 $new->pkgnum($dest_pkgnum);
1781 my $error = $new->replace($cust_svc);
1782 return $error if $error;
1783 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1785 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1786 warn "alternates to consider: ".
1787 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1789 my @alternate = grep {
1790 warn "considering alternate svcpart $_: ".
1791 "$target{$_} available in new package\n"
1794 } @{$svcpart2svcparts{$cust_svc->svcpart}};
1796 warn "alternate(s) found\n" if $DEBUG;
1797 my $change_svcpart = $alternate[0];
1798 $target{$change_svcpart}--;
1799 my $new = new FS::cust_svc { $cust_svc->hash };
1800 $new->svcpart($change_svcpart);
1801 $new->pkgnum($dest_pkgnum);
1802 my $error = $new->replace($cust_svc);
1803 return $error if $error;
1816 This method is deprecated. See the I<depend_jobnum> option to the insert and
1817 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1824 local $SIG{HUP} = 'IGNORE';
1825 local $SIG{INT} = 'IGNORE';
1826 local $SIG{QUIT} = 'IGNORE';
1827 local $SIG{TERM} = 'IGNORE';
1828 local $SIG{TSTP} = 'IGNORE';
1829 local $SIG{PIPE} = 'IGNORE';
1831 my $oldAutoCommit = $FS::UID::AutoCommit;
1832 local $FS::UID::AutoCommit = 0;
1835 foreach my $cust_svc ( $self->cust_svc ) {
1836 #false laziness w/svc_Common::insert
1837 my $svc_x = $cust_svc->svc_x;
1838 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1839 my $error = $part_export->export_insert($svc_x);
1841 $dbh->rollback if $oldAutoCommit;
1847 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1854 =head1 CLASS METHODS
1860 Returns an SQL expression identifying recurring packages.
1864 sub recurring_sql { "
1865 '0' != ( select freq from part_pkg
1866 where cust_pkg.pkgpart = part_pkg.pkgpart )
1871 Returns an SQL expression identifying one-time packages.
1876 '0' = ( select freq from part_pkg
1877 where cust_pkg.pkgpart = part_pkg.pkgpart )
1882 Returns an SQL expression identifying active packages.
1887 ". $_[0]->recurring_sql(). "
1888 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1889 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1894 Returns an SQL expression identifying inactive packages (one-time packages
1895 that are otherwise unsuspended/uncancelled).
1899 sub inactive_sql { "
1900 ". $_[0]->onetime_sql(). "
1901 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1902 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1908 Returns an SQL expression identifying suspended packages.
1912 sub suspended_sql { susp_sql(@_); }
1914 #$_[0]->recurring_sql(). ' AND '.
1916 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1917 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
1924 Returns an SQL exprression identifying cancelled packages.
1928 sub cancelled_sql { cancel_sql(@_); }
1930 #$_[0]->recurring_sql(). ' AND '.
1931 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1934 =item search_sql HASHREF
1938 Returns a qsearch hash expression to search for parameters specified in HASHREF.
1939 Valid parameters are
1947 active, inactive, suspended, cancel (or cancelled)
1951 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
1961 arrayref of beginning and ending epoch date
1965 arrayref of beginning and ending epoch date
1969 arrayref of beginning and ending epoch date
1973 arrayref of beginning and ending epoch date
1977 arrayref of beginning and ending epoch date
1981 arrayref of beginning and ending epoch date
1985 arrayref of beginning and ending epoch date
1989 pkgnum or APKG_pkgnum
1993 a value suited to passing to FS::UI::Web::cust_header
1997 specifies the user for agent virtualization
2004 my ($class, $params) = @_;
2011 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2013 "cust_main.agentnum = $1";
2020 if ( $params->{'magic'} eq 'active'
2021 || $params->{'status'} eq 'active' ) {
2023 push @where, FS::cust_pkg->active_sql();
2025 } elsif ( $params->{'magic'} eq 'inactive'
2026 || $params->{'status'} eq 'inactive' ) {
2028 push @where, FS::cust_pkg->inactive_sql();
2030 } elsif ( $params->{'magic'} eq 'suspended'
2031 || $params->{'status'} eq 'suspended' ) {
2033 push @where, FS::cust_pkg->suspended_sql();
2035 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
2036 || $params->{'status'} =~ /^cancell?ed$/ ) {
2038 push @where, FS::cust_pkg->cancelled_sql();
2040 } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
2042 push @where, FS::cust_pkg->inactive_sql();
2047 # parse package class
2050 #false lazinessish w/graph/cust_bill_pkg.cgi
2053 if ( exists($params->{'classnum'})
2054 && $params->{'classnum'} =~ /^(\d*)$/
2058 if ( $classnum ) { #a specific class
2059 push @where, "classnum = $classnum";
2061 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
2062 #die "classnum $classnum not found!" unless $pkg_class[0];
2063 #$title .= $pkg_class[0]->classname.' ';
2065 } elsif ( $classnum eq '' ) { #the empty class
2067 push @where, "classnum IS NULL";
2068 #$title .= 'Empty class ';
2069 #@pkg_class = ( '(empty class)' );
2070 } elsif ( $classnum eq '0' ) {
2071 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
2072 #push @pkg_class, '(empty class)';
2074 die "illegal classnum";
2083 my $pkgpart = join (' OR pkgpart=',
2084 grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
2085 push @where, '(pkgpart=' . $pkgpart . ')' if $pkgpart;
2093 #false laziness w/report_cust_pkg.html
2096 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
2097 'active' => { 'susp'=>1, 'cancel'=>1 },
2098 'suspended' => { 'cancel' => 1 },
2103 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
2105 next unless exists($params->{$field});
2107 my($beginning, $ending) = @{$params->{$field}};
2109 next if $beginning == 0 && $ending == 4294967295;
2112 "cust_pkg.$field IS NOT NULL",
2113 "cust_pkg.$field >= $beginning",
2114 "cust_pkg.$field <= $ending";
2116 $orderby ||= "ORDER BY cust_pkg.$field";
2120 $orderby ||= 'ORDER BY bill';
2123 # parse magic, legacy, etc.
2126 if ( $params->{'magic'} &&
2127 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
2130 $orderby = 'ORDER BY pkgnum';
2132 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2133 push @where, "pkgpart = $1";
2136 } elsif ( $params->{'query'} eq 'pkgnum' ) {
2138 $orderby = 'ORDER BY pkgnum';
2140 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2142 $orderby = 'ORDER BY pkgnum';
2145 SELECT count(*) FROM pkg_svc
2146 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
2147 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2148 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
2149 AND cust_svc.svcpart = pkg_svc.svcpart
2156 # setup queries, links, subs, etc. for the search
2159 # here is the agent virtualization
2160 if ($params->{CurrentUser}) {
2162 qsearchs('access_user', { username => $params->{CurrentUser} });
2165 push @where, $access_user->agentnums_sql('table'=>'cust_main');
2170 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
2173 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2175 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
2176 'LEFT JOIN part_pkg USING ( pkgpart ) '.
2177 'LEFT JOIN pkg_class USING ( classnum ) ';
2179 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2182 'table' => 'cust_pkg',
2184 'select' => join(', ',
2186 ( map "part_pkg.$_", qw( pkg freq ) ),
2187 'pkg_class.classname',
2188 'cust_main.custnum as cust_main_custnum',
2189 FS::UI::Web::cust_sql_fields(
2190 $params->{'cust_fields'}
2193 'extra_sql' => "$extra_sql $orderby",
2194 'addl_from' => $addl_from,
2195 'count_query' => $count_query,
2204 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
2206 CUSTNUM is a customer (see L<FS::cust_main>)
2208 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2209 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2212 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2213 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
2214 new billing items. An error is returned if this is not possible (see
2215 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
2218 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2219 newly-created cust_pkg objects.
2221 REFNUM, if specified, will specify the FS::pkg_referral record to be created
2222 and inserted. Multiple FS::pkg_referral records can be created by
2223 setting I<refnum> to an array reference of refnums or a hash reference with
2224 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
2225 record will be created corresponding to cust_main.refnum.
2230 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
2232 my $conf = new FS::Conf;
2234 # Transactionize this whole mess
2235 local $SIG{HUP} = 'IGNORE';
2236 local $SIG{INT} = 'IGNORE';
2237 local $SIG{QUIT} = 'IGNORE';
2238 local $SIG{TERM} = 'IGNORE';
2239 local $SIG{TSTP} = 'IGNORE';
2240 local $SIG{PIPE} = 'IGNORE';
2242 my $oldAutoCommit = $FS::UID::AutoCommit;
2243 local $FS::UID::AutoCommit = 0;
2247 my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2248 return "Customer not found: $custnum" unless $cust_main;
2250 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2253 my $change = scalar(@old_cust_pkg) != 0;
2256 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2260 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
2262 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( setup );
2263 $hash{'setup'} = $time if $old_cust_pkg[0]->setup;
2265 $hash{'change_date'} = $time;
2266 $hash{"change_$_"} = $old_cust_pkg[0]->$_() foreach qw( pkgnum pkgpart );
2269 # Create the new packages.
2270 foreach my $pkgpart (@$pkgparts) {
2271 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2272 pkgpart => $pkgpart,
2276 $error = $cust_pkg->insert( 'change' => $change );
2278 $dbh->rollback if $oldAutoCommit;
2281 push @$return_cust_pkg, $cust_pkg;
2283 # $return_cust_pkg now contains refs to all of the newly
2286 # Transfer services and cancel old packages.
2287 foreach my $old_pkg (@old_cust_pkg) {
2289 foreach my $new_pkg (@$return_cust_pkg) {
2290 $error = $old_pkg->transfer($new_pkg);
2291 if ($error and $error == 0) {
2292 # $old_pkg->transfer failed.
2293 $dbh->rollback if $oldAutoCommit;
2298 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2299 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2300 foreach my $new_pkg (@$return_cust_pkg) {
2301 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2302 if ($error and $error == 0) {
2303 # $old_pkg->transfer failed.
2304 $dbh->rollback if $oldAutoCommit;
2311 # Transfers were successful, but we went through all of the
2312 # new packages and still had services left on the old package.
2313 # We can't cancel the package under the circumstances, so abort.
2314 $dbh->rollback if $oldAutoCommit;
2315 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2317 $error = $old_pkg->cancel( quiet=>1 );
2323 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2327 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2329 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2330 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2333 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2334 replace. The services (see L<FS::cust_svc>) are moved to the
2335 new billing items. An error is returned if this is not possible (see
2338 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2339 newly-created cust_pkg objects.
2344 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2346 # Transactionize this whole mess
2347 local $SIG{HUP} = 'IGNORE';
2348 local $SIG{INT} = 'IGNORE';
2349 local $SIG{QUIT} = 'IGNORE';
2350 local $SIG{TERM} = 'IGNORE';
2351 local $SIG{TSTP} = 'IGNORE';
2352 local $SIG{PIPE} = 'IGNORE';
2354 my $oldAutoCommit = $FS::UID::AutoCommit;
2355 local $FS::UID::AutoCommit = 0;
2359 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2362 while(scalar(@old_cust_pkg)) {
2364 my $custnum = $old_cust_pkg[0]->custnum;
2365 my (@remove) = map { $_->pkgnum }
2366 grep { $_->custnum == $custnum } @old_cust_pkg;
2367 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2369 my $error = order $custnum, $pkgparts, \@remove, \@return;
2371 push @errors, $error
2373 push @$return_cust_pkg, @return;
2376 if (scalar(@errors)) {
2377 $dbh->rollback if $oldAutoCommit;
2378 return join(' / ', @errors);
2381 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2387 Associates this package with a (suspension or cancellation) reason (see
2388 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2391 Available options are:
2397 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.
2401 the access_user (see L<FS::access_user>) providing the reason
2409 the action (cancel, susp, adjourn, expire) associated with the reason
2413 If there is an error, returns the error, otherwise returns false.
2418 my ($self, %options) = @_;
2420 my $otaker = $options{reason_otaker} ||
2421 $FS::CurrentUser::CurrentUser->username;
2424 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2428 } elsif ( ref($options{'reason'}) ) {
2430 return 'Enter a new reason (or select an existing one)'
2431 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2433 my $reason = new FS::reason({
2434 'reason_type' => $options{'reason'}->{'typenum'},
2435 'reason' => $options{'reason'}->{'reason'},
2437 my $error = $reason->insert;
2438 return $error if $error;
2440 $reasonnum = $reason->reasonnum;
2443 return "Unparsable reason: ". $options{'reason'};
2446 my $cust_pkg_reason =
2447 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2448 'reasonnum' => $reasonnum,
2449 'otaker' => $otaker,
2450 'action' => substr(uc($options{'action'}),0,1),
2451 'date' => $options{'date'}
2456 $cust_pkg_reason->insert;
2459 =item set_usage USAGE_VALUE_HASHREF
2461 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2462 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2463 upbytes, downbytes, and totalbytes are appropriate keys.
2465 All svc_accts which are part of this package have their values reset.
2470 my ($self, $valueref) = @_;
2472 foreach my $cust_svc ($self->cust_svc){
2473 my $svc_x = $cust_svc->svc_x;
2474 $svc_x->set_usage($valueref)
2475 if $svc_x->can("set_usage");
2479 =item recharge USAGE_VALUE_HASHREF
2481 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2482 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2483 upbytes, downbytes, and totalbytes are appropriate keys.
2485 All svc_accts which are part of this package have their values incremented.
2490 my ($self, $valueref) = @_;
2492 foreach my $cust_svc ($self->cust_svc){
2493 my $svc_x = $cust_svc->svc_x;
2494 $svc_x->recharge($valueref)
2495 if $svc_x->can("recharge");
2503 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
2505 In sub order, the @pkgparts array (passed by reference) is clobbered.
2507 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
2508 method to pass dates to the recur_prog expression, it should do so.
2510 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
2511 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
2512 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
2513 configuration values. Probably need a subroutine which decides what to do
2514 based on whether or not we've fetched the user yet, rather than a hash. See
2515 FS::UID and the TODO.
2517 Now that things are transactional should the check in the insert method be
2522 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
2523 L<FS::pkg_svc>, schema.html from the base documentation