4 use vars qw(@ISA $disable_agentcheck $DEBUG);
5 use List::Util qw(max);
7 use FS::UID qw( getotaker dbh );
8 use FS::Misc qw( send_email );
9 use FS::Record qw( qsearch qsearchs );
10 use FS::cust_main_Mixin;
16 use FS::cust_bill_pkg;
20 use FS::cust_pkg_reason;
24 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
26 # because they load configuration by setting FS::UID::callback (see TODO)
32 # for sending cancel emails in sub cancel
35 @ISA = qw( FS::cust_main_Mixin FS::option_Common FS::Record );
39 $disable_agentcheck = 0;
43 my ( $hashref, $cache ) = @_;
44 #if ( $hashref->{'pkgpart'} ) {
45 if ( $hashref->{'pkg'} ) {
46 # #@{ $self->{'_pkgnum'} } = ();
47 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
48 # $self->{'_pkgpart'} = $subcache;
49 # #push @{ $self->{'_pkgnum'} },
50 # FS::part_pkg->new_or_cached($hashref, $subcache);
51 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
53 if ( exists $hashref->{'svcnum'} ) {
54 #@{ $self->{'_pkgnum'} } = ();
55 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
56 $self->{'_svcnum'} = $subcache;
57 #push @{ $self->{'_pkgnum'} },
58 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
64 FS::cust_pkg - Object methods for cust_pkg objects
70 $record = new FS::cust_pkg \%hash;
71 $record = new FS::cust_pkg { 'column' => 'value' };
73 $error = $record->insert;
75 $error = $new_record->replace($old_record);
77 $error = $record->delete;
79 $error = $record->check;
81 $error = $record->cancel;
83 $error = $record->suspend;
85 $error = $record->unsuspend;
87 $part_pkg = $record->part_pkg;
89 @labels = $record->labels;
91 $seconds = $record->seconds_since($timestamp);
93 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
94 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
98 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
99 inherits from FS::Record. The following fields are currently supported:
103 =item pkgnum - primary key (assigned automatically for new billing items)
105 =item custnum - Customer (see L<FS::cust_main>)
107 =item pkgpart - Billing item definition (see L<FS::part_pkg>)
111 =item bill - date (next bill date)
113 =item last_bill - last bill date
123 =item otaker - order taker (assigned automatically if null, see L<FS::UID>)
125 =item manual_flag - If this field is set to 1, disables the automatic
126 unsuspension of this package when using the B<unsuspendauto> config file.
128 =item quantity - If not set, defaults to 1
132 Note: setup, bill, adjourn, susp, expire and cancel are specified as UNIX timestamps;
133 see L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for
134 conversion functions.
142 Create a new billing item. To add the item to the database, see L<"insert">.
146 sub table { 'cust_pkg'; }
147 sub cust_linked { $_[0]->cust_main_custnum; }
148 sub cust_unlinked_msg {
150 "WARNING: can't find cust_main.custnum ". $self->custnum.
151 ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
154 =item insert [ OPTION => VALUE ... ]
156 Adds this billing item to the database ("Orders" the item). If there is an
157 error, returns the error, otherwise returns false.
159 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
160 will be used to look up the package definition and agent restrictions will be
163 The following options are available: I<change>
165 I<change>, if set true, supresses any referral credit to a referring customer.
170 my( $self, %options ) = @_;
172 local $SIG{HUP} = 'IGNORE';
173 local $SIG{INT} = 'IGNORE';
174 local $SIG{QUIT} = 'IGNORE';
175 local $SIG{TERM} = 'IGNORE';
176 local $SIG{TSTP} = 'IGNORE';
177 local $SIG{PIPE} = 'IGNORE';
179 my $oldAutoCommit = $FS::UID::AutoCommit;
180 local $FS::UID::AutoCommit = 0;
183 my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
185 $dbh->rollback if $oldAutoCommit;
189 #if ( $self->reg_code ) {
190 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
191 # $error = $reg_code->delete;
193 # $dbh->rollback if $oldAutoCommit;
198 my $conf = new FS::Conf;
199 my $cust_main = $self->cust_main;
200 my $part_pkg = $self->part_pkg;
201 if ( $conf->exists('referral_credit')
202 && $cust_main->referral_custnum
203 && ! $options{'change'}
204 && $part_pkg->freq !~ /^0\D?$/
207 my $referring_cust_main = $cust_main->referring_cust_main;
208 if ( $referring_cust_main->status ne 'cancelled' ) {
210 if ( $part_pkg->freq !~ /^\d+$/ ) {
211 warn 'WARNING: Not crediting customer '. $cust_main->referral_custnum.
212 ' for package '. $self->pkgnum.
213 ' ( customer '. $self->custnum. ')'.
214 ' - One-time referral credits not (yet) available for '.
215 ' packages with '. $part_pkg->freq_pretty. ' frequency';
218 my $amount = sprintf( "%.2f", $part_pkg->base_recur / $part_pkg->freq );
220 $referring_cust_main->
222 'Referral credit for '.$cust_main->name,
223 'reason_type' => $conf->config('referral_credit_type')
226 $dbh->rollback if $oldAutoCommit;
227 return "Error crediting customer ". $cust_main->referral_custnum.
228 " for referral: $error";
236 if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
237 my $queue = new FS::queue {
238 'job' => 'FS::cust_main::queueable_print',
240 $error = $queue->insert(
241 'custnum' => $self->custnum,
242 'template' => 'welcome_letter',
246 warn "can't send welcome letter: $error";
251 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
258 This method now works but you probably shouldn't use it.
260 You don't want to delete billing items, because there would then be no record
261 the customer ever purchased the item. Instead, see the cancel method.
266 # return "Can't delete cust_pkg records!";
269 =item replace OLD_RECORD
271 Replaces the OLD_RECORD with this one in the database. If there is an error,
272 returns the error, otherwise returns false.
274 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
276 Changing pkgpart may have disasterous effects. See the order subroutine.
278 setup and bill are normally updated by calling the bill method of a customer
279 object (see L<FS::cust_main>).
281 suspend is normally updated by the suspend and unsuspend methods.
283 cancel is normally updated by the cancel method (and also the order subroutine
289 my( $new, $old, %options ) = @_;
291 # We absolutely have to have an old vs. new record to make this work.
292 if (!defined($old)) {
293 $old = qsearchs( 'cust_pkg', { 'pkgnum' => $new->pkgnum } );
295 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
296 return "Can't change otaker!" if $old->otaker ne $new->otaker;
299 #return "Can't change setup once it exists!"
300 # if $old->getfield('setup') &&
301 # $old->getfield('setup') != $new->getfield('setup');
303 #some logic for bill, susp, cancel?
305 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
307 local $SIG{HUP} = 'IGNORE';
308 local $SIG{INT} = 'IGNORE';
309 local $SIG{QUIT} = 'IGNORE';
310 local $SIG{TERM} = 'IGNORE';
311 local $SIG{TSTP} = 'IGNORE';
312 local $SIG{PIPE} = 'IGNORE';
314 my $oldAutoCommit = $FS::UID::AutoCommit;
315 local $FS::UID::AutoCommit = 0;
318 foreach my $method ( qw(adjourn expire) ) { # How many reasons?
319 if ($options{'reason'} && $new->$method && $old->$method ne $new->$method) {
320 my $error = $new->insert_reason(
321 'reason' => $options{'reason'},
322 'date' => $new->$method,
324 'reason_otaker' => $options{'reason_otaker'},
327 dbh->rollback if $oldAutoCommit;
328 return "Error inserting cust_pkg_reason: $error";
333 #save off and freeze RADIUS attributes for any associated svc_acct records
335 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
337 #also check for specific exports?
338 # to avoid spurious modify export events
339 @svc_acct = map { $_->svc_x }
340 grep { $_->part_svc->svcdb eq 'svc_acct' }
343 $_->snapshot foreach @svc_acct;
347 my $error = $new->SUPER::replace($old,
348 $options{options} ? ${options{options}} : ()
351 $dbh->rollback if $oldAutoCommit;
355 #for prepaid packages,
356 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
357 foreach my $old_svc_acct ( @svc_acct ) {
358 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
359 my $s_error = $new_svc_acct->replace($old_svc_acct);
361 $dbh->rollback if $oldAutoCommit;
366 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
373 Checks all fields to make sure this is a valid billing item. If there is an
374 error, returns the error, otherwise returns false. Called by the insert and
383 $self->ut_numbern('pkgnum')
384 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
385 || $self->ut_numbern('pkgpart')
386 || $self->ut_numbern('setup')
387 || $self->ut_numbern('bill')
388 || $self->ut_numbern('susp')
389 || $self->ut_numbern('cancel')
390 || $self->ut_numbern('adjourn')
391 || $self->ut_numbern('expire')
393 return $error if $error;
395 if ( $self->reg_code ) {
397 unless ( grep { $self->pkgpart == $_->pkgpart }
398 map { $_->reg_code_pkg }
399 qsearchs( 'reg_code', { 'code' => $self->reg_code,
400 'agentnum' => $self->cust_main->agentnum })
402 return "Unknown registration code";
405 } elsif ( $self->promo_code ) {
408 qsearchs('part_pkg', {
409 'pkgpart' => $self->pkgpart,
410 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
412 return 'Unknown promotional code' unless $promo_part_pkg;
416 unless ( $disable_agentcheck ) {
418 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
419 my $pkgpart_href = $agent->pkgpart_hashref;
420 return "agent ". $agent->agentnum.
421 " can't purchase pkgpart ". $self->pkgpart
422 unless $pkgpart_href->{ $self->pkgpart };
425 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
426 return $error if $error;
430 $self->otaker(getotaker) unless $self->otaker;
431 $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
434 if ( $self->dbdef_table->column('manual_flag') ) {
435 $self->manual_flag('') if $self->manual_flag eq ' ';
436 $self->manual_flag =~ /^([01]?)$/
437 or return "Illegal manual_flag ". $self->manual_flag;
438 $self->manual_flag($1);
444 =item cancel [ OPTION => VALUE ... ]
446 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
447 in this package, then cancels the package itself (sets the cancel field to
450 Available options are: I<quiet> I<reason> I<date>
452 I<quiet> can be set true to supress email cancellation notices.
453 I<reason> can be set to a reasonnum (see L<FS::reason>) explaining the action
454 I<date> can be set to a unix style timestamp to specify when to cancel (expire)
456 If there is an error, returns the error, otherwise returns false.
461 my( $self, %options ) = @_;
464 local $SIG{HUP} = 'IGNORE';
465 local $SIG{INT} = 'IGNORE';
466 local $SIG{QUIT} = 'IGNORE';
467 local $SIG{TERM} = 'IGNORE';
468 local $SIG{TSTP} = 'IGNORE';
469 local $SIG{PIPE} = 'IGNORE';
471 my $oldAutoCommit = $FS::UID::AutoCommit;
472 local $FS::UID::AutoCommit = 0;
475 my $old = $self->select_for_update;
477 if ( $old->get('cancel') || $self->get('cancel') ) {
478 dbh->rollback if $oldAutoCommit;
479 return ""; # no error
482 my $date = $options{date} if $options{date}; # expire/cancel later
483 $date = '' if ($date && $date <= time); # complain instead?
485 if ($options{'reason'}) {
486 $error = $self->insert_reason( 'reason' => $options{'reason'},
487 'action' => $date ? 'expire' : 'cancel',
488 'reason_otaker' => $options{'reason_otaker'},
491 dbh->rollback if $oldAutoCommit;
492 return "Error inserting cust_pkg_reason: $error";
498 foreach my $cust_svc (
501 sort { $a->[1] <=> $b->[1] }
502 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
503 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
506 my $error = $cust_svc->cancel;
509 $dbh->rollback if $oldAutoCommit;
510 return "Error cancelling cust_svc: $error";
514 # Add a credit for remaining service
515 my $remaining_value = $self->calc_remain();
516 if ( $remaining_value > 0 ) {
517 my $conf = new FS::Conf;
518 my $error = $self->cust_main->credit(
520 'Credit for unused time on '. $self->part_pkg->pkg,
521 'reason_type' => $conf->config('cancel_credit_type'),
524 $dbh->rollback if $oldAutoCommit;
525 return "Error crediting customer \$$remaining_value for unused time on".
526 $self->part_pkg->pkg. ": $error";
531 my %hash = $self->hash;
532 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = time);
533 my $new = new FS::cust_pkg ( \%hash );
534 $error = $new->replace( $self, options => { $self->options } );
536 $dbh->rollback if $oldAutoCommit;
540 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
541 return '' if $date; #no errors
543 my $conf = new FS::Conf;
544 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
545 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
546 my $conf = new FS::Conf;
547 my $error = send_email(
548 'from' => $conf->config('invoice_from'),
549 'to' => \@invoicing_list,
550 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
551 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
553 #should this do something on errors?
562 Cancels any pending expiration (sets the expire field to null).
564 If there is an error, returns the error, otherwise returns false.
569 my( $self, %options ) = @_;
572 local $SIG{HUP} = 'IGNORE';
573 local $SIG{INT} = 'IGNORE';
574 local $SIG{QUIT} = 'IGNORE';
575 local $SIG{TERM} = 'IGNORE';
576 local $SIG{TSTP} = 'IGNORE';
577 local $SIG{PIPE} = 'IGNORE';
579 my $oldAutoCommit = $FS::UID::AutoCommit;
580 local $FS::UID::AutoCommit = 0;
583 my $old = $self->select_for_update;
585 my $pkgnum = $old->pkgnum;
586 if ( $old->get('cancel') || $self->get('cancel') ) {
587 dbh->rollback if $oldAutoCommit;
588 return "Can't unexpire cancelled package $pkgnum";
589 # or at least it's pointless
592 unless ( $old->get('expire') && $self->get('expire') ) {
593 dbh->rollback if $oldAutoCommit;
594 return ""; # no error
597 my %hash = $self->hash;
598 $hash{'expire'} = '';
599 my $new = new FS::cust_pkg ( \%hash );
600 $error = $new->replace( $self, options => { $self->options } );
602 $dbh->rollback if $oldAutoCommit;
606 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
612 =item suspend [ OPTION => VALUE ... ]
614 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
615 package, then suspends the package itself (sets the susp field to now).
617 Available options are: I<reason> I<date>
619 I<date> can be set to a unix style timestamp to specify when to suspend (adjourn)
620 I<reason> can be set to a reasonnum (see L<FS::reason>) explaining the action
622 If there is an error, returns the error, otherwise returns false.
627 my( $self, %options ) = @_;
630 local $SIG{HUP} = 'IGNORE';
631 local $SIG{INT} = 'IGNORE';
632 local $SIG{QUIT} = 'IGNORE';
633 local $SIG{TERM} = 'IGNORE';
634 local $SIG{TSTP} = 'IGNORE';
635 local $SIG{PIPE} = 'IGNORE';
637 my $oldAutoCommit = $FS::UID::AutoCommit;
638 local $FS::UID::AutoCommit = 0;
641 my $old = $self->select_for_update;
643 my $pkgnum = $old->pkgnum;
644 if ( $old->get('cancel') || $self->get('cancel') ) {
645 dbh->rollback if $oldAutoCommit;
646 return "Can't suspend cancelled package $pkgnum";
649 if ( $old->get('susp') || $self->get('susp') ) {
650 dbh->rollback if $oldAutoCommit;
651 return ""; # no error # complain on adjourn?
654 my $date = $options{date} if $options{date}; # adjourn/suspend later
655 $date = '' if ($date && $date <= time); # complain instead?
657 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
658 dbh->rollback if $oldAutoCommit;
659 return "Package $pkgnum expires before it would be suspended.";
662 if ($options{'reason'}) {
663 $error = $self->insert_reason( 'reason' => $options{'reason'},
664 'action' => $date ? 'adjourn' : 'suspend',
665 'reason_otaker' => $options{'reason_otaker'},
668 dbh->rollback if $oldAutoCommit;
669 return "Error inserting cust_pkg_reason: $error";
674 foreach my $cust_svc (
675 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
677 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
679 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
680 $dbh->rollback if $oldAutoCommit;
681 return "Illegal svcdb value in part_svc!";
684 require "FS/$svcdb.pm";
686 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
688 $error = $svc->suspend;
690 $dbh->rollback if $oldAutoCommit;
697 my %hash = $self->hash;
698 $date ? ($hash{'adjourn'} = $date) : ($hash{'susp'} = time);
699 my $new = new FS::cust_pkg ( \%hash );
700 $error = $new->replace( $self, options => { $self->options } );
702 $dbh->rollback if $oldAutoCommit;
706 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
711 =item unsuspend [ OPTION => VALUE ... ]
713 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
714 package, then unsuspends the package itself (clears the susp field and the
715 adjourn field if it is in the past).
717 Available options are: I<adjust_next_bill>.
719 I<adjust_next_bill> can be set true to adjust the next bill date forward by
720 the amount of time the account was inactive. This was set true by default
721 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
722 explicitly requested. Price plans for which this makes sense (anniversary-date
723 based than prorate or subscription) could have an option to enable this
726 If there is an error, returns the error, otherwise returns false.
731 my( $self, %opt ) = @_;
734 local $SIG{HUP} = 'IGNORE';
735 local $SIG{INT} = 'IGNORE';
736 local $SIG{QUIT} = 'IGNORE';
737 local $SIG{TERM} = 'IGNORE';
738 local $SIG{TSTP} = 'IGNORE';
739 local $SIG{PIPE} = 'IGNORE';
741 my $oldAutoCommit = $FS::UID::AutoCommit;
742 local $FS::UID::AutoCommit = 0;
745 my $old = $self->select_for_update;
747 my $pkgnum = $old->pkgnum;
748 if ( $old->get('cancel') || $self->get('cancel') ) {
749 dbh->rollback if $oldAutoCommit;
750 return "Can't unsuspend cancelled package $pkgnum";
753 unless ( $old->get('susp') && $self->get('susp') ) {
754 dbh->rollback if $oldAutoCommit;
755 return ""; # no error # complain instead?
758 foreach my $cust_svc (
759 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
761 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
763 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
764 $dbh->rollback if $oldAutoCommit;
765 return "Illegal svcdb value in part_svc!";
768 require "FS/$svcdb.pm";
770 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
772 $error = $svc->unsuspend;
774 $dbh->rollback if $oldAutoCommit;
781 my %hash = $self->hash;
782 my $inactive = time - $hash{'susp'};
784 my $conf = new FS::Conf;
786 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
787 if ( $opt{'adjust_next_bill'}
788 || $conf->config('unsuspend-always_adjust_next_bill_date') )
789 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
792 $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
793 my $new = new FS::cust_pkg ( \%hash );
794 $error = $new->replace( $self, options => { $self->options } );
796 $dbh->rollback if $oldAutoCommit;
800 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
807 Cancels any pending suspension (sets the adjourn field to null).
809 If there is an error, returns the error, otherwise returns false.
814 my( $self, %options ) = @_;
817 local $SIG{HUP} = 'IGNORE';
818 local $SIG{INT} = 'IGNORE';
819 local $SIG{QUIT} = 'IGNORE';
820 local $SIG{TERM} = 'IGNORE';
821 local $SIG{TSTP} = 'IGNORE';
822 local $SIG{PIPE} = 'IGNORE';
824 my $oldAutoCommit = $FS::UID::AutoCommit;
825 local $FS::UID::AutoCommit = 0;
828 my $old = $self->select_for_update;
830 my $pkgnum = $old->pkgnum;
831 if ( $old->get('cancel') || $self->get('cancel') ) {
832 dbh->rollback if $oldAutoCommit;
833 return "Can't unadjourn cancelled package $pkgnum";
834 # or at least it's pointless
837 if ( $old->get('susp') || $self->get('susp') ) {
838 dbh->rollback if $oldAutoCommit;
839 return "Can't unadjourn suspended package $pkgnum";
840 # perhaps this is arbitrary
843 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
844 dbh->rollback if $oldAutoCommit;
845 return ""; # no error
848 my %hash = $self->hash;
849 $hash{'adjourn'} = '';
850 my $new = new FS::cust_pkg ( \%hash );
851 $error = $new->replace( $self, options => { $self->options } );
853 $dbh->rollback if $oldAutoCommit;
857 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
865 Returns the last bill date, or if there is no last bill date, the setup date.
866 Useful for billing metered services.
872 if ( $self->dbdef_table->column('last_bill') ) {
873 return $self->setfield('last_bill', $_[0]) if @_;
874 return $self->getfield('last_bill') if $self->getfield('last_bill');
876 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
877 'edate' => $self->bill, } );
878 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
881 =item last_cust_pkg_reason ACTION
883 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
884 Returns false if there is no reason or the package is not currenly ACTION'd
885 ACTION is one of adjourn, susp, cancel, or expire.
889 sub last_cust_pkg_reason {
890 my ( $self, $action ) = ( shift, shift );
891 my $date = $self->get($action);
893 'table' => 'cust_pkg_reason',
894 'hashref' => { 'pkgnum' => $self->pkgnum,
895 'action' => substr(uc($action), 0, 1),
898 'order_by' => 'ORDER BY num DESC LIMIT 1',
902 =item last_reason ACTION
904 Returns the most recent ACTION FS::reason associated with the package.
905 Returns false if there is no reason or the package is not currenly ACTION'd
906 ACTION is one of adjourn, susp, cancel, or expire.
911 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
912 $cust_pkg_reason->reason
918 Returns the definition for this billing item, as an FS::part_pkg object (see
925 #exists( $self->{'_pkgpart'} )
927 ? $self->{'_pkgpart'}
928 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
933 Returns the cancelled package this package was changed from, if any.
939 return '' unless $self->change_pkgnum;
940 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
945 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
952 $self->part_pkg->calc_setup($self, @_);
957 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
964 $self->part_pkg->calc_recur($self, @_);
969 Calls the I<calc_remain> of the FS::part_pkg object associated with this
976 $self->part_pkg->calc_remain($self, @_);
981 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
988 $self->part_pkg->calc_cancel($self, @_);
993 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
999 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1002 =item cust_svc [ SVCPART ]
1004 Returns the services for this package, as FS::cust_svc objects (see
1005 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1014 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1015 'svcpart' => shift, } );
1018 #if ( $self->{'_svcnum'} ) {
1019 # values %{ $self->{'_svcnum'}->cache };
1021 $self->_sort_cust_svc(
1022 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1028 =item overlimit [ SVCPART ]
1030 Returns the services for this package which have exceeded their
1031 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1032 is specified, return only the matching services.
1038 grep { $_->overlimit } $self->cust_svc;
1041 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1043 Returns historical services for this package created before END TIMESTAMP and
1044 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1045 (see L<FS::h_cust_svc>).
1052 $self->_sort_cust_svc(
1053 [ qsearch( 'h_cust_svc',
1054 { 'pkgnum' => $self->pkgnum, },
1055 FS::h_cust_svc->sql_h_search(@_),
1061 sub _sort_cust_svc {
1062 my( $self, $arrayref ) = @_;
1065 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1067 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1068 'svcpart' => $_->svcpart } );
1070 $pkg_svc ? $pkg_svc->primary_svc : '',
1071 $pkg_svc ? $pkg_svc->quantity : 0,
1078 =item num_cust_svc [ SVCPART ]
1080 Returns the number of provisioned services for this package. If a svcpart is
1081 specified, counts only the matching services.
1087 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1088 $sql .= ' AND svcpart = ?' if @_;
1089 my $sth = dbh->prepare($sql) or die dbh->errstr;
1090 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1091 $sth->fetchrow_arrayref->[0];
1094 =item available_part_svc
1096 Returns a list of FS::part_svc objects representing services included in this
1097 package but not yet provisioned. Each FS::part_svc object also has an extra
1098 field, I<num_avail>, which specifies the number of available services.
1102 sub available_part_svc {
1104 grep { $_->num_avail > 0 }
1106 my $part_svc = $_->part_svc;
1107 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1108 $_->quantity - $self->num_cust_svc($_->svcpart);
1111 $self->part_pkg->pkg_svc;
1116 Returns a list of FS::part_svc objects representing provisioned and available
1117 services included in this package. Each FS::part_svc object also has the
1118 following extra fields:
1122 =item num_cust_svc (count)
1124 =item num_avail (quantity - count)
1126 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1129 label -> ($cust_svc->label)[1]
1138 #XXX some sort of sort order besides numeric by svcpart...
1139 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1141 my $part_svc = $pkg_svc->part_svc;
1142 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1143 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1144 $part_svc->{'Hash'}{'num_avail'} =
1145 max( 0, $pkg_svc->quantity - $num_cust_svc );
1146 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1148 } $self->part_pkg->pkg_svc;
1151 push @part_svc, map {
1153 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1154 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1155 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1156 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1158 } $self->extra_part_svc;
1164 =item extra_part_svc
1166 Returns a list of FS::part_svc objects corresponding to services in this
1167 package which are still provisioned but not (any longer) available in the
1172 sub extra_part_svc {
1175 my $pkgnum = $self->pkgnum;
1176 my $pkgpart = $self->pkgpart;
1179 'table' => 'part_svc',
1181 'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1182 WHERE pkg_svc.svcpart = part_svc.svcpart
1183 AND pkg_svc.pkgpart = $pkgpart
1186 AND 0 < ( SELECT count(*)
1188 LEFT JOIN cust_pkg using ( pkgnum )
1189 WHERE cust_svc.svcpart = part_svc.svcpart
1190 AND pkgnum = $pkgnum
1197 Returns a short status string for this package, currently:
1201 =item not yet billed
1203 =item one-time charge
1218 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1220 return 'cancelled' if $self->get('cancel');
1221 return 'suspended' if $self->susp;
1222 return 'not yet billed' unless $self->setup;
1223 return 'one-time charge' if $freq =~ /^(0|$)/;
1229 Class method that returns the list of possible status strings for pacakges
1230 (see L<the status method|/status>). For example:
1232 @statuses = FS::cust_pkg->statuses();
1236 tie my %statuscolor, 'Tie::IxHash',
1237 'not yet billed' => '000000',
1238 'one-time charge' => '000000',
1239 'active' => '00CC00',
1240 'suspended' => 'FF9900',
1241 'cancelled' => 'FF0000',
1245 my $self = shift; #could be class...
1246 grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1247 # mayble split btw one-time vs. recur
1253 Returns a hex triplet color string for this package's status.
1259 $statuscolor{$self->status};
1264 Returns a list of lists, calling the label method for all services
1265 (see L<FS::cust_svc>) of this billing item.
1271 map { [ $_->label ] } $self->cust_svc;
1274 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1276 Like the labels method, but returns historical information on services that
1277 were active as of END_TIMESTAMP and (optionally) not cancelled before
1280 Returns a list of lists, calling the label method for all (historical) services
1281 (see L<FS::h_cust_svc>) of this billing item.
1287 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1290 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1292 Like h_labels, except returns a simple flat list, and shortens long
1293 (currently >5) lists of identical services to one line that lists the service
1294 label and the number of individual services rather than individual items.
1298 sub h_labels_short {
1302 #tie %labels, 'Tie::IxHash';
1303 push @{ $labels{$_->[0]} }, $_->[1]
1304 foreach $self->h_labels(@_);
1306 foreach my $label ( keys %labels ) {
1307 my @values = @{ $labels{$label} };
1308 my $num = scalar(@values);
1310 push @labels, "$label ($num)";
1312 push @labels, map { "$label: $_" } @values;
1322 Returns the parent customer object (see L<FS::cust_main>).
1328 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1331 =item seconds_since TIMESTAMP
1333 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1334 package have been online since TIMESTAMP, according to the session monitor.
1336 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1337 L<Time::Local> and L<Date::Parse> for conversion functions.
1342 my($self, $since) = @_;
1345 foreach my $cust_svc (
1346 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1348 $seconds += $cust_svc->seconds_since($since);
1355 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1357 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1358 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1361 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1362 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1368 sub seconds_since_sqlradacct {
1369 my($self, $start, $end) = @_;
1373 foreach my $cust_svc (
1375 my $part_svc = $_->part_svc;
1376 $part_svc->svcdb eq 'svc_acct'
1377 && scalar($part_svc->part_export('sqlradius'));
1380 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1387 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1389 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1390 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1394 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1395 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1400 sub attribute_since_sqlradacct {
1401 my($self, $start, $end, $attrib) = @_;
1405 foreach my $cust_svc (
1407 my $part_svc = $_->part_svc;
1408 $part_svc->svcdb eq 'svc_acct'
1409 && scalar($part_svc->part_export('sqlradius'));
1412 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1424 my( $self, $value ) = @_;
1425 if ( defined($value) ) {
1426 $self->setfield('quantity', $value);
1428 $self->getfield('quantity') || 1;
1431 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1433 Transfers as many services as possible from this package to another package.
1435 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1436 object. The destination package must already exist.
1438 Services are moved only if the destination allows services with the correct
1439 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1440 this option with caution! No provision is made for export differences
1441 between the old and new service definitions. Probably only should be used
1442 when your exports for all service definitions of a given svcdb are identical.
1443 (attempt a transfer without it first, to move all possible svcpart-matching
1446 Any services that can't be moved remain in the original package.
1448 Returns an error, if there is one; otherwise, returns the number of services
1449 that couldn't be moved.
1454 my ($self, $dest_pkgnum, %opt) = @_;
1460 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1461 $dest = $dest_pkgnum;
1462 $dest_pkgnum = $dest->pkgnum;
1464 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1467 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1469 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1470 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1473 foreach my $cust_svc ($dest->cust_svc) {
1474 $target{$cust_svc->svcpart}--;
1477 my %svcpart2svcparts = ();
1478 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1479 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1480 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1481 next if exists $svcpart2svcparts{$svcpart};
1482 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1483 $svcpart2svcparts{$svcpart} = [
1485 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1487 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1488 'svcpart' => $_ } );
1490 $pkg_svc ? $pkg_svc->primary_svc : '',
1491 $pkg_svc ? $pkg_svc->quantity : 0,
1495 grep { $_ != $svcpart }
1497 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1499 warn "alternates for svcpart $svcpart: ".
1500 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1505 foreach my $cust_svc ($self->cust_svc) {
1506 if($target{$cust_svc->svcpart} > 0) {
1507 $target{$cust_svc->svcpart}--;
1508 my $new = new FS::cust_svc { $cust_svc->hash };
1509 $new->pkgnum($dest_pkgnum);
1510 my $error = $new->replace($cust_svc);
1511 return $error if $error;
1512 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1514 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1515 warn "alternates to consider: ".
1516 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1518 my @alternate = grep {
1519 warn "considering alternate svcpart $_: ".
1520 "$target{$_} available in new package\n"
1523 } @{$svcpart2svcparts{$cust_svc->svcpart}};
1525 warn "alternate(s) found\n" if $DEBUG;
1526 my $change_svcpart = $alternate[0];
1527 $target{$change_svcpart}--;
1528 my $new = new FS::cust_svc { $cust_svc->hash };
1529 $new->svcpart($change_svcpart);
1530 $new->pkgnum($dest_pkgnum);
1531 my $error = $new->replace($cust_svc);
1532 return $error if $error;
1545 This method is deprecated. See the I<depend_jobnum> option to the insert and
1546 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1553 local $SIG{HUP} = 'IGNORE';
1554 local $SIG{INT} = 'IGNORE';
1555 local $SIG{QUIT} = 'IGNORE';
1556 local $SIG{TERM} = 'IGNORE';
1557 local $SIG{TSTP} = 'IGNORE';
1558 local $SIG{PIPE} = 'IGNORE';
1560 my $oldAutoCommit = $FS::UID::AutoCommit;
1561 local $FS::UID::AutoCommit = 0;
1564 foreach my $cust_svc ( $self->cust_svc ) {
1565 #false laziness w/svc_Common::insert
1566 my $svc_x = $cust_svc->svc_x;
1567 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1568 my $error = $part_export->export_insert($svc_x);
1570 $dbh->rollback if $oldAutoCommit;
1576 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1583 =head1 CLASS METHODS
1589 Returns an SQL expression identifying recurring packages.
1593 sub recurring_sql { "
1594 '0' != ( select freq from part_pkg
1595 where cust_pkg.pkgpart = part_pkg.pkgpart )
1600 Returns an SQL expression identifying one-time packages.
1605 '0' = ( select freq from part_pkg
1606 where cust_pkg.pkgpart = part_pkg.pkgpart )
1611 Returns an SQL expression identifying active packages.
1616 ". $_[0]->recurring_sql(). "
1617 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1618 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1623 Returns an SQL expression identifying inactive packages (one-time packages
1624 that are otherwise unsuspended/uncancelled).
1628 sub inactive_sql { "
1629 ". $_[0]->onetime_sql(). "
1630 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1631 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1637 Returns an SQL expression identifying suspended packages.
1641 sub suspended_sql { susp_sql(@_); }
1643 #$_[0]->recurring_sql(). ' AND '.
1645 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1646 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
1653 Returns an SQL exprression identifying cancelled packages.
1657 sub cancelled_sql { cancel_sql(@_); }
1659 #$_[0]->recurring_sql(). ' AND '.
1660 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1663 =item search_sql HASHREF
1667 Returns a qsearch hash expression to search for parameters specified in HASHREF.
1668 Valid parameters are
1676 active, inactive, suspended, cancel (or cancelled)
1680 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
1690 arrayref of beginning and ending epoch date
1694 arrayref of beginning and ending epoch date
1698 arrayref of beginning and ending epoch date
1702 arrayref of beginning and ending epoch date
1706 arrayref of beginning and ending epoch date
1710 arrayref of beginning and ending epoch date
1714 arrayref of beginning and ending epoch date
1718 pkgnum or APKG_pkgnum
1722 a value suited to passing to FS::UI::Web::cust_header
1726 specifies the user for agent virtualization
1733 my ($class, $params) = @_;
1740 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
1749 if ( $params->{'magic'} eq 'active'
1750 || $params->{'status'} eq 'active' ) {
1752 push @where, FS::cust_pkg->active_sql();
1754 } elsif ( $params->{'magic'} eq 'inactive'
1755 || $params->{'status'} eq 'inactive' ) {
1757 push @where, FS::cust_pkg->inactive_sql();
1759 } elsif ( $params->{'magic'} eq 'suspended'
1760 || $params->{'status'} eq 'suspended' ) {
1762 push @where, FS::cust_pkg->suspended_sql();
1764 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
1765 || $params->{'status'} =~ /^cancell?ed$/ ) {
1767 push @where, FS::cust_pkg->cancelled_sql();
1769 } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
1771 push @where, FS::cust_pkg->inactive_sql();
1776 # parse package class
1779 #false lazinessish w/graph/cust_bill_pkg.cgi
1782 if ( exists($params->{'classnum'})
1783 && $params->{'classnum'} =~ /^(\d*)$/
1787 if ( $classnum ) { #a specific class
1788 push @where, "classnum = $classnum";
1790 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
1791 #die "classnum $classnum not found!" unless $pkg_class[0];
1792 #$title .= $pkg_class[0]->classname.' ';
1794 } elsif ( $classnum eq '' ) { #the empty class
1796 push @where, "classnum IS NULL";
1797 #$title .= 'Empty class ';
1798 #@pkg_class = ( '(empty class)' );
1799 } elsif ( $classnum eq '0' ) {
1800 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
1801 #push @pkg_class, '(empty class)';
1803 die "illegal classnum";
1812 my $pkgpart = join (' OR pkgpart=',
1813 grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
1814 push @where, '(pkgpart=' . $pkgpart . ')' if $pkgpart;
1822 #false laziness w/report_cust_pkg.html
1825 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
1826 'active' => { 'susp'=>1, 'cancel'=>1 },
1827 'suspended' => { 'cancel' => 1 },
1832 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
1834 next unless exists($params->{$field});
1836 my($beginning, $ending) = @{$params->{$field}};
1838 next if $beginning == 0 && $ending == 4294967295;
1841 "cust_pkg.$field IS NOT NULL",
1842 "cust_pkg.$field >= $beginning",
1843 "cust_pkg.$field <= $ending";
1845 $orderby ||= "ORDER BY cust_pkg.$field";
1849 $orderby ||= 'ORDER BY bill';
1852 # parse magic, legacy, etc.
1855 if ( $params->{'magic'} &&
1856 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
1859 $orderby = 'ORDER BY pkgnum';
1861 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
1862 push @where, "pkgpart = $1";
1865 } elsif ( $params->{'query'} eq 'pkgnum' ) {
1867 $orderby = 'ORDER BY pkgnum';
1869 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
1871 $orderby = 'ORDER BY pkgnum';
1874 SELECT count(*) FROM pkg_svc
1875 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
1876 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
1877 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
1878 AND cust_svc.svcpart = pkg_svc.svcpart
1885 # setup queries, links, subs, etc. for the search
1888 # here is the agent virtualization
1889 if ($params->{CurrentUser}) {
1891 qsearchs('access_user', { username => $params->{CurrentUser} });
1894 push @where, $access_user->agentnums_sql('table' => 'cust_main');
1899 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table' => 'cust_main');
1902 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
1904 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
1905 'LEFT JOIN part_pkg USING ( pkgpart ) '.
1906 'LEFT JOIN pkg_class USING ( classnum ) ';
1908 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
1911 'table' => 'cust_pkg',
1913 'select' => join(', ',
1915 ( map "part_pkg.$_", qw( pkg freq ) ),
1916 'pkg_class.classname',
1917 'cust_main.custnum as cust_main_custnum',
1918 FS::UI::Web::cust_sql_fields(
1919 $params->{'cust_fields'}
1922 'extra_sql' => "$extra_sql $orderby",
1923 'addl_from' => $addl_from,
1924 'count_query' => $count_query,
1933 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
1935 CUSTNUM is a customer (see L<FS::cust_main>)
1937 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
1938 L<FS::part_pkg>) to order for this customer. Duplicates are of course
1941 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
1942 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
1943 new billing items. An error is returned if this is not possible (see
1944 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
1947 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
1948 newly-created cust_pkg objects.
1953 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
1955 my $conf = new FS::Conf;
1957 # Transactionize this whole mess
1958 local $SIG{HUP} = 'IGNORE';
1959 local $SIG{INT} = 'IGNORE';
1960 local $SIG{QUIT} = 'IGNORE';
1961 local $SIG{TERM} = 'IGNORE';
1962 local $SIG{TSTP} = 'IGNORE';
1963 local $SIG{PIPE} = 'IGNORE';
1965 my $oldAutoCommit = $FS::UID::AutoCommit;
1966 local $FS::UID::AutoCommit = 0;
1970 my $cust_main = qsearchs('cust_main', { custnum => $custnum });
1971 return "Customer not found: $custnum" unless $cust_main;
1973 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
1976 my $change = scalar(@old_cust_pkg) != 0;
1979 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
1983 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
1985 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( setup );
1986 $hash{'setup'} = $time if $old_cust_pkg[0]->setup;
1988 $hash{'change_date'} = $time;
1989 $hash{"change_$_"} = $old_cust_pkg[0]->$_() foreach qw( pkgnum pkgpart );
1992 # Create the new packages.
1993 foreach my $pkgpart (@$pkgparts) {
1994 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
1995 pkgpart => $pkgpart,
1998 $error = $cust_pkg->insert( 'change' => $change );
2000 $dbh->rollback if $oldAutoCommit;
2003 push @$return_cust_pkg, $cust_pkg;
2005 # $return_cust_pkg now contains refs to all of the newly
2008 # Transfer services and cancel old packages.
2009 foreach my $old_pkg (@old_cust_pkg) {
2011 foreach my $new_pkg (@$return_cust_pkg) {
2012 $error = $old_pkg->transfer($new_pkg);
2013 if ($error and $error == 0) {
2014 # $old_pkg->transfer failed.
2015 $dbh->rollback if $oldAutoCommit;
2020 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2021 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2022 foreach my $new_pkg (@$return_cust_pkg) {
2023 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2024 if ($error and $error == 0) {
2025 # $old_pkg->transfer failed.
2026 $dbh->rollback if $oldAutoCommit;
2033 # Transfers were successful, but we went through all of the
2034 # new packages and still had services left on the old package.
2035 # We can't cancel the package under the circumstances, so abort.
2036 $dbh->rollback if $oldAutoCommit;
2037 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2039 $error = $old_pkg->cancel( quiet=>1 );
2045 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2049 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2051 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2052 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2055 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2056 replace. The services (see L<FS::cust_svc>) are moved to the
2057 new billing items. An error is returned if this is not possible (see
2060 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2061 newly-created cust_pkg objects.
2066 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2068 # Transactionize this whole mess
2069 local $SIG{HUP} = 'IGNORE';
2070 local $SIG{INT} = 'IGNORE';
2071 local $SIG{QUIT} = 'IGNORE';
2072 local $SIG{TERM} = 'IGNORE';
2073 local $SIG{TSTP} = 'IGNORE';
2074 local $SIG{PIPE} = 'IGNORE';
2076 my $oldAutoCommit = $FS::UID::AutoCommit;
2077 local $FS::UID::AutoCommit = 0;
2081 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2084 while(scalar(@old_cust_pkg)) {
2086 my $custnum = $old_cust_pkg[0]->custnum;
2087 my (@remove) = map { $_->pkgnum }
2088 grep { $_->custnum == $custnum } @old_cust_pkg;
2089 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2091 my $error = order $custnum, $pkgparts, \@remove, \@return;
2093 push @errors, $error
2095 push @$return_cust_pkg, @return;
2098 if (scalar(@errors)) {
2099 $dbh->rollback if $oldAutoCommit;
2100 return join(' / ', @errors);
2103 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2109 Associates this package with a (suspension or cancellation) reason (see
2110 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2113 Available options are:
2117 =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.
2119 =item otaker_reason - the access_user (see L<FS::access_user>) providing the reason
2121 =item date - a unix timestamp
2123 =item action - the action (cancel, susp, adjourn, expire) associated with the reason
2127 If there is an error, returns the error, otherwise returns false.
2132 my ($self, %options) = @_;
2134 my $otaker = $options{reason_otaker} ||
2135 $FS::CurrentUser::CurrentUser->username;
2138 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2142 } elsif ( ref($options{'reason'}) ) {
2144 return 'Enter a new reason (or select an existing one)'
2145 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2147 my $reason = new FS::reason({
2148 'reason_type' => $options{'reason'}->{'typenum'},
2149 'reason' => $options{'reason'}->{'reason'},
2151 my $error = $reason->insert;
2152 return $error if $error;
2154 $reasonnum = $reason->reasonnum;
2157 return "Unparsable reason: ". $options{'reason'};
2160 my $cust_pkg_reason =
2161 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2162 'reasonnum' => $reasonnum,
2163 'otaker' => $otaker,
2164 'action' => substr(uc($options{'action'}),0,1),
2165 'date' => $options{'date'}
2170 $cust_pkg_reason->insert;
2173 =item set_usage USAGE_VALUE_HASHREF
2175 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2176 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2177 upbytes, downbytes, and totalbytes are appropriate keys.
2179 All svc_accts which are part of this package have their values reset.
2184 my ($self, $valueref) = @_;
2186 foreach my $cust_svc ($self->cust_svc){
2187 my $svc_x = $cust_svc->svc_x;
2188 $svc_x->set_usage($valueref)
2189 if $svc_x->can("set_usage");
2197 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
2199 In sub order, the @pkgparts array (passed by reference) is clobbered.
2201 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
2202 method to pass dates to the recur_prog expression, it should do so.
2204 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
2205 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
2206 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
2207 configuration values. Probably need a subroutine which decides what to do
2208 based on whether or not we've fetched the user yet, rather than a hash. See
2209 FS::UID and the TODO.
2211 Now that things are transactional should the check in the insert method be
2216 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
2217 L<FS::pkg_svc>, schema.html from the base documentation