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;
17 use FS::cust_pkg_detail;
21 use FS::cust_pkg_reason;
25 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
27 # because they load configuration by setting FS::UID::callback (see TODO)
33 # for sending cancel emails in sub cancel
36 @ISA = qw( FS::cust_main_Mixin FS::option_Common FS::Record );
40 $disable_agentcheck = 0;
44 my ( $hashref, $cache ) = @_;
45 #if ( $hashref->{'pkgpart'} ) {
46 if ( $hashref->{'pkg'} ) {
47 # #@{ $self->{'_pkgnum'} } = ();
48 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
49 # $self->{'_pkgpart'} = $subcache;
50 # #push @{ $self->{'_pkgnum'} },
51 # FS::part_pkg->new_or_cached($hashref, $subcache);
52 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
54 if ( exists $hashref->{'svcnum'} ) {
55 #@{ $self->{'_pkgnum'} } = ();
56 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
57 $self->{'_svcnum'} = $subcache;
58 #push @{ $self->{'_pkgnum'} },
59 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
65 FS::cust_pkg - Object methods for cust_pkg objects
71 $record = new FS::cust_pkg \%hash;
72 $record = new FS::cust_pkg { 'column' => 'value' };
74 $error = $record->insert;
76 $error = $new_record->replace($old_record);
78 $error = $record->delete;
80 $error = $record->check;
82 $error = $record->cancel;
84 $error = $record->suspend;
86 $error = $record->unsuspend;
88 $part_pkg = $record->part_pkg;
90 @labels = $record->labels;
92 $seconds = $record->seconds_since($timestamp);
94 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
95 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
99 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
100 inherits from FS::Record. The following fields are currently supported:
104 =item pkgnum - primary key (assigned automatically for new billing items)
106 =item custnum - Customer (see L<FS::cust_main>)
108 =item pkgpart - Billing item definition (see L<FS::part_pkg>)
112 =item bill - date (next bill date)
114 =item last_bill - last bill date
124 =item otaker - order taker (assigned automatically if null, see L<FS::UID>)
126 =item manual_flag - If this field is set to 1, disables the automatic
127 unsuspension of this package when using the B<unsuspendauto> config file.
129 =item quantity - If not set, defaults to 1
133 Note: setup, bill, adjourn, susp, expire and cancel are specified as UNIX timestamps;
134 see L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for
135 conversion functions.
143 Create a new billing item. To add the item to the database, see L<"insert">.
147 sub table { 'cust_pkg'; }
148 sub cust_linked { $_[0]->cust_main_custnum; }
149 sub cust_unlinked_msg {
151 "WARNING: can't find cust_main.custnum ". $self->custnum.
152 ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
155 =item insert [ OPTION => VALUE ... ]
157 Adds this billing item to the database ("Orders" the item). If there is an
158 error, returns the error, otherwise returns false.
160 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
161 will be used to look up the package definition and agent restrictions will be
164 The following options are available: I<change>
166 I<change>, if set true, supresses any referral credit to a referring customer.
171 my( $self, %options ) = @_;
173 local $SIG{HUP} = 'IGNORE';
174 local $SIG{INT} = 'IGNORE';
175 local $SIG{QUIT} = 'IGNORE';
176 local $SIG{TERM} = 'IGNORE';
177 local $SIG{TSTP} = 'IGNORE';
178 local $SIG{PIPE} = 'IGNORE';
180 my $oldAutoCommit = $FS::UID::AutoCommit;
181 local $FS::UID::AutoCommit = 0;
184 my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
186 $dbh->rollback if $oldAutoCommit;
190 #if ( $self->reg_code ) {
191 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
192 # $error = $reg_code->delete;
194 # $dbh->rollback if $oldAutoCommit;
199 my $conf = new FS::Conf;
200 my $cust_main = $self->cust_main;
201 my $part_pkg = $self->part_pkg;
202 if ( $conf->exists('referral_credit')
203 && $cust_main->referral_custnum
204 && ! $options{'change'}
205 && $part_pkg->freq !~ /^0\D?$/
208 my $referring_cust_main = $cust_main->referring_cust_main;
209 if ( $referring_cust_main->status ne 'cancelled' ) {
211 if ( $part_pkg->freq !~ /^\d+$/ ) {
212 warn 'WARNING: Not crediting customer '. $cust_main->referral_custnum.
213 ' for package '. $self->pkgnum.
214 ' ( customer '. $self->custnum. ')'.
215 ' - One-time referral credits not (yet) available for '.
216 ' packages with '. $part_pkg->freq_pretty. ' frequency';
219 my $amount = sprintf( "%.2f", $part_pkg->base_recur($self) / $part_pkg->freq );
221 $referring_cust_main->
223 'Referral credit for '.$cust_main->name,
224 'reason_type' => $conf->config('referral_credit_type')
227 $dbh->rollback if $oldAutoCommit;
228 return "Error crediting customer ". $cust_main->referral_custnum.
229 " for referral: $error";
237 if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
238 my $queue = new FS::queue {
239 'job' => 'FS::cust_main::queueable_print',
241 $error = $queue->insert(
242 'custnum' => $self->custnum,
243 'template' => 'welcome_letter',
247 warn "can't send welcome letter: $error";
252 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
259 This method now works but you probably shouldn't use it.
261 You don't want to delete billing items, because there would then be no record
262 the customer ever purchased the item. Instead, see the cancel method.
267 # return "Can't delete cust_pkg records!";
270 =item replace OLD_RECORD
272 Replaces the OLD_RECORD with this one in the database. If there is an error,
273 returns the error, otherwise returns false.
275 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
277 Changing pkgpart may have disasterous effects. See the order subroutine.
279 setup and bill are normally updated by calling the bill method of a customer
280 object (see L<FS::cust_main>).
282 suspend is normally updated by the suspend and unsuspend methods.
284 cancel is normally updated by the cancel method (and also the order subroutine
290 my( $new, $old, %options ) = @_;
292 # We absolutely have to have an old vs. new record to make this work.
293 if (!defined($old)) {
294 $old = qsearchs( 'cust_pkg', { 'pkgnum' => $new->pkgnum } );
296 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
297 return "Can't change otaker!" if $old->otaker ne $new->otaker;
300 #return "Can't change setup once it exists!"
301 # if $old->getfield('setup') &&
302 # $old->getfield('setup') != $new->getfield('setup');
304 #some logic for bill, susp, cancel?
306 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
308 local $SIG{HUP} = 'IGNORE';
309 local $SIG{INT} = 'IGNORE';
310 local $SIG{QUIT} = 'IGNORE';
311 local $SIG{TERM} = 'IGNORE';
312 local $SIG{TSTP} = 'IGNORE';
313 local $SIG{PIPE} = 'IGNORE';
315 my $oldAutoCommit = $FS::UID::AutoCommit;
316 local $FS::UID::AutoCommit = 0;
319 foreach my $method ( qw(adjourn expire) ) { # How many reasons?
320 if ($options{'reason'} && $new->$method && $old->$method ne $new->$method) {
321 my $error = $new->insert_reason(
322 'reason' => $options{'reason'},
323 'date' => $new->$method,
325 'reason_otaker' => $options{'reason_otaker'},
328 dbh->rollback if $oldAutoCommit;
329 return "Error inserting cust_pkg_reason: $error";
334 #save off and freeze RADIUS attributes for any associated svc_acct records
336 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
338 #also check for specific exports?
339 # to avoid spurious modify export events
340 @svc_acct = map { $_->svc_x }
341 grep { $_->part_svc->svcdb eq 'svc_acct' }
344 $_->snapshot foreach @svc_acct;
348 my $error = $new->SUPER::replace($old,
349 $options{options} ? ${options{options}} : ()
352 $dbh->rollback if $oldAutoCommit;
356 #for prepaid packages,
357 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
358 foreach my $old_svc_acct ( @svc_acct ) {
359 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
360 my $s_error = $new_svc_acct->replace($old_svc_acct);
362 $dbh->rollback if $oldAutoCommit;
367 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
374 Checks all fields to make sure this is a valid billing item. If there is an
375 error, returns the error, otherwise returns false. Called by the insert and
384 $self->ut_numbern('pkgnum')
385 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
386 || $self->ut_numbern('pkgpart')
387 || $self->ut_numbern('setup')
388 || $self->ut_numbern('bill')
389 || $self->ut_numbern('susp')
390 || $self->ut_numbern('cancel')
391 || $self->ut_numbern('adjourn')
392 || $self->ut_numbern('expire')
394 return $error if $error;
396 if ( $self->reg_code ) {
398 unless ( grep { $self->pkgpart == $_->pkgpart }
399 map { $_->reg_code_pkg }
400 qsearchs( 'reg_code', { 'code' => $self->reg_code,
401 'agentnum' => $self->cust_main->agentnum })
403 return "Unknown registration code";
406 } elsif ( $self->promo_code ) {
409 qsearchs('part_pkg', {
410 'pkgpart' => $self->pkgpart,
411 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
413 return 'Unknown promotional code' unless $promo_part_pkg;
417 unless ( $disable_agentcheck ) {
419 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
420 my $pkgpart_href = $agent->pkgpart_hashref;
421 return "agent ". $agent->agentnum.
422 " can't purchase pkgpart ". $self->pkgpart
423 unless $pkgpart_href->{ $self->pkgpart };
426 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
427 return $error if $error;
431 $self->otaker(getotaker) unless $self->otaker;
432 $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
435 if ( $self->dbdef_table->column('manual_flag') ) {
436 $self->manual_flag('') if $self->manual_flag eq ' ';
437 $self->manual_flag =~ /^([01]?)$/
438 or return "Illegal manual_flag ". $self->manual_flag;
439 $self->manual_flag($1);
445 =item cancel [ OPTION => VALUE ... ]
447 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
448 in this package, then cancels the package itself (sets the cancel field to
451 Available options are: I<quiet> I<reason> I<date>
453 I<quiet> can be set true to supress email cancellation notices.
454 I<reason> can be set to a reasonnum (see L<FS::reason>) explaining the action
455 I<date> can be set to a unix style timestamp to specify when to cancel (expire)
457 If there is an error, returns the error, otherwise returns false.
462 my( $self, %options ) = @_;
465 local $SIG{HUP} = 'IGNORE';
466 local $SIG{INT} = 'IGNORE';
467 local $SIG{QUIT} = 'IGNORE';
468 local $SIG{TERM} = 'IGNORE';
469 local $SIG{TSTP} = 'IGNORE';
470 local $SIG{PIPE} = 'IGNORE';
472 my $oldAutoCommit = $FS::UID::AutoCommit;
473 local $FS::UID::AutoCommit = 0;
476 my $old = $self->select_for_update;
478 if ( $old->get('cancel') || $self->get('cancel') ) {
479 dbh->rollback if $oldAutoCommit;
480 return ""; # no error
483 my $date = $options{date} if $options{date}; # expire/cancel later
484 $date = '' if ($date && $date <= time); # complain instead?
486 my $cancel_time = $options{'time'} || time;
488 if ($options{'reason'}) {
489 $error = $self->insert_reason( 'reason' => $options{'reason'},
490 'action' => $date ? 'expire' : 'cancel',
491 'date' => $date ? $date : $cancel_time,
492 'reason_otaker' => $options{'reason_otaker'},
495 dbh->rollback if $oldAutoCommit;
496 return "Error inserting cust_pkg_reason: $error";
502 foreach my $cust_svc (
505 sort { $a->[1] <=> $b->[1] }
506 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
507 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
510 my $error = $cust_svc->cancel;
513 $dbh->rollback if $oldAutoCommit;
514 return "Error cancelling cust_svc: $error";
518 # Add a credit for remaining service
519 my $remaining_value = $self->calc_remain();
520 if ( $remaining_value > 0 ) {
521 my $conf = new FS::Conf;
522 my $error = $self->cust_main->credit(
524 'Credit for unused time on '. $self->part_pkg->pkg,
525 'reason_type' => $conf->config('cancel_credit_type'),
528 $dbh->rollback if $oldAutoCommit;
529 return "Error crediting customer \$$remaining_value for unused time on".
530 $self->part_pkg->pkg. ": $error";
535 my %hash = $self->hash;
536 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
537 my $new = new FS::cust_pkg ( \%hash );
538 $error = $new->replace( $self, options => { $self->options } );
540 $dbh->rollback if $oldAutoCommit;
544 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
545 return '' if $date; #no errors
547 my $conf = new FS::Conf;
548 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
549 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
550 my $conf = new FS::Conf;
551 my $error = send_email(
552 'from' => $conf->config('invoice_from'),
553 'to' => \@invoicing_list,
554 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
555 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
557 #should this do something on errors?
566 Cancels any pending expiration (sets the expire field to null).
568 If there is an error, returns the error, otherwise returns false.
573 my( $self, %options ) = @_;
576 local $SIG{HUP} = 'IGNORE';
577 local $SIG{INT} = 'IGNORE';
578 local $SIG{QUIT} = 'IGNORE';
579 local $SIG{TERM} = 'IGNORE';
580 local $SIG{TSTP} = 'IGNORE';
581 local $SIG{PIPE} = 'IGNORE';
583 my $oldAutoCommit = $FS::UID::AutoCommit;
584 local $FS::UID::AutoCommit = 0;
587 my $old = $self->select_for_update;
589 my $pkgnum = $old->pkgnum;
590 if ( $old->get('cancel') || $self->get('cancel') ) {
591 dbh->rollback if $oldAutoCommit;
592 return "Can't unexpire cancelled package $pkgnum";
593 # or at least it's pointless
596 unless ( $old->get('expire') && $self->get('expire') ) {
597 dbh->rollback if $oldAutoCommit;
598 return ""; # no error
601 my %hash = $self->hash;
602 $hash{'expire'} = '';
603 my $new = new FS::cust_pkg ( \%hash );
604 $error = $new->replace( $self, options => { $self->options } );
606 $dbh->rollback if $oldAutoCommit;
610 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
616 =item suspend [ OPTION => VALUE ... ]
618 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
619 package, then suspends the package itself (sets the susp field to now).
621 Available options are: I<reason> I<date>
623 I<date> can be set to a unix style timestamp to specify when to suspend (adjourn)
624 I<reason> can be set to a reasonnum (see L<FS::reason>) explaining the action
626 If there is an error, returns the error, otherwise returns false.
631 my( $self, %options ) = @_;
634 local $SIG{HUP} = 'IGNORE';
635 local $SIG{INT} = 'IGNORE';
636 local $SIG{QUIT} = 'IGNORE';
637 local $SIG{TERM} = 'IGNORE';
638 local $SIG{TSTP} = 'IGNORE';
639 local $SIG{PIPE} = 'IGNORE';
641 my $oldAutoCommit = $FS::UID::AutoCommit;
642 local $FS::UID::AutoCommit = 0;
645 my $old = $self->select_for_update;
647 my $pkgnum = $old->pkgnum;
648 if ( $old->get('cancel') || $self->get('cancel') ) {
649 dbh->rollback if $oldAutoCommit;
650 return "Can't suspend cancelled package $pkgnum";
653 if ( $old->get('susp') || $self->get('susp') ) {
654 dbh->rollback if $oldAutoCommit;
655 return ""; # no error # complain on adjourn?
658 my $date = $options{date} if $options{date}; # adjourn/suspend later
659 $date = '' if ($date && $date <= time); # complain instead?
661 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
662 dbh->rollback if $oldAutoCommit;
663 return "Package $pkgnum expires before it would be suspended.";
666 my $suspend_time = $options{'time'} || time;
668 if ($options{'reason'}) {
669 $error = $self->insert_reason( 'reason' => $options{'reason'},
670 'action' => $date ? 'adjourn' : 'suspend',
671 'date' => $date ? $date : $suspend_time,
672 'reason_otaker' => $options{'reason_otaker'},
675 dbh->rollback if $oldAutoCommit;
676 return "Error inserting cust_pkg_reason: $error";
684 foreach my $cust_svc (
685 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
687 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
689 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
690 $dbh->rollback if $oldAutoCommit;
691 return "Illegal svcdb value in part_svc!";
694 require "FS/$svcdb.pm";
696 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
698 $error = $svc->suspend;
700 $dbh->rollback if $oldAutoCommit;
703 my( $label, $value ) = $cust_svc->label;
704 push @labels, "$label: $value";
708 my $conf = new FS::Conf;
709 if ( $conf->config('suspend_email_admin') ) {
711 my $error = send_email(
712 'from' => $conf->config('invoice_from'), #??? well as good as any
713 'to' => $conf->config('suspend_email_admin'),
714 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
716 "This is an automatic message from your Freeside installation\n",
717 "informing you that the following customer package has been suspended:\n",
719 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
720 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
721 ( map { "Service : $_\n" } @labels ),
726 warn "WARNING: can't send suspension admin email (suspending anyway): ".
734 my %hash = $self->hash;
736 $hash{'adjourn'} = $date;
738 $hash{'susp'} = $suspend_time;
740 my $new = new FS::cust_pkg ( \%hash );
741 $error = $new->replace( $self, options => { $self->options } );
743 $dbh->rollback if $oldAutoCommit;
747 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
752 =item unsuspend [ OPTION => VALUE ... ]
754 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
755 package, then unsuspends the package itself (clears the susp field and the
756 adjourn field if it is in the past).
758 Available options are: I<adjust_next_bill>.
760 I<adjust_next_bill> can be set true to adjust the next bill date forward by
761 the amount of time the account was inactive. This was set true by default
762 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
763 explicitly requested. Price plans for which this makes sense (anniversary-date
764 based than prorate or subscription) could have an option to enable this
767 If there is an error, returns the error, otherwise returns false.
772 my( $self, %opt ) = @_;
775 local $SIG{HUP} = 'IGNORE';
776 local $SIG{INT} = 'IGNORE';
777 local $SIG{QUIT} = 'IGNORE';
778 local $SIG{TERM} = 'IGNORE';
779 local $SIG{TSTP} = 'IGNORE';
780 local $SIG{PIPE} = 'IGNORE';
782 my $oldAutoCommit = $FS::UID::AutoCommit;
783 local $FS::UID::AutoCommit = 0;
786 my $old = $self->select_for_update;
788 my $pkgnum = $old->pkgnum;
789 if ( $old->get('cancel') || $self->get('cancel') ) {
790 dbh->rollback if $oldAutoCommit;
791 return "Can't unsuspend cancelled package $pkgnum";
794 unless ( $old->get('susp') && $self->get('susp') ) {
795 dbh->rollback if $oldAutoCommit;
796 return ""; # no error # complain instead?
799 foreach my $cust_svc (
800 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
802 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
804 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
805 $dbh->rollback if $oldAutoCommit;
806 return "Illegal svcdb value in part_svc!";
809 require "FS/$svcdb.pm";
811 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
813 $error = $svc->unsuspend;
815 $dbh->rollback if $oldAutoCommit;
822 my %hash = $self->hash;
823 my $inactive = time - $hash{'susp'};
825 my $conf = new FS::Conf;
827 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
828 if ( $opt{'adjust_next_bill'}
829 || $conf->exists('unsuspend-always_adjust_next_bill_date') )
830 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
833 $hash{'adjourn'} = '' if $hash{'adjourn'} < 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;
848 Cancels any pending suspension (sets the adjourn field to null).
850 If there is an error, returns the error, otherwise returns false.
855 my( $self, %options ) = @_;
858 local $SIG{HUP} = 'IGNORE';
859 local $SIG{INT} = 'IGNORE';
860 local $SIG{QUIT} = 'IGNORE';
861 local $SIG{TERM} = 'IGNORE';
862 local $SIG{TSTP} = 'IGNORE';
863 local $SIG{PIPE} = 'IGNORE';
865 my $oldAutoCommit = $FS::UID::AutoCommit;
866 local $FS::UID::AutoCommit = 0;
869 my $old = $self->select_for_update;
871 my $pkgnum = $old->pkgnum;
872 if ( $old->get('cancel') || $self->get('cancel') ) {
873 dbh->rollback if $oldAutoCommit;
874 return "Can't unadjourn cancelled package $pkgnum";
875 # or at least it's pointless
878 if ( $old->get('susp') || $self->get('susp') ) {
879 dbh->rollback if $oldAutoCommit;
880 return "Can't unadjourn suspended package $pkgnum";
881 # perhaps this is arbitrary
884 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
885 dbh->rollback if $oldAutoCommit;
886 return ""; # no error
889 my %hash = $self->hash;
890 $hash{'adjourn'} = '';
891 my $new = new FS::cust_pkg ( \%hash );
892 $error = $new->replace( $self, options => { $self->options } );
894 $dbh->rollback if $oldAutoCommit;
898 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
906 Returns the last bill date, or if there is no last bill date, the setup date.
907 Useful for billing metered services.
913 if ( $self->dbdef_table->column('last_bill') ) {
914 return $self->setfield('last_bill', $_[0]) if @_;
915 return $self->getfield('last_bill') if $self->getfield('last_bill');
917 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
918 'edate' => $self->bill, } );
919 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
922 =item last_cust_pkg_reason ACTION
924 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
925 Returns false if there is no reason or the package is not currenly ACTION'd
926 ACTION is one of adjourn, susp, cancel, or expire.
930 sub last_cust_pkg_reason {
931 my ( $self, $action ) = ( shift, shift );
932 my $date = $self->get($action);
934 'table' => 'cust_pkg_reason',
935 'hashref' => { 'pkgnum' => $self->pkgnum,
936 'action' => substr(uc($action), 0, 1),
939 'order_by' => 'ORDER BY num DESC LIMIT 1',
943 =item last_reason ACTION
945 Returns the most recent ACTION FS::reason associated with the package.
946 Returns false if there is no reason or the package is not currenly ACTION'd
947 ACTION is one of adjourn, susp, cancel, or expire.
952 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
953 $cust_pkg_reason->reason
959 Returns the definition for this billing item, as an FS::part_pkg object (see
966 #exists( $self->{'_pkgpart'} )
968 ? $self->{'_pkgpart'}
969 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
974 Returns the cancelled package this package was changed from, if any.
980 return '' unless $self->change_pkgnum;
981 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
986 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
993 $self->part_pkg->calc_setup($self, @_);
998 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1005 $self->part_pkg->calc_recur($self, @_);
1010 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1017 $self->part_pkg->calc_remain($self, @_);
1022 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1029 $self->part_pkg->calc_cancel($self, @_);
1034 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1040 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1043 =item cust_pkg_detail [ DETAILTYPE ]
1045 Returns any customer package details for this package (see
1046 L<FS::cust_pkg_detail>).
1048 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1052 sub cust_pkg_detail {
1054 my %hash = ( 'pkgnum' => $self->pkgnum );
1055 $hash{detailtype} = shift if @_;
1057 'table' => 'cust_pkg_detail',
1058 'hashref' => \%hash,
1059 'order_by' => 'ORDER BY weight, pkgdetailnum',
1063 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1065 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1067 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1069 If there is an error, returns the error, otherwise returns false.
1073 sub set_cust_pkg_detail {
1074 my( $self, $detailtype, @details ) = @_;
1076 local $SIG{HUP} = 'IGNORE';
1077 local $SIG{INT} = 'IGNORE';
1078 local $SIG{QUIT} = 'IGNORE';
1079 local $SIG{TERM} = 'IGNORE';
1080 local $SIG{TSTP} = 'IGNORE';
1081 local $SIG{PIPE} = 'IGNORE';
1083 my $oldAutoCommit = $FS::UID::AutoCommit;
1084 local $FS::UID::AutoCommit = 0;
1087 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1088 my $error = $current->delete;
1090 $dbh->rollback if $oldAutoCommit;
1091 return "error removing old detail: $error";
1095 foreach my $detail ( @details ) {
1096 my $cust_pkg_detail = new FS::cust_pkg_detail {
1097 'pkgnum' => $self->pkgnum,
1098 'detailtype' => $detailtype,
1099 'detail' => $detail,
1101 my $error = $cust_pkg_detail->insert;
1103 $dbh->rollback if $oldAutoCommit;
1104 return "error adding new detail: $error";
1109 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1114 =item cust_svc [ SVCPART ]
1116 Returns the services for this package, as FS::cust_svc objects (see
1117 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1126 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1127 'svcpart' => shift, } );
1130 #if ( $self->{'_svcnum'} ) {
1131 # values %{ $self->{'_svcnum'}->cache };
1133 $self->_sort_cust_svc(
1134 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1140 =item overlimit [ SVCPART ]
1142 Returns the services for this package which have exceeded their
1143 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1144 is specified, return only the matching services.
1150 grep { $_->overlimit } $self->cust_svc;
1153 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1155 Returns historical services for this package created before END TIMESTAMP and
1156 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1157 (see L<FS::h_cust_svc>).
1164 $self->_sort_cust_svc(
1165 [ qsearch( 'h_cust_svc',
1166 { 'pkgnum' => $self->pkgnum, },
1167 FS::h_cust_svc->sql_h_search(@_),
1173 sub _sort_cust_svc {
1174 my( $self, $arrayref ) = @_;
1177 sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2]
1183 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1184 'svcpart' => $_->svcpart } );
1186 $pkg_svc ? $pkg_svc->primary_svc : '',
1187 $pkg_svc ? $pkg_svc->quantity : 0,
1194 =item num_cust_svc [ SVCPART ]
1196 Returns the number of provisioned services for this package. If a svcpart is
1197 specified, counts only the matching services.
1203 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1204 $sql .= ' AND svcpart = ?' if @_;
1205 my $sth = dbh->prepare($sql) or die dbh->errstr;
1206 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1207 $sth->fetchrow_arrayref->[0];
1210 =item available_part_svc
1212 Returns a list of FS::part_svc objects representing services included in this
1213 package but not yet provisioned. Each FS::part_svc object also has an extra
1214 field, I<num_avail>, which specifies the number of available services.
1218 sub available_part_svc {
1220 grep { $_->num_avail > 0 }
1222 my $part_svc = $_->part_svc;
1223 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1224 $_->quantity - $self->num_cust_svc($_->svcpart);
1227 $self->part_pkg->pkg_svc;
1232 Returns a list of FS::part_svc objects representing provisioned and available
1233 services included in this package. Each FS::part_svc object also has the
1234 following extra fields:
1238 =item num_cust_svc (count)
1240 =item num_avail (quantity - count)
1242 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1245 label -> ($cust_svc->label)[1]
1254 #XXX some sort of sort order besides numeric by svcpart...
1255 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1257 my $part_svc = $pkg_svc->part_svc;
1258 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1259 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1260 $part_svc->{'Hash'}{'num_avail'} =
1261 max( 0, $pkg_svc->quantity - $num_cust_svc );
1262 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1264 } $self->part_pkg->pkg_svc;
1267 push @part_svc, map {
1269 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1270 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1271 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1272 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1274 } $self->extra_part_svc;
1280 =item extra_part_svc
1282 Returns a list of FS::part_svc objects corresponding to services in this
1283 package which are still provisioned but not (any longer) available in the
1288 sub extra_part_svc {
1291 my $pkgnum = $self->pkgnum;
1292 my $pkgpart = $self->pkgpart;
1295 'table' => 'part_svc',
1297 'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1298 WHERE pkg_svc.svcpart = part_svc.svcpart
1299 AND pkg_svc.pkgpart = $pkgpart
1302 AND 0 < ( SELECT count(*)
1304 LEFT JOIN cust_pkg using ( pkgnum )
1305 WHERE cust_svc.svcpart = part_svc.svcpart
1306 AND pkgnum = $pkgnum
1313 Returns a short status string for this package, currently:
1317 =item not yet billed
1319 =item one-time charge
1334 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1336 return 'cancelled' if $self->get('cancel');
1337 return 'suspended' if $self->susp;
1338 return 'not yet billed' unless $self->setup;
1339 return 'one-time charge' if $freq =~ /^(0|$)/;
1345 Class method that returns the list of possible status strings for pacakges
1346 (see L<the status method|/status>). For example:
1348 @statuses = FS::cust_pkg->statuses();
1352 tie my %statuscolor, 'Tie::IxHash',
1353 'not yet billed' => '000000',
1354 'one-time charge' => '000000',
1355 'active' => '00CC00',
1356 'suspended' => 'FF9900',
1357 'cancelled' => 'FF0000',
1361 my $self = shift; #could be class...
1362 grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1363 # mayble split btw one-time vs. recur
1369 Returns a hex triplet color string for this package's status.
1375 $statuscolor{$self->status};
1380 Returns a list of lists, calling the label method for all services
1381 (see L<FS::cust_svc>) of this billing item.
1387 map { [ $_->label ] } $self->cust_svc;
1390 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1392 Like the labels method, but returns historical information on services that
1393 were active as of END_TIMESTAMP and (optionally) not cancelled before
1396 Returns a list of lists, calling the label method for all (historical) services
1397 (see L<FS::h_cust_svc>) of this billing item.
1403 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1406 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1408 Like h_labels, except returns a simple flat list, and shortens long
1409 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1410 identical services to one line that lists the service label and the number of
1411 individual services rather than individual items.
1415 sub h_labels_short {
1418 my $conf = new FS::Conf;
1419 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1422 #tie %labels, 'Tie::IxHash';
1423 push @{ $labels{$_->[0]} }, $_->[1]
1424 foreach $self->h_labels(@_);
1426 foreach my $label ( keys %labels ) {
1428 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1429 my $num = scalar(@values);
1430 if ( $num > $max_same_services ) {
1431 push @labels, "$label ($num)";
1433 push @labels, map { "$label: $_" } @values;
1443 Returns the parent customer object (see L<FS::cust_main>).
1449 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1452 =item seconds_since TIMESTAMP
1454 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1455 package have been online since TIMESTAMP, according to the session monitor.
1457 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1458 L<Time::Local> and L<Date::Parse> for conversion functions.
1463 my($self, $since) = @_;
1466 foreach my $cust_svc (
1467 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1469 $seconds += $cust_svc->seconds_since($since);
1476 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1478 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1479 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1482 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1483 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1489 sub seconds_since_sqlradacct {
1490 my($self, $start, $end) = @_;
1494 foreach my $cust_svc (
1496 my $part_svc = $_->part_svc;
1497 $part_svc->svcdb eq 'svc_acct'
1498 && scalar($part_svc->part_export('sqlradius'));
1501 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1508 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1510 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1511 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1515 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1516 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1521 sub attribute_since_sqlradacct {
1522 my($self, $start, $end, $attrib) = @_;
1526 foreach my $cust_svc (
1528 my $part_svc = $_->part_svc;
1529 $part_svc->svcdb eq 'svc_acct'
1530 && scalar($part_svc->part_export('sqlradius'));
1533 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1545 my( $self, $value ) = @_;
1546 if ( defined($value) ) {
1547 $self->setfield('quantity', $value);
1549 $self->getfield('quantity') || 1;
1552 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1554 Transfers as many services as possible from this package to another package.
1556 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1557 object. The destination package must already exist.
1559 Services are moved only if the destination allows services with the correct
1560 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1561 this option with caution! No provision is made for export differences
1562 between the old and new service definitions. Probably only should be used
1563 when your exports for all service definitions of a given svcdb are identical.
1564 (attempt a transfer without it first, to move all possible svcpart-matching
1567 Any services that can't be moved remain in the original package.
1569 Returns an error, if there is one; otherwise, returns the number of services
1570 that couldn't be moved.
1575 my ($self, $dest_pkgnum, %opt) = @_;
1581 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1582 $dest = $dest_pkgnum;
1583 $dest_pkgnum = $dest->pkgnum;
1585 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1588 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1590 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1591 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1594 foreach my $cust_svc ($dest->cust_svc) {
1595 $target{$cust_svc->svcpart}--;
1598 my %svcpart2svcparts = ();
1599 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1600 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1601 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1602 next if exists $svcpart2svcparts{$svcpart};
1603 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1604 $svcpart2svcparts{$svcpart} = [
1606 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1608 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1609 'svcpart' => $_ } );
1611 $pkg_svc ? $pkg_svc->primary_svc : '',
1612 $pkg_svc ? $pkg_svc->quantity : 0,
1616 grep { $_ != $svcpart }
1618 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1620 warn "alternates for svcpart $svcpart: ".
1621 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1626 foreach my $cust_svc ($self->cust_svc) {
1627 if($target{$cust_svc->svcpart} > 0) {
1628 $target{$cust_svc->svcpart}--;
1629 my $new = new FS::cust_svc { $cust_svc->hash };
1630 $new->pkgnum($dest_pkgnum);
1631 my $error = $new->replace($cust_svc);
1632 return $error if $error;
1633 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1635 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1636 warn "alternates to consider: ".
1637 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1639 my @alternate = grep {
1640 warn "considering alternate svcpart $_: ".
1641 "$target{$_} available in new package\n"
1644 } @{$svcpart2svcparts{$cust_svc->svcpart}};
1646 warn "alternate(s) found\n" if $DEBUG;
1647 my $change_svcpart = $alternate[0];
1648 $target{$change_svcpart}--;
1649 my $new = new FS::cust_svc { $cust_svc->hash };
1650 $new->svcpart($change_svcpart);
1651 $new->pkgnum($dest_pkgnum);
1652 my $error = $new->replace($cust_svc);
1653 return $error if $error;
1666 This method is deprecated. See the I<depend_jobnum> option to the insert and
1667 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1674 local $SIG{HUP} = 'IGNORE';
1675 local $SIG{INT} = 'IGNORE';
1676 local $SIG{QUIT} = 'IGNORE';
1677 local $SIG{TERM} = 'IGNORE';
1678 local $SIG{TSTP} = 'IGNORE';
1679 local $SIG{PIPE} = 'IGNORE';
1681 my $oldAutoCommit = $FS::UID::AutoCommit;
1682 local $FS::UID::AutoCommit = 0;
1685 foreach my $cust_svc ( $self->cust_svc ) {
1686 #false laziness w/svc_Common::insert
1687 my $svc_x = $cust_svc->svc_x;
1688 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1689 my $error = $part_export->export_insert($svc_x);
1691 $dbh->rollback if $oldAutoCommit;
1697 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1704 =head1 CLASS METHODS
1710 Returns an SQL expression identifying recurring packages.
1714 sub recurring_sql { "
1715 '0' != ( select freq from part_pkg
1716 where cust_pkg.pkgpart = part_pkg.pkgpart )
1721 Returns an SQL expression identifying one-time packages.
1726 '0' = ( select freq from part_pkg
1727 where cust_pkg.pkgpart = part_pkg.pkgpart )
1732 Returns an SQL expression identifying active packages.
1737 ". $_[0]->recurring_sql(). "
1738 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1739 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1744 Returns an SQL expression identifying inactive packages (one-time packages
1745 that are otherwise unsuspended/uncancelled).
1749 sub inactive_sql { "
1750 ". $_[0]->onetime_sql(). "
1751 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1752 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1758 Returns an SQL expression identifying suspended packages.
1762 sub suspended_sql { susp_sql(@_); }
1764 #$_[0]->recurring_sql(). ' AND '.
1766 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1767 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
1774 Returns an SQL exprression identifying cancelled packages.
1778 sub cancelled_sql { cancel_sql(@_); }
1780 #$_[0]->recurring_sql(). ' AND '.
1781 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1784 =item search_sql HASHREF
1788 Returns a qsearch hash expression to search for parameters specified in HASHREF.
1789 Valid parameters are
1797 active, inactive, suspended, cancel (or cancelled)
1801 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
1807 pkgpart or arrayref or hashref of pkgparts
1811 arrayref of beginning and ending epoch date
1815 arrayref of beginning and ending epoch date
1819 arrayref of beginning and ending epoch date
1823 arrayref of beginning and ending epoch date
1827 arrayref of beginning and ending epoch date
1831 arrayref of beginning and ending epoch date
1835 arrayref of beginning and ending epoch date
1839 pkgnum or APKG_pkgnum
1843 a value suited to passing to FS::UI::Web::cust_header
1847 specifies the user for agent virtualization
1854 my ($class, $params) = @_;
1861 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
1870 if ( $params->{'magic'} eq 'active'
1871 || $params->{'status'} eq 'active' ) {
1873 push @where, FS::cust_pkg->active_sql();
1875 } elsif ( $params->{'magic'} eq 'inactive'
1876 || $params->{'status'} eq 'inactive' ) {
1878 push @where, FS::cust_pkg->inactive_sql();
1880 } elsif ( $params->{'magic'} eq 'suspended'
1881 || $params->{'status'} eq 'suspended' ) {
1883 push @where, FS::cust_pkg->suspended_sql();
1885 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
1886 || $params->{'status'} =~ /^cancell?ed$/ ) {
1888 push @where, FS::cust_pkg->cancelled_sql();
1890 } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
1892 push @where, FS::cust_pkg->inactive_sql();
1897 # parse package class
1900 #false lazinessish w/graph/cust_bill_pkg.cgi
1903 if ( exists($params->{'classnum'})
1904 && $params->{'classnum'} =~ /^(\d*)$/
1908 if ( $classnum ) { #a specific class
1909 push @where, "classnum = $classnum";
1911 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
1912 #die "classnum $classnum not found!" unless $pkg_class[0];
1913 #$title .= $pkg_class[0]->classname.' ';
1915 } elsif ( $classnum eq '' ) { #the empty class
1917 push @where, "classnum IS NULL";
1918 #$title .= 'Empty class ';
1919 #@pkg_class = ( '(empty class)' );
1920 } elsif ( $classnum eq '0' ) {
1921 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
1922 #push @pkg_class, '(empty class)';
1924 die "illegal classnum";
1933 if ( ref($params->{'pkgpart'}) ) {
1936 if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
1937 @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
1938 } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
1939 @pkgpart = @{ $params->{'pkgpart'} };
1941 die 'unhandled pkgpart ref '. $params->{'pkgpart'};
1944 @pkgpart = grep /^(\d+)$/, @pkgpart;
1946 push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
1948 } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
1949 push @where, "pkgpart = $1";
1958 #false laziness w/report_cust_pkg.html
1961 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
1962 'active' => { 'susp'=>1, 'cancel'=>1 },
1963 'suspended' => { 'cancel' => 1 },
1968 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
1970 next unless exists($params->{$field});
1972 my($beginning, $ending) = @{$params->{$field}};
1974 next if $beginning == 0 && $ending == 4294967295;
1977 "cust_pkg.$field IS NOT NULL",
1978 "cust_pkg.$field >= $beginning",
1979 "cust_pkg.$field <= $ending";
1981 $orderby ||= "ORDER BY cust_pkg.$field";
1985 $orderby ||= 'ORDER BY bill';
1988 # parse magic, legacy, etc.
1991 if ( $params->{'magic'} &&
1992 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
1995 $orderby = 'ORDER BY pkgnum';
1997 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
1998 push @where, "pkgpart = $1";
2001 } elsif ( $params->{'query'} eq 'pkgnum' ) {
2003 $orderby = 'ORDER BY pkgnum';
2005 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2007 $orderby = 'ORDER BY pkgnum';
2010 SELECT count(*) FROM pkg_svc
2011 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
2012 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2013 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
2014 AND cust_svc.svcpart = pkg_svc.svcpart
2021 # setup queries, links, subs, etc. for the search
2024 # here is the agent virtualization
2025 if ($params->{CurrentUser}) {
2027 qsearchs('access_user', { username => $params->{CurrentUser} });
2030 push @where, $access_user->agentnums_sql('table' => 'cust_main');
2035 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table' => 'cust_main');
2038 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2040 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
2041 'LEFT JOIN part_pkg USING ( pkgpart ) '.
2042 'LEFT JOIN pkg_class USING ( classnum ) ';
2044 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2047 'table' => 'cust_pkg',
2049 'select' => join(', ',
2051 ( map "part_pkg.$_", qw( pkg freq ) ),
2052 'pkg_class.classname',
2053 'cust_main.custnum as cust_main_custnum',
2054 FS::UI::Web::cust_sql_fields(
2055 $params->{'cust_fields'}
2058 'extra_sql' => "$extra_sql $orderby",
2059 'addl_from' => $addl_from,
2060 'count_query' => $count_query,
2069 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
2071 CUSTNUM is a customer (see L<FS::cust_main>)
2073 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2074 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2077 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2078 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
2079 new billing items. An error is returned if this is not possible (see
2080 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
2083 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2084 newly-created cust_pkg objects.
2089 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2091 my $conf = new FS::Conf;
2093 # Transactionize this whole mess
2094 local $SIG{HUP} = 'IGNORE';
2095 local $SIG{INT} = 'IGNORE';
2096 local $SIG{QUIT} = 'IGNORE';
2097 local $SIG{TERM} = 'IGNORE';
2098 local $SIG{TSTP} = 'IGNORE';
2099 local $SIG{PIPE} = 'IGNORE';
2101 my $oldAutoCommit = $FS::UID::AutoCommit;
2102 local $FS::UID::AutoCommit = 0;
2106 my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2107 return "Customer not found: $custnum" unless $cust_main;
2109 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2112 my $change = scalar(@old_cust_pkg) != 0;
2115 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2119 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
2121 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( setup );
2122 $hash{'setup'} = $time if $old_cust_pkg[0]->setup;
2124 $hash{'change_date'} = $time;
2125 $hash{"change_$_"} = $old_cust_pkg[0]->$_() foreach qw( pkgnum pkgpart );
2128 # Create the new packages.
2129 foreach my $pkgpart (@$pkgparts) {
2130 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2131 pkgpart => $pkgpart,
2134 $error = $cust_pkg->insert( 'change' => $change );
2136 $dbh->rollback if $oldAutoCommit;
2139 push @$return_cust_pkg, $cust_pkg;
2141 # $return_cust_pkg now contains refs to all of the newly
2144 # Transfer services and cancel old packages.
2145 foreach my $old_pkg (@old_cust_pkg) {
2147 foreach my $new_pkg (@$return_cust_pkg) {
2148 $error = $old_pkg->transfer($new_pkg);
2149 if ($error and $error == 0) {
2150 # $old_pkg->transfer failed.
2151 $dbh->rollback if $oldAutoCommit;
2156 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2157 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2158 foreach my $new_pkg (@$return_cust_pkg) {
2159 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2160 if ($error and $error == 0) {
2161 # $old_pkg->transfer failed.
2162 $dbh->rollback if $oldAutoCommit;
2169 # Transfers were successful, but we went through all of the
2170 # new packages and still had services left on the old package.
2171 # We can't cancel the package under the circumstances, so abort.
2172 $dbh->rollback if $oldAutoCommit;
2173 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2176 #reset usage if changing pkgpart
2177 foreach my $new_pkg (@$return_cust_pkg) {
2178 if ($old_pkg->pkgpart != $new_pkg->pkgpart) {
2179 my $part_pkg = $new_pkg->part_pkg;
2180 $error = $part_pkg->reset_usage($new_pkg, $part_pkg->is_prepaid
2184 if $part_pkg->can('reset_usage');
2187 $dbh->rollback if $oldAutoCommit;
2188 return "Error setting usage values: $error";
2193 $error = $old_pkg->cancel( quiet=>1 );
2199 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2203 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2205 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2206 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2209 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2210 replace. The services (see L<FS::cust_svc>) are moved to the
2211 new billing items. An error is returned if this is not possible (see
2214 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2215 newly-created cust_pkg objects.
2220 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2222 # Transactionize this whole mess
2223 local $SIG{HUP} = 'IGNORE';
2224 local $SIG{INT} = 'IGNORE';
2225 local $SIG{QUIT} = 'IGNORE';
2226 local $SIG{TERM} = 'IGNORE';
2227 local $SIG{TSTP} = 'IGNORE';
2228 local $SIG{PIPE} = 'IGNORE';
2230 my $oldAutoCommit = $FS::UID::AutoCommit;
2231 local $FS::UID::AutoCommit = 0;
2235 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2238 while(scalar(@old_cust_pkg)) {
2240 my $custnum = $old_cust_pkg[0]->custnum;
2241 my (@remove) = map { $_->pkgnum }
2242 grep { $_->custnum == $custnum } @old_cust_pkg;
2243 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2245 my $error = order $custnum, $pkgparts, \@remove, \@return;
2247 push @errors, $error
2249 push @$return_cust_pkg, @return;
2252 if (scalar(@errors)) {
2253 $dbh->rollback if $oldAutoCommit;
2254 return join(' / ', @errors);
2257 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2263 Associates this package with a (suspension or cancellation) reason (see
2264 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2267 Available options are:
2271 =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.
2273 =item otaker_reason - the access_user (see L<FS::access_user>) providing the reason
2275 =item date - a unix timestamp
2277 =item action - the action (cancel, susp, adjourn, expire) associated with the reason
2281 If there is an error, returns the error, otherwise returns false.
2286 my ($self, %options) = @_;
2288 my $otaker = $options{reason_otaker} ||
2289 $FS::CurrentUser::CurrentUser->username;
2292 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2296 } elsif ( ref($options{'reason'}) ) {
2298 return 'Enter a new reason (or select an existing one)'
2299 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2301 my $reason = new FS::reason({
2302 'reason_type' => $options{'reason'}->{'typenum'},
2303 'reason' => $options{'reason'}->{'reason'},
2305 my $error = $reason->insert;
2306 return $error if $error;
2308 $reasonnum = $reason->reasonnum;
2311 return "Unparsable reason: ". $options{'reason'};
2314 my $cust_pkg_reason =
2315 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2316 'reasonnum' => $reasonnum,
2317 'otaker' => $otaker,
2318 'action' => substr(uc($options{'action'}),0,1),
2319 'date' => $options{'date'}
2324 $cust_pkg_reason->insert;
2327 =item set_usage USAGE_VALUE_HASHREF
2329 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2330 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2331 upbytes, downbytes, and totalbytes are appropriate keys.
2333 All svc_accts which are part of this package have their values reset.
2338 my ($self, $valueref, %opt) = @_;
2340 foreach my $cust_svc ($self->cust_svc){
2341 my $svc_x = $cust_svc->svc_x;
2342 $svc_x->set_usage($valueref, %opt)
2343 if $svc_x->can("set_usage");
2351 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
2353 In sub order, the @pkgparts array (passed by reference) is clobbered.
2355 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
2356 method to pass dates to the recur_prog expression, it should do so.
2358 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
2359 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
2360 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
2361 configuration values. Probably need a subroutine which decides what to do
2362 based on whether or not we've fetched the user yet, rather than a hash. See
2363 FS::UID and the TODO.
2365 Now that things are transactional should the check in the insert method be
2370 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
2371 L<FS::pkg_svc>, schema.html from the base documentation