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
439 $self->locationnum('') if $self->locationnum == 0 || $self->locationnum == -1;
442 $self->ut_numbern('pkgnum')
443 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
444 || $self->ut_numbern('pkgpart')
445 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
446 || $self->ut_numbern('setup')
447 || $self->ut_numbern('bill')
448 || $self->ut_numbern('susp')
449 || $self->ut_numbern('cancel')
450 || $self->ut_numbern('adjourn')
451 || $self->ut_numbern('expire')
453 return $error if $error;
455 if ( $self->reg_code ) {
457 unless ( grep { $self->pkgpart == $_->pkgpart }
458 map { $_->reg_code_pkg }
459 qsearchs( 'reg_code', { 'code' => $self->reg_code,
460 'agentnum' => $self->cust_main->agentnum })
462 return "Unknown registration code";
465 } elsif ( $self->promo_code ) {
468 qsearchs('part_pkg', {
469 'pkgpart' => $self->pkgpart,
470 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
472 return 'Unknown promotional code' unless $promo_part_pkg;
476 unless ( $disable_agentcheck ) {
478 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
479 my $pkgpart_href = $agent->pkgpart_hashref;
480 return "agent ". $agent->agentnum.
481 " can't purchase pkgpart ". $self->pkgpart
482 unless $pkgpart_href->{ $self->pkgpart };
485 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
486 return $error if $error;
490 $self->otaker(getotaker) unless $self->otaker;
491 $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
494 if ( $self->dbdef_table->column('manual_flag') ) {
495 $self->manual_flag('') if $self->manual_flag eq ' ';
496 $self->manual_flag =~ /^([01]?)$/
497 or return "Illegal manual_flag ". $self->manual_flag;
498 $self->manual_flag($1);
504 =item cancel [ OPTION => VALUE ... ]
506 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
507 in this package, then cancels the package itself (sets the cancel field to
510 Available options are:
514 =item quiet - can be set true to supress email cancellation notices.
516 =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.
518 =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.
520 =item date - can be set to a unix style timestamp to specify when to cancel (expire)
524 If there is an error, returns the error, otherwise returns false.
529 my( $self, %options ) = @_;
532 warn "cust_pkg::cancel called with options".
533 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
536 local $SIG{HUP} = 'IGNORE';
537 local $SIG{INT} = 'IGNORE';
538 local $SIG{QUIT} = 'IGNORE';
539 local $SIG{TERM} = 'IGNORE';
540 local $SIG{TSTP} = 'IGNORE';
541 local $SIG{PIPE} = 'IGNORE';
543 my $oldAutoCommit = $FS::UID::AutoCommit;
544 local $FS::UID::AutoCommit = 0;
547 my $old = $self->select_for_update;
549 if ( $old->get('cancel') || $self->get('cancel') ) {
550 dbh->rollback if $oldAutoCommit;
551 return ""; # no error
554 my $date = $options{date} if $options{date}; # expire/cancel later
555 $date = '' if ($date && $date <= time); # complain instead?
557 my $cancel_time = $options{'time'} || time;
559 if ( $options{'reason'} ) {
560 $error = $self->insert_reason( 'reason' => $options{'reason'},
561 'action' => $date ? 'expire' : 'cancel',
562 'date' => $date ? $date : $cancel_time,
563 'reason_otaker' => $options{'reason_otaker'},
566 dbh->rollback if $oldAutoCommit;
567 return "Error inserting cust_pkg_reason: $error";
573 foreach my $cust_svc (
576 sort { $a->[1] <=> $b->[1] }
577 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
578 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
581 my $error = $cust_svc->cancel;
584 $dbh->rollback if $oldAutoCommit;
585 return "Error cancelling cust_svc: $error";
589 # Add a credit for remaining service
590 my $remaining_value = $self->calc_remain(time=>$cancel_time);
591 if ( $remaining_value > 0 && !$options{'no_credit'} ) {
592 my $conf = new FS::Conf;
593 my $error = $self->cust_main->credit(
595 'Credit for unused time on '. $self->part_pkg->pkg,
596 'reason_type' => $conf->config('cancel_credit_type'),
599 $dbh->rollback if $oldAutoCommit;
600 return "Error crediting customer \$$remaining_value for unused time on".
601 $self->part_pkg->pkg. ": $error";
606 my %hash = $self->hash;
607 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
608 my $new = new FS::cust_pkg ( \%hash );
609 $error = $new->replace( $self, options => { $self->options } );
611 $dbh->rollback if $oldAutoCommit;
615 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
616 return '' if $date; #no errors
618 my $conf = new FS::Conf;
619 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
620 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
621 my $conf = new FS::Conf;
622 my $error = send_email(
623 'from' => $conf->config('invoice_from'),
624 'to' => \@invoicing_list,
625 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
626 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
628 #should this do something on errors?
635 =item cancel_if_expired [ NOW_TIMESTAMP ]
637 Cancels this package if its expire date has been reached.
641 sub cancel_if_expired {
643 my $time = shift || time;
644 return '' unless $self->expire && $self->expire <= $time;
645 my $error = $self->cancel;
647 return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
648 $self->custnum. ": $error";
655 Cancels any pending expiration (sets the expire field to null).
657 If there is an error, returns the error, otherwise returns false.
662 my( $self, %options ) = @_;
665 local $SIG{HUP} = 'IGNORE';
666 local $SIG{INT} = 'IGNORE';
667 local $SIG{QUIT} = 'IGNORE';
668 local $SIG{TERM} = 'IGNORE';
669 local $SIG{TSTP} = 'IGNORE';
670 local $SIG{PIPE} = 'IGNORE';
672 my $oldAutoCommit = $FS::UID::AutoCommit;
673 local $FS::UID::AutoCommit = 0;
676 my $old = $self->select_for_update;
678 my $pkgnum = $old->pkgnum;
679 if ( $old->get('cancel') || $self->get('cancel') ) {
680 dbh->rollback if $oldAutoCommit;
681 return "Can't unexpire cancelled package $pkgnum";
682 # or at least it's pointless
685 unless ( $old->get('expire') && $self->get('expire') ) {
686 dbh->rollback if $oldAutoCommit;
687 return ""; # no error
690 my %hash = $self->hash;
691 $hash{'expire'} = '';
692 my $new = new FS::cust_pkg ( \%hash );
693 $error = $new->replace( $self, options => { $self->options } );
695 $dbh->rollback if $oldAutoCommit;
699 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
705 =item suspend [ OPTION => VALUE ... ]
707 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
708 package, then suspends the package itself (sets the susp field to now).
710 Available options are:
714 =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.
716 =item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
720 If there is an error, returns the error, otherwise returns false.
725 my( $self, %options ) = @_;
728 local $SIG{HUP} = 'IGNORE';
729 local $SIG{INT} = 'IGNORE';
730 local $SIG{QUIT} = 'IGNORE';
731 local $SIG{TERM} = 'IGNORE';
732 local $SIG{TSTP} = 'IGNORE';
733 local $SIG{PIPE} = 'IGNORE';
735 my $oldAutoCommit = $FS::UID::AutoCommit;
736 local $FS::UID::AutoCommit = 0;
739 my $old = $self->select_for_update;
741 my $pkgnum = $old->pkgnum;
742 if ( $old->get('cancel') || $self->get('cancel') ) {
743 dbh->rollback if $oldAutoCommit;
744 return "Can't suspend cancelled package $pkgnum";
747 if ( $old->get('susp') || $self->get('susp') ) {
748 dbh->rollback if $oldAutoCommit;
749 return ""; # no error # complain on adjourn?
752 my $date = $options{date} if $options{date}; # adjourn/suspend later
753 $date = '' if ($date && $date <= time); # complain instead?
755 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
756 dbh->rollback if $oldAutoCommit;
757 return "Package $pkgnum expires before it would be suspended.";
760 my $suspend_time = $options{'time'} || time;
762 if ( $options{'reason'} ) {
763 $error = $self->insert_reason( 'reason' => $options{'reason'},
764 'action' => $date ? 'adjourn' : 'suspend',
765 'date' => $date ? $date : $suspend_time,
766 'reason_otaker' => $options{'reason_otaker'},
769 dbh->rollback if $oldAutoCommit;
770 return "Error inserting cust_pkg_reason: $error";
778 foreach my $cust_svc (
779 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
781 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
783 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
784 $dbh->rollback if $oldAutoCommit;
785 return "Illegal svcdb value in part_svc!";
788 require "FS/$svcdb.pm";
790 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
792 $error = $svc->suspend;
794 $dbh->rollback if $oldAutoCommit;
797 my( $label, $value ) = $cust_svc->label;
798 push @labels, "$label: $value";
802 my $conf = new FS::Conf;
803 if ( $conf->config('suspend_email_admin') ) {
805 my $error = send_email(
806 'from' => $conf->config('invoice_from'), #??? well as good as any
807 'to' => $conf->config('suspend_email_admin'),
808 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
810 "This is an automatic message from your Freeside installation\n",
811 "informing you that the following customer package has been suspended:\n",
813 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
814 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
815 ( map { "Service : $_\n" } @labels ),
820 warn "WARNING: can't send suspension admin email (suspending anyway): ".
828 my %hash = $self->hash;
830 $hash{'adjourn'} = $date;
832 $hash{'susp'} = $suspend_time;
834 my $new = new FS::cust_pkg ( \%hash );
835 $error = $new->replace( $self, options => { $self->options } );
837 $dbh->rollback if $oldAutoCommit;
841 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
846 =item unsuspend [ OPTION => VALUE ... ]
848 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
849 package, then unsuspends the package itself (clears the susp field and the
850 adjourn field if it is in the past).
852 Available options are:
856 =item adjust_next_bill
858 Can be set true to adjust the next bill date forward by
859 the amount of time the account was inactive. This was set true by default
860 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
861 explicitly requested. Price plans for which this makes sense (anniversary-date
862 based than prorate or subscription) could have an option to enable this
867 If there is an error, returns the error, otherwise returns false.
872 my( $self, %opt ) = @_;
875 local $SIG{HUP} = 'IGNORE';
876 local $SIG{INT} = 'IGNORE';
877 local $SIG{QUIT} = 'IGNORE';
878 local $SIG{TERM} = 'IGNORE';
879 local $SIG{TSTP} = 'IGNORE';
880 local $SIG{PIPE} = 'IGNORE';
882 my $oldAutoCommit = $FS::UID::AutoCommit;
883 local $FS::UID::AutoCommit = 0;
886 my $old = $self->select_for_update;
888 my $pkgnum = $old->pkgnum;
889 if ( $old->get('cancel') || $self->get('cancel') ) {
890 dbh->rollback if $oldAutoCommit;
891 return "Can't unsuspend cancelled package $pkgnum";
894 unless ( $old->get('susp') && $self->get('susp') ) {
895 dbh->rollback if $oldAutoCommit;
896 return ""; # no error # complain instead?
899 foreach my $cust_svc (
900 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
902 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
904 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
905 $dbh->rollback if $oldAutoCommit;
906 return "Illegal svcdb value in part_svc!";
909 require "FS/$svcdb.pm";
911 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
913 $error = $svc->unsuspend;
915 $dbh->rollback if $oldAutoCommit;
922 my %hash = $self->hash;
923 my $inactive = time - $hash{'susp'};
925 my $conf = new FS::Conf;
927 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
928 if ( $opt{'adjust_next_bill'}
929 || $conf->config('unsuspend-always_adjust_next_bill_date') )
930 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
933 $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
934 my $new = new FS::cust_pkg ( \%hash );
935 $error = $new->replace( $self, options => { $self->options } );
937 $dbh->rollback if $oldAutoCommit;
941 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
948 Cancels any pending suspension (sets the adjourn field to null).
950 If there is an error, returns the error, otherwise returns false.
955 my( $self, %options ) = @_;
958 local $SIG{HUP} = 'IGNORE';
959 local $SIG{INT} = 'IGNORE';
960 local $SIG{QUIT} = 'IGNORE';
961 local $SIG{TERM} = 'IGNORE';
962 local $SIG{TSTP} = 'IGNORE';
963 local $SIG{PIPE} = 'IGNORE';
965 my $oldAutoCommit = $FS::UID::AutoCommit;
966 local $FS::UID::AutoCommit = 0;
969 my $old = $self->select_for_update;
971 my $pkgnum = $old->pkgnum;
972 if ( $old->get('cancel') || $self->get('cancel') ) {
973 dbh->rollback if $oldAutoCommit;
974 return "Can't unadjourn cancelled package $pkgnum";
975 # or at least it's pointless
978 if ( $old->get('susp') || $self->get('susp') ) {
979 dbh->rollback if $oldAutoCommit;
980 return "Can't unadjourn suspended package $pkgnum";
981 # perhaps this is arbitrary
984 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
985 dbh->rollback if $oldAutoCommit;
986 return ""; # no error
989 my %hash = $self->hash;
990 $hash{'adjourn'} = '';
991 my $new = new FS::cust_pkg ( \%hash );
992 $error = $new->replace( $self, options => { $self->options } );
994 $dbh->rollback if $oldAutoCommit;
998 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1006 Returns the last bill date, or if there is no last bill date, the setup date.
1007 Useful for billing metered services.
1013 return $self->setfield('last_bill', $_[0]) if @_;
1014 return $self->getfield('last_bill') if $self->getfield('last_bill');
1015 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1016 'edate' => $self->bill, } );
1017 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1020 =item last_cust_pkg_reason ACTION
1022 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1023 Returns false if there is no reason or the package is not currenly ACTION'd
1024 ACTION is one of adjourn, susp, cancel, or expire.
1028 sub last_cust_pkg_reason {
1029 my ( $self, $action ) = ( shift, shift );
1030 my $date = $self->get($action);
1032 'table' => 'cust_pkg_reason',
1033 'hashref' => { 'pkgnum' => $self->pkgnum,
1034 'action' => substr(uc($action), 0, 1),
1037 'order_by' => 'ORDER BY num DESC LIMIT 1',
1041 =item last_reason ACTION
1043 Returns the most recent ACTION FS::reason associated with the package.
1044 Returns false if there is no reason or the package is not currenly ACTION'd
1045 ACTION is one of adjourn, susp, cancel, or expire.
1050 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1051 $cust_pkg_reason->reason
1052 if $cust_pkg_reason;
1057 Returns the definition for this billing item, as an FS::part_pkg object (see
1064 #exists( $self->{'_pkgpart'} )
1066 ? $self->{'_pkgpart'}
1067 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1072 Returns the cancelled package this package was changed from, if any.
1078 return '' unless $self->change_pkgnum;
1079 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1084 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1091 $self->part_pkg->calc_setup($self, @_);
1096 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1103 $self->part_pkg->calc_recur($self, @_);
1108 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1115 $self->part_pkg->calc_remain($self, @_);
1120 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1127 $self->part_pkg->calc_cancel($self, @_);
1132 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1138 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1141 =item cust_pkg_detail [ DETAILTYPE ]
1143 Returns any customer package details for this package (see
1144 L<FS::cust_pkg_detail>).
1146 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1150 sub cust_pkg_detail {
1152 my %hash = ( 'pkgnum' => $self->pkgnum );
1153 $hash{detailtype} = shift if @_;
1155 'table' => 'cust_pkg_detail',
1156 'hashref' => \%hash,
1157 'order_by' => 'ORDER BY weight, pkgdetailnum',
1161 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1163 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1165 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1167 If there is an error, returns the error, otherwise returns false.
1171 sub set_cust_pkg_detail {
1172 my( $self, $detailtype, @details ) = @_;
1174 local $SIG{HUP} = 'IGNORE';
1175 local $SIG{INT} = 'IGNORE';
1176 local $SIG{QUIT} = 'IGNORE';
1177 local $SIG{TERM} = 'IGNORE';
1178 local $SIG{TSTP} = 'IGNORE';
1179 local $SIG{PIPE} = 'IGNORE';
1181 my $oldAutoCommit = $FS::UID::AutoCommit;
1182 local $FS::UID::AutoCommit = 0;
1185 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1186 my $error = $current->delete;
1188 $dbh->rollback if $oldAutoCommit;
1189 return "error removing old detail: $error";
1193 foreach my $detail ( @details ) {
1194 my $cust_pkg_detail = new FS::cust_pkg_detail {
1195 'pkgnum' => $self->pkgnum,
1196 'detailtype' => $detailtype,
1197 'detail' => $detail,
1199 my $error = $cust_pkg_detail->insert;
1201 $dbh->rollback if $oldAutoCommit;
1202 return "error adding new detail: $error";
1207 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1214 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
1218 #false laziness w/cust_bill.pm
1222 'table' => 'cust_event',
1223 'addl_from' => 'JOIN part_event USING ( eventpart )',
1224 'hashref' => { 'tablenum' => $self->pkgnum },
1225 'extra_sql' => " AND eventtable = 'cust_pkg' ",
1229 =item num_cust_event
1231 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
1235 #false laziness w/cust_bill.pm
1236 sub num_cust_event {
1239 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
1240 " WHERE tablenum = ? AND eventtable = 'cust_pkg'";
1241 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
1242 $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
1243 $sth->fetchrow_arrayref->[0];
1246 =item cust_svc [ SVCPART ]
1248 Returns the services for this package, as FS::cust_svc objects (see
1249 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1258 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1259 'svcpart' => shift, } );
1262 #if ( $self->{'_svcnum'} ) {
1263 # values %{ $self->{'_svcnum'}->cache };
1265 $self->_sort_cust_svc(
1266 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1272 =item overlimit [ SVCPART ]
1274 Returns the services for this package which have exceeded their
1275 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1276 is specified, return only the matching services.
1282 grep { $_->overlimit } $self->cust_svc;
1285 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1287 Returns historical services for this package created before END TIMESTAMP and
1288 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1289 (see L<FS::h_cust_svc>).
1296 $self->_sort_cust_svc(
1297 [ qsearch( 'h_cust_svc',
1298 { 'pkgnum' => $self->pkgnum, },
1299 FS::h_cust_svc->sql_h_search(@_),
1305 sub _sort_cust_svc {
1306 my( $self, $arrayref ) = @_;
1309 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1311 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1312 'svcpart' => $_->svcpart } );
1314 $pkg_svc ? $pkg_svc->primary_svc : '',
1315 $pkg_svc ? $pkg_svc->quantity : 0,
1322 =item num_cust_svc [ SVCPART ]
1324 Returns the number of provisioned services for this package. If a svcpart is
1325 specified, counts only the matching services.
1331 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1332 $sql .= ' AND svcpart = ?' if @_;
1333 my $sth = dbh->prepare($sql) or die dbh->errstr;
1334 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1335 $sth->fetchrow_arrayref->[0];
1338 =item available_part_svc
1340 Returns a list of FS::part_svc objects representing services included in this
1341 package but not yet provisioned. Each FS::part_svc object also has an extra
1342 field, I<num_avail>, which specifies the number of available services.
1346 sub available_part_svc {
1348 grep { $_->num_avail > 0 }
1350 my $part_svc = $_->part_svc;
1351 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1352 $_->quantity - $self->num_cust_svc($_->svcpart);
1355 $self->part_pkg->pkg_svc;
1360 Returns a list of FS::part_svc objects representing provisioned and available
1361 services included in this package. Each FS::part_svc object also has the
1362 following extra fields:
1366 =item num_cust_svc (count)
1368 =item num_avail (quantity - count)
1370 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1373 label -> ($cust_svc->label)[1]
1382 #XXX some sort of sort order besides numeric by svcpart...
1383 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1385 my $part_svc = $pkg_svc->part_svc;
1386 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1387 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1388 $part_svc->{'Hash'}{'num_avail'} =
1389 max( 0, $pkg_svc->quantity - $num_cust_svc );
1390 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1392 } $self->part_pkg->pkg_svc;
1395 push @part_svc, map {
1397 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1398 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1399 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1400 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1402 } $self->extra_part_svc;
1408 =item extra_part_svc
1410 Returns a list of FS::part_svc objects corresponding to services in this
1411 package which are still provisioned but not (any longer) available in the
1416 sub extra_part_svc {
1419 my $pkgnum = $self->pkgnum;
1420 my $pkgpart = $self->pkgpart;
1423 'table' => 'part_svc',
1425 'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1426 WHERE pkg_svc.svcpart = part_svc.svcpart
1427 AND pkg_svc.pkgpart = $pkgpart
1430 AND 0 < ( SELECT count(*)
1432 LEFT JOIN cust_pkg using ( pkgnum )
1433 WHERE cust_svc.svcpart = part_svc.svcpart
1434 AND pkgnum = $pkgnum
1441 Returns a short status string for this package, currently:
1445 =item not yet billed
1447 =item one-time charge
1462 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1464 return 'cancelled' if $self->get('cancel');
1465 return 'suspended' if $self->susp;
1466 return 'not yet billed' unless $self->setup;
1467 return 'one-time charge' if $freq =~ /^(0|$)/;
1473 Class method that returns the list of possible status strings for packages
1474 (see L<the status method|/status>). For example:
1476 @statuses = FS::cust_pkg->statuses();
1480 tie my %statuscolor, 'Tie::IxHash',
1481 'not yet billed' => '000000',
1482 'one-time charge' => '000000',
1483 'active' => '00CC00',
1484 'suspended' => 'FF9900',
1485 'cancelled' => 'FF0000',
1489 my $self = shift; #could be class...
1490 grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1491 # mayble split btw one-time vs. recur
1497 Returns a hex triplet color string for this package's status.
1503 $statuscolor{$self->status};
1508 Returns a list of lists, calling the label method for all services
1509 (see L<FS::cust_svc>) of this billing item.
1515 map { [ $_->label ] } $self->cust_svc;
1518 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1520 Like the labels method, but returns historical information on services that
1521 were active as of END_TIMESTAMP and (optionally) not cancelled before
1524 Returns a list of lists, calling the label method for all (historical) services
1525 (see L<FS::h_cust_svc>) of this billing item.
1531 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1534 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1536 Like h_labels, except returns a simple flat list, and shortens long
1537 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1538 identical services to one line that lists the service label and the number of
1539 individual services rather than individual items.
1543 sub h_labels_short {
1546 my $conf = new FS::Conf;
1547 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1550 #tie %labels, 'Tie::IxHash';
1551 push @{ $labels{$_->[0]} }, $_->[1]
1552 foreach $self->h_labels(@_);
1554 foreach my $label ( keys %labels ) {
1556 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1557 my $num = scalar(@values);
1558 if ( $num > $max_same_services ) {
1559 push @labels, "$label ($num)";
1561 push @labels, map { "$label: $_" } @values;
1571 Returns the parent customer object (see L<FS::cust_main>).
1577 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1582 Returns the location object, if any (see L<FS::cust_location>).
1588 return '' unless $self->locationnum;
1589 qsearchs( 'cust_location', { 'locationnum' => $self->locationnum } );
1592 =item cust_location_or_main
1594 If this package is associated with a location, returns the locaiton (see
1595 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
1599 sub cust_location_or_main {
1601 $self->cust_location || $self->cust_main;
1604 =item seconds_since TIMESTAMP
1606 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1607 package have been online since TIMESTAMP, according to the session monitor.
1609 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1610 L<Time::Local> and L<Date::Parse> for conversion functions.
1615 my($self, $since) = @_;
1618 foreach my $cust_svc (
1619 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1621 $seconds += $cust_svc->seconds_since($since);
1628 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1630 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1631 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1634 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1635 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1641 sub seconds_since_sqlradacct {
1642 my($self, $start, $end) = @_;
1646 foreach my $cust_svc (
1648 my $part_svc = $_->part_svc;
1649 $part_svc->svcdb eq 'svc_acct'
1650 && scalar($part_svc->part_export('sqlradius'));
1653 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1660 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1662 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1663 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1667 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1668 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1673 sub attribute_since_sqlradacct {
1674 my($self, $start, $end, $attrib) = @_;
1678 foreach my $cust_svc (
1680 my $part_svc = $_->part_svc;
1681 $part_svc->svcdb eq 'svc_acct'
1682 && scalar($part_svc->part_export('sqlradius'));
1685 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1697 my( $self, $value ) = @_;
1698 if ( defined($value) ) {
1699 $self->setfield('quantity', $value);
1701 $self->getfield('quantity') || 1;
1704 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1706 Transfers as many services as possible from this package to another package.
1708 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1709 object. The destination package must already exist.
1711 Services are moved only if the destination allows services with the correct
1712 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1713 this option with caution! No provision is made for export differences
1714 between the old and new service definitions. Probably only should be used
1715 when your exports for all service definitions of a given svcdb are identical.
1716 (attempt a transfer without it first, to move all possible svcpart-matching
1719 Any services that can't be moved remain in the original package.
1721 Returns an error, if there is one; otherwise, returns the number of services
1722 that couldn't be moved.
1727 my ($self, $dest_pkgnum, %opt) = @_;
1733 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1734 $dest = $dest_pkgnum;
1735 $dest_pkgnum = $dest->pkgnum;
1737 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1740 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1742 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1743 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1746 foreach my $cust_svc ($dest->cust_svc) {
1747 $target{$cust_svc->svcpart}--;
1750 my %svcpart2svcparts = ();
1751 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1752 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1753 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1754 next if exists $svcpart2svcparts{$svcpart};
1755 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1756 $svcpart2svcparts{$svcpart} = [
1758 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1760 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1761 'svcpart' => $_ } );
1763 $pkg_svc ? $pkg_svc->primary_svc : '',
1764 $pkg_svc ? $pkg_svc->quantity : 0,
1768 grep { $_ != $svcpart }
1770 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1772 warn "alternates for svcpart $svcpart: ".
1773 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1778 foreach my $cust_svc ($self->cust_svc) {
1779 if($target{$cust_svc->svcpart} > 0) {
1780 $target{$cust_svc->svcpart}--;
1781 my $new = new FS::cust_svc { $cust_svc->hash };
1782 $new->pkgnum($dest_pkgnum);
1783 my $error = $new->replace($cust_svc);
1784 return $error if $error;
1785 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1787 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1788 warn "alternates to consider: ".
1789 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1791 my @alternate = grep {
1792 warn "considering alternate svcpart $_: ".
1793 "$target{$_} available in new package\n"
1796 } @{$svcpart2svcparts{$cust_svc->svcpart}};
1798 warn "alternate(s) found\n" if $DEBUG;
1799 my $change_svcpart = $alternate[0];
1800 $target{$change_svcpart}--;
1801 my $new = new FS::cust_svc { $cust_svc->hash };
1802 $new->svcpart($change_svcpart);
1803 $new->pkgnum($dest_pkgnum);
1804 my $error = $new->replace($cust_svc);
1805 return $error if $error;
1818 This method is deprecated. See the I<depend_jobnum> option to the insert and
1819 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1826 local $SIG{HUP} = 'IGNORE';
1827 local $SIG{INT} = 'IGNORE';
1828 local $SIG{QUIT} = 'IGNORE';
1829 local $SIG{TERM} = 'IGNORE';
1830 local $SIG{TSTP} = 'IGNORE';
1831 local $SIG{PIPE} = 'IGNORE';
1833 my $oldAutoCommit = $FS::UID::AutoCommit;
1834 local $FS::UID::AutoCommit = 0;
1837 foreach my $cust_svc ( $self->cust_svc ) {
1838 #false laziness w/svc_Common::insert
1839 my $svc_x = $cust_svc->svc_x;
1840 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1841 my $error = $part_export->export_insert($svc_x);
1843 $dbh->rollback if $oldAutoCommit;
1849 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1856 =head1 CLASS METHODS
1862 Returns an SQL expression identifying recurring packages.
1866 sub recurring_sql { "
1867 '0' != ( select freq from part_pkg
1868 where cust_pkg.pkgpart = part_pkg.pkgpart )
1873 Returns an SQL expression identifying one-time packages.
1878 '0' = ( select freq from part_pkg
1879 where cust_pkg.pkgpart = part_pkg.pkgpart )
1884 Returns an SQL expression identifying active packages.
1889 ". $_[0]->recurring_sql(). "
1890 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1891 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1896 Returns an SQL expression identifying inactive packages (one-time packages
1897 that are otherwise unsuspended/uncancelled).
1901 sub inactive_sql { "
1902 ". $_[0]->onetime_sql(). "
1903 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1904 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1910 Returns an SQL expression identifying suspended packages.
1914 sub suspended_sql { susp_sql(@_); }
1916 #$_[0]->recurring_sql(). ' AND '.
1918 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1919 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
1926 Returns an SQL exprression identifying cancelled packages.
1930 sub cancelled_sql { cancel_sql(@_); }
1932 #$_[0]->recurring_sql(). ' AND '.
1933 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1936 =item search_sql HASHREF
1940 Returns a qsearch hash expression to search for parameters specified in HASHREF.
1941 Valid parameters are
1949 active, inactive, suspended, cancel (or cancelled)
1953 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
1963 arrayref of beginning and ending epoch date
1967 arrayref of beginning and ending epoch date
1971 arrayref of beginning and ending epoch date
1975 arrayref of beginning and ending epoch date
1979 arrayref of beginning and ending epoch date
1983 arrayref of beginning and ending epoch date
1987 arrayref of beginning and ending epoch date
1991 pkgnum or APKG_pkgnum
1995 a value suited to passing to FS::UI::Web::cust_header
1999 specifies the user for agent virtualization
2006 my ($class, $params) = @_;
2013 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2015 "cust_main.agentnum = $1";
2022 if ( $params->{'magic'} eq 'active'
2023 || $params->{'status'} eq 'active' ) {
2025 push @where, FS::cust_pkg->active_sql();
2027 } elsif ( $params->{'magic'} eq 'inactive'
2028 || $params->{'status'} eq 'inactive' ) {
2030 push @where, FS::cust_pkg->inactive_sql();
2032 } elsif ( $params->{'magic'} eq 'suspended'
2033 || $params->{'status'} eq 'suspended' ) {
2035 push @where, FS::cust_pkg->suspended_sql();
2037 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
2038 || $params->{'status'} =~ /^cancell?ed$/ ) {
2040 push @where, FS::cust_pkg->cancelled_sql();
2042 } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
2044 push @where, FS::cust_pkg->inactive_sql();
2049 # parse package class
2052 #false lazinessish w/graph/cust_bill_pkg.cgi
2055 if ( exists($params->{'classnum'})
2056 && $params->{'classnum'} =~ /^(\d*)$/
2060 if ( $classnum ) { #a specific class
2061 push @where, "classnum = $classnum";
2063 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
2064 #die "classnum $classnum not found!" unless $pkg_class[0];
2065 #$title .= $pkg_class[0]->classname.' ';
2067 } elsif ( $classnum eq '' ) { #the empty class
2069 push @where, "classnum IS NULL";
2070 #$title .= 'Empty class ';
2071 #@pkg_class = ( '(empty class)' );
2072 } elsif ( $classnum eq '0' ) {
2073 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
2074 #push @pkg_class, '(empty class)';
2076 die "illegal classnum";
2085 my $pkgpart = join (' OR pkgpart=',
2086 grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
2087 push @where, '(pkgpart=' . $pkgpart . ')' if $pkgpart;
2095 #false laziness w/report_cust_pkg.html
2098 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
2099 'active' => { 'susp'=>1, 'cancel'=>1 },
2100 'suspended' => { 'cancel' => 1 },
2105 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
2107 next unless exists($params->{$field});
2109 my($beginning, $ending) = @{$params->{$field}};
2111 next if $beginning == 0 && $ending == 4294967295;
2114 "cust_pkg.$field IS NOT NULL",
2115 "cust_pkg.$field >= $beginning",
2116 "cust_pkg.$field <= $ending";
2118 $orderby ||= "ORDER BY cust_pkg.$field";
2122 $orderby ||= 'ORDER BY bill';
2125 # parse magic, legacy, etc.
2128 if ( $params->{'magic'} &&
2129 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
2132 $orderby = 'ORDER BY pkgnum';
2134 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2135 push @where, "pkgpart = $1";
2138 } elsif ( $params->{'query'} eq 'pkgnum' ) {
2140 $orderby = 'ORDER BY pkgnum';
2142 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2144 $orderby = 'ORDER BY pkgnum';
2147 SELECT count(*) FROM pkg_svc
2148 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
2149 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2150 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
2151 AND cust_svc.svcpart = pkg_svc.svcpart
2158 # setup queries, links, subs, etc. for the search
2161 # here is the agent virtualization
2162 if ($params->{CurrentUser}) {
2164 qsearchs('access_user', { username => $params->{CurrentUser} });
2167 push @where, $access_user->agentnums_sql('table'=>'cust_main');
2172 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
2175 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2177 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
2178 'LEFT JOIN part_pkg USING ( pkgpart ) '.
2179 'LEFT JOIN pkg_class USING ( classnum ) ';
2181 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2184 'table' => 'cust_pkg',
2186 'select' => join(', ',
2188 ( map "part_pkg.$_", qw( pkg freq ) ),
2189 'pkg_class.classname',
2190 'cust_main.custnum as cust_main_custnum',
2191 FS::UI::Web::cust_sql_fields(
2192 $params->{'cust_fields'}
2195 'extra_sql' => "$extra_sql $orderby",
2196 'addl_from' => $addl_from,
2197 'count_query' => $count_query,
2206 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
2208 CUSTNUM is a customer (see L<FS::cust_main>)
2210 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2211 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2214 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2215 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
2216 new billing items. An error is returned if this is not possible (see
2217 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
2220 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2221 newly-created cust_pkg objects.
2223 REFNUM, if specified, will specify the FS::pkg_referral record to be created
2224 and inserted. Multiple FS::pkg_referral records can be created by
2225 setting I<refnum> to an array reference of refnums or a hash reference with
2226 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
2227 record will be created corresponding to cust_main.refnum.
2232 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
2234 my $conf = new FS::Conf;
2236 # Transactionize this whole mess
2237 local $SIG{HUP} = 'IGNORE';
2238 local $SIG{INT} = 'IGNORE';
2239 local $SIG{QUIT} = 'IGNORE';
2240 local $SIG{TERM} = 'IGNORE';
2241 local $SIG{TSTP} = 'IGNORE';
2242 local $SIG{PIPE} = 'IGNORE';
2244 my $oldAutoCommit = $FS::UID::AutoCommit;
2245 local $FS::UID::AutoCommit = 0;
2249 my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2250 return "Customer not found: $custnum" unless $cust_main;
2252 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2255 my $change = scalar(@old_cust_pkg) != 0;
2258 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2262 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
2264 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( setup );
2265 $hash{'setup'} = $time if $old_cust_pkg[0]->setup;
2267 $hash{'change_date'} = $time;
2268 $hash{"change_$_"} = $old_cust_pkg[0]->$_() foreach qw( pkgnum pkgpart );
2271 # Create the new packages.
2272 foreach my $pkgpart (@$pkgparts) {
2273 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2274 pkgpart => $pkgpart,
2278 $error = $cust_pkg->insert( 'change' => $change );
2280 $dbh->rollback if $oldAutoCommit;
2283 push @$return_cust_pkg, $cust_pkg;
2285 # $return_cust_pkg now contains refs to all of the newly
2288 # Transfer services and cancel old packages.
2289 foreach my $old_pkg (@old_cust_pkg) {
2291 foreach my $new_pkg (@$return_cust_pkg) {
2292 $error = $old_pkg->transfer($new_pkg);
2293 if ($error and $error == 0) {
2294 # $old_pkg->transfer failed.
2295 $dbh->rollback if $oldAutoCommit;
2300 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2301 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2302 foreach my $new_pkg (@$return_cust_pkg) {
2303 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2304 if ($error and $error == 0) {
2305 # $old_pkg->transfer failed.
2306 $dbh->rollback if $oldAutoCommit;
2313 # Transfers were successful, but we went through all of the
2314 # new packages and still had services left on the old package.
2315 # We can't cancel the package under the circumstances, so abort.
2316 $dbh->rollback if $oldAutoCommit;
2317 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2319 $error = $old_pkg->cancel( quiet=>1 );
2325 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2329 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2331 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2332 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2335 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2336 replace. The services (see L<FS::cust_svc>) are moved to the
2337 new billing items. An error is returned if this is not possible (see
2340 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2341 newly-created cust_pkg objects.
2346 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2348 # Transactionize this whole mess
2349 local $SIG{HUP} = 'IGNORE';
2350 local $SIG{INT} = 'IGNORE';
2351 local $SIG{QUIT} = 'IGNORE';
2352 local $SIG{TERM} = 'IGNORE';
2353 local $SIG{TSTP} = 'IGNORE';
2354 local $SIG{PIPE} = 'IGNORE';
2356 my $oldAutoCommit = $FS::UID::AutoCommit;
2357 local $FS::UID::AutoCommit = 0;
2361 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2364 while(scalar(@old_cust_pkg)) {
2366 my $custnum = $old_cust_pkg[0]->custnum;
2367 my (@remove) = map { $_->pkgnum }
2368 grep { $_->custnum == $custnum } @old_cust_pkg;
2369 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2371 my $error = order $custnum, $pkgparts, \@remove, \@return;
2373 push @errors, $error
2375 push @$return_cust_pkg, @return;
2378 if (scalar(@errors)) {
2379 $dbh->rollback if $oldAutoCommit;
2380 return join(' / ', @errors);
2383 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2389 Associates this package with a (suspension or cancellation) reason (see
2390 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2393 Available options are:
2399 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.
2403 the access_user (see L<FS::access_user>) providing the reason
2411 the action (cancel, susp, adjourn, expire) associated with the reason
2415 If there is an error, returns the error, otherwise returns false.
2420 my ($self, %options) = @_;
2422 my $otaker = $options{reason_otaker} ||
2423 $FS::CurrentUser::CurrentUser->username;
2426 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2430 } elsif ( ref($options{'reason'}) ) {
2432 return 'Enter a new reason (or select an existing one)'
2433 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2435 my $reason = new FS::reason({
2436 'reason_type' => $options{'reason'}->{'typenum'},
2437 'reason' => $options{'reason'}->{'reason'},
2439 my $error = $reason->insert;
2440 return $error if $error;
2442 $reasonnum = $reason->reasonnum;
2445 return "Unparsable reason: ". $options{'reason'};
2448 my $cust_pkg_reason =
2449 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2450 'reasonnum' => $reasonnum,
2451 'otaker' => $otaker,
2452 'action' => substr(uc($options{'action'}),0,1),
2453 'date' => $options{'date'}
2458 $cust_pkg_reason->insert;
2461 =item set_usage USAGE_VALUE_HASHREF
2463 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2464 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2465 upbytes, downbytes, and totalbytes are appropriate keys.
2467 All svc_accts which are part of this package have their values reset.
2472 my ($self, $valueref) = @_;
2474 foreach my $cust_svc ($self->cust_svc){
2475 my $svc_x = $cust_svc->svc_x;
2476 $svc_x->set_usage($valueref)
2477 if $svc_x->can("set_usage");
2481 =item recharge USAGE_VALUE_HASHREF
2483 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2484 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2485 upbytes, downbytes, and totalbytes are appropriate keys.
2487 All svc_accts which are part of this package have their values incremented.
2492 my ($self, $valueref) = @_;
2494 foreach my $cust_svc ($self->cust_svc){
2495 my $svc_x = $cust_svc->svc_x;
2496 $svc_x->recharge($valueref)
2497 if $svc_x->can("recharge");
2505 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
2507 In sub order, the @pkgparts array (passed by reference) is clobbered.
2509 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
2510 method to pass dates to the recur_prog expression, it should do so.
2512 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
2513 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
2514 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
2515 configuration values. Probably need a subroutine which decides what to do
2516 based on whether or not we've fetched the user yet, rather than a hash. See
2517 FS::UID and the TODO.
2519 Now that things are transactional should the check in the insert method be
2524 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
2525 L<FS::pkg_svc>, schema.html from the base documentation