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 / $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 if ($options{'reason'}) {
487 $error = $self->insert_reason( 'reason' => $options{'reason'},
488 'action' => $date ? 'expire' : 'cancel',
490 'reason_otaker' => $options{'reason_otaker'},
493 dbh->rollback if $oldAutoCommit;
494 return "Error inserting cust_pkg_reason: $error";
500 foreach my $cust_svc (
503 sort { $a->[1] <=> $b->[1] }
504 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
505 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
508 my $error = $cust_svc->cancel;
511 $dbh->rollback if $oldAutoCommit;
512 return "Error cancelling cust_svc: $error";
516 # Add a credit for remaining service
517 my $remaining_value = $self->calc_remain();
518 if ( $remaining_value > 0 ) {
519 my $conf = new FS::Conf;
520 my $error = $self->cust_main->credit(
522 'Credit for unused time on '. $self->part_pkg->pkg,
523 'reason_type' => $conf->config('cancel_credit_type'),
526 $dbh->rollback if $oldAutoCommit;
527 return "Error crediting customer \$$remaining_value for unused time on".
528 $self->part_pkg->pkg. ": $error";
533 my %hash = $self->hash;
534 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = time);
535 my $new = new FS::cust_pkg ( \%hash );
536 $error = $new->replace( $self, options => { $self->options } );
538 $dbh->rollback if $oldAutoCommit;
542 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
543 return '' if $date; #no errors
545 my $conf = new FS::Conf;
546 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
547 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
548 my $conf = new FS::Conf;
549 my $error = send_email(
550 'from' => $conf->config('invoice_from'),
551 'to' => \@invoicing_list,
552 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
553 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
555 #should this do something on errors?
564 Cancels any pending expiration (sets the expire field to null).
566 If there is an error, returns the error, otherwise returns false.
571 my( $self, %options ) = @_;
574 local $SIG{HUP} = 'IGNORE';
575 local $SIG{INT} = 'IGNORE';
576 local $SIG{QUIT} = 'IGNORE';
577 local $SIG{TERM} = 'IGNORE';
578 local $SIG{TSTP} = 'IGNORE';
579 local $SIG{PIPE} = 'IGNORE';
581 my $oldAutoCommit = $FS::UID::AutoCommit;
582 local $FS::UID::AutoCommit = 0;
585 my $old = $self->select_for_update;
587 my $pkgnum = $old->pkgnum;
588 if ( $old->get('cancel') || $self->get('cancel') ) {
589 dbh->rollback if $oldAutoCommit;
590 return "Can't unexpire cancelled package $pkgnum";
591 # or at least it's pointless
594 unless ( $old->get('expire') && $self->get('expire') ) {
595 dbh->rollback if $oldAutoCommit;
596 return ""; # no error
599 my %hash = $self->hash;
600 $hash{'expire'} = '';
601 my $new = new FS::cust_pkg ( \%hash );
602 $error = $new->replace( $self, options => { $self->options } );
604 $dbh->rollback if $oldAutoCommit;
608 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
614 =item suspend [ OPTION => VALUE ... ]
616 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
617 package, then suspends the package itself (sets the susp field to now).
619 Available options are: I<reason> I<date>
621 I<date> can be set to a unix style timestamp to specify when to suspend (adjourn)
622 I<reason> can be set to a reasonnum (see L<FS::reason>) explaining the action
624 If there is an error, returns the error, otherwise returns false.
629 my( $self, %options ) = @_;
632 local $SIG{HUP} = 'IGNORE';
633 local $SIG{INT} = 'IGNORE';
634 local $SIG{QUIT} = 'IGNORE';
635 local $SIG{TERM} = 'IGNORE';
636 local $SIG{TSTP} = 'IGNORE';
637 local $SIG{PIPE} = 'IGNORE';
639 my $oldAutoCommit = $FS::UID::AutoCommit;
640 local $FS::UID::AutoCommit = 0;
643 my $old = $self->select_for_update;
645 my $pkgnum = $old->pkgnum;
646 if ( $old->get('cancel') || $self->get('cancel') ) {
647 dbh->rollback if $oldAutoCommit;
648 return "Can't suspend cancelled package $pkgnum";
651 if ( $old->get('susp') || $self->get('susp') ) {
652 dbh->rollback if $oldAutoCommit;
653 return ""; # no error # complain on adjourn?
656 my $date = $options{date} if $options{date}; # adjourn/suspend later
657 $date = '' if ($date && $date <= time); # complain instead?
659 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
660 dbh->rollback if $oldAutoCommit;
661 return "Package $pkgnum expires before it would be suspended.";
664 if ($options{'reason'}) {
665 $error = $self->insert_reason( 'reason' => $options{'reason'},
666 'action' => $date ? 'adjourn' : 'suspend',
668 'reason_otaker' => $options{'reason_otaker'},
671 dbh->rollback if $oldAutoCommit;
672 return "Error inserting cust_pkg_reason: $error";
680 foreach my $cust_svc (
681 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
683 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
685 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
686 $dbh->rollback if $oldAutoCommit;
687 return "Illegal svcdb value in part_svc!";
690 require "FS/$svcdb.pm";
692 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
694 $error = $svc->suspend;
696 $dbh->rollback if $oldAutoCommit;
699 my( $label, $value ) = $cust_svc->label;
700 push @labels, "$label: $value";
704 my $conf = new FS::Conf;
705 if ( $conf->config('suspend_email_admin') ) {
707 my $error = send_email(
708 'from' => $conf->config('invoice_from'), #??? well as good as any
709 'to' => $conf->config('suspend_email_admin'),
710 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
712 "This is an automatic message from your Freeside installation\n",
713 "informing you that the following customer package has been suspended:\n",
715 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
716 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
717 ( map { "Service : $_\n" } @labels ),
722 warn "WARNING: can't send suspension admin email (suspending anyway): ".
730 my %hash = $self->hash;
732 $hash{'adjourn'} = $date;
734 $hash{'susp'} = time;
736 my $new = new FS::cust_pkg ( \%hash );
737 $error = $new->replace( $self, options => { $self->options } );
739 $dbh->rollback if $oldAutoCommit;
743 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
748 =item unsuspend [ OPTION => VALUE ... ]
750 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
751 package, then unsuspends the package itself (clears the susp field and the
752 adjourn field if it is in the past).
754 Available options are: I<adjust_next_bill>.
756 I<adjust_next_bill> can be set true to adjust the next bill date forward by
757 the amount of time the account was inactive. This was set true by default
758 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
759 explicitly requested. Price plans for which this makes sense (anniversary-date
760 based than prorate or subscription) could have an option to enable this
763 If there is an error, returns the error, otherwise returns false.
768 my( $self, %opt ) = @_;
771 local $SIG{HUP} = 'IGNORE';
772 local $SIG{INT} = 'IGNORE';
773 local $SIG{QUIT} = 'IGNORE';
774 local $SIG{TERM} = 'IGNORE';
775 local $SIG{TSTP} = 'IGNORE';
776 local $SIG{PIPE} = 'IGNORE';
778 my $oldAutoCommit = $FS::UID::AutoCommit;
779 local $FS::UID::AutoCommit = 0;
782 my $old = $self->select_for_update;
784 my $pkgnum = $old->pkgnum;
785 if ( $old->get('cancel') || $self->get('cancel') ) {
786 dbh->rollback if $oldAutoCommit;
787 return "Can't unsuspend cancelled package $pkgnum";
790 unless ( $old->get('susp') && $self->get('susp') ) {
791 dbh->rollback if $oldAutoCommit;
792 return ""; # no error # complain instead?
795 foreach my $cust_svc (
796 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
798 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
800 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
801 $dbh->rollback if $oldAutoCommit;
802 return "Illegal svcdb value in part_svc!";
805 require "FS/$svcdb.pm";
807 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
809 $error = $svc->unsuspend;
811 $dbh->rollback if $oldAutoCommit;
818 my %hash = $self->hash;
819 my $inactive = time - $hash{'susp'};
821 my $conf = new FS::Conf;
823 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
824 if ( $opt{'adjust_next_bill'}
825 || $conf->exists('unsuspend-always_adjust_next_bill_date') )
826 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
829 $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
830 my $new = new FS::cust_pkg ( \%hash );
831 $error = $new->replace( $self, options => { $self->options } );
833 $dbh->rollback if $oldAutoCommit;
837 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
844 Cancels any pending suspension (sets the adjourn field to null).
846 If there is an error, returns the error, otherwise returns false.
851 my( $self, %options ) = @_;
854 local $SIG{HUP} = 'IGNORE';
855 local $SIG{INT} = 'IGNORE';
856 local $SIG{QUIT} = 'IGNORE';
857 local $SIG{TERM} = 'IGNORE';
858 local $SIG{TSTP} = 'IGNORE';
859 local $SIG{PIPE} = 'IGNORE';
861 my $oldAutoCommit = $FS::UID::AutoCommit;
862 local $FS::UID::AutoCommit = 0;
865 my $old = $self->select_for_update;
867 my $pkgnum = $old->pkgnum;
868 if ( $old->get('cancel') || $self->get('cancel') ) {
869 dbh->rollback if $oldAutoCommit;
870 return "Can't unadjourn cancelled package $pkgnum";
871 # or at least it's pointless
874 if ( $old->get('susp') || $self->get('susp') ) {
875 dbh->rollback if $oldAutoCommit;
876 return "Can't unadjourn suspended package $pkgnum";
877 # perhaps this is arbitrary
880 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
881 dbh->rollback if $oldAutoCommit;
882 return ""; # no error
885 my %hash = $self->hash;
886 $hash{'adjourn'} = '';
887 my $new = new FS::cust_pkg ( \%hash );
888 $error = $new->replace( $self, options => { $self->options } );
890 $dbh->rollback if $oldAutoCommit;
894 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
902 Returns the last bill date, or if there is no last bill date, the setup date.
903 Useful for billing metered services.
909 if ( $self->dbdef_table->column('last_bill') ) {
910 return $self->setfield('last_bill', $_[0]) if @_;
911 return $self->getfield('last_bill') if $self->getfield('last_bill');
913 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
914 'edate' => $self->bill, } );
915 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
918 =item last_cust_pkg_reason ACTION
920 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
921 Returns false if there is no reason or the package is not currenly ACTION'd
922 ACTION is one of adjourn, susp, cancel, or expire.
926 sub last_cust_pkg_reason {
927 my ( $self, $action ) = ( shift, shift );
928 my $date = $self->get($action);
930 'table' => 'cust_pkg_reason',
931 'hashref' => { 'pkgnum' => $self->pkgnum,
932 'action' => substr(uc($action), 0, 1),
935 'order_by' => 'ORDER BY num DESC LIMIT 1',
939 =item last_reason ACTION
941 Returns the most recent ACTION FS::reason associated with the package.
942 Returns false if there is no reason or the package is not currenly ACTION'd
943 ACTION is one of adjourn, susp, cancel, or expire.
948 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
949 $cust_pkg_reason->reason
955 Returns the definition for this billing item, as an FS::part_pkg object (see
962 #exists( $self->{'_pkgpart'} )
964 ? $self->{'_pkgpart'}
965 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
970 Returns the cancelled package this package was changed from, if any.
976 return '' unless $self->change_pkgnum;
977 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
982 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
989 $self->part_pkg->calc_setup($self, @_);
994 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1001 $self->part_pkg->calc_recur($self, @_);
1006 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1013 $self->part_pkg->calc_remain($self, @_);
1018 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1025 $self->part_pkg->calc_cancel($self, @_);
1030 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1036 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1039 =item cust_pkg_detail [ DETAILTYPE ]
1041 Returns any customer package details for this package (see
1042 L<FS::cust_pkg_detail>).
1044 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1048 sub cust_pkg_detail {
1050 my %hash = ( 'pkgnum' => $self->pkgnum );
1051 $hash{detailtype} = shift if @_;
1053 'table' => 'cust_pkg_detail',
1054 'hashref' => \%hash,
1055 'order_by' => 'ORDER BY weight, pkgdetailnum',
1059 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1061 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1063 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1065 If there is an error, returns the error, otherwise returns false.
1069 sub set_cust_pkg_detail {
1070 my( $self, $detailtype, @details ) = @_;
1072 local $SIG{HUP} = 'IGNORE';
1073 local $SIG{INT} = 'IGNORE';
1074 local $SIG{QUIT} = 'IGNORE';
1075 local $SIG{TERM} = 'IGNORE';
1076 local $SIG{TSTP} = 'IGNORE';
1077 local $SIG{PIPE} = 'IGNORE';
1079 my $oldAutoCommit = $FS::UID::AutoCommit;
1080 local $FS::UID::AutoCommit = 0;
1083 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1084 my $error = $current->delete;
1086 $dbh->rollback if $oldAutoCommit;
1087 return "error removing old detail: $error";
1091 foreach my $detail ( @details ) {
1092 my $cust_pkg_detail = new FS::cust_pkg_detail {
1093 'pkgnum' => $self->pkgnum,
1094 'detailtype' => $detailtype,
1095 'detail' => $detail,
1097 my $error = $cust_pkg_detail->insert;
1099 $dbh->rollback if $oldAutoCommit;
1100 return "error adding new detail: $error";
1105 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1110 =item cust_svc [ SVCPART ]
1112 Returns the services for this package, as FS::cust_svc objects (see
1113 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1122 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1123 'svcpart' => shift, } );
1126 #if ( $self->{'_svcnum'} ) {
1127 # values %{ $self->{'_svcnum'}->cache };
1129 $self->_sort_cust_svc(
1130 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1136 =item overlimit [ SVCPART ]
1138 Returns the services for this package which have exceeded their
1139 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1140 is specified, return only the matching services.
1146 grep { $_->overlimit } $self->cust_svc;
1149 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1151 Returns historical services for this package created before END TIMESTAMP and
1152 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1153 (see L<FS::h_cust_svc>).
1160 $self->_sort_cust_svc(
1161 [ qsearch( 'h_cust_svc',
1162 { 'pkgnum' => $self->pkgnum, },
1163 FS::h_cust_svc->sql_h_search(@_),
1169 sub _sort_cust_svc {
1170 my( $self, $arrayref ) = @_;
1173 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1175 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1176 'svcpart' => $_->svcpart } );
1178 $pkg_svc ? $pkg_svc->primary_svc : '',
1179 $pkg_svc ? $pkg_svc->quantity : 0,
1186 =item num_cust_svc [ SVCPART ]
1188 Returns the number of provisioned services for this package. If a svcpart is
1189 specified, counts only the matching services.
1195 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1196 $sql .= ' AND svcpart = ?' if @_;
1197 my $sth = dbh->prepare($sql) or die dbh->errstr;
1198 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1199 $sth->fetchrow_arrayref->[0];
1202 =item available_part_svc
1204 Returns a list of FS::part_svc objects representing services included in this
1205 package but not yet provisioned. Each FS::part_svc object also has an extra
1206 field, I<num_avail>, which specifies the number of available services.
1210 sub available_part_svc {
1212 grep { $_->num_avail > 0 }
1214 my $part_svc = $_->part_svc;
1215 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1216 $_->quantity - $self->num_cust_svc($_->svcpart);
1219 $self->part_pkg->pkg_svc;
1224 Returns a list of FS::part_svc objects representing provisioned and available
1225 services included in this package. Each FS::part_svc object also has the
1226 following extra fields:
1230 =item num_cust_svc (count)
1232 =item num_avail (quantity - count)
1234 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1237 label -> ($cust_svc->label)[1]
1246 #XXX some sort of sort order besides numeric by svcpart...
1247 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1249 my $part_svc = $pkg_svc->part_svc;
1250 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1251 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1252 $part_svc->{'Hash'}{'num_avail'} =
1253 max( 0, $pkg_svc->quantity - $num_cust_svc );
1254 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1256 } $self->part_pkg->pkg_svc;
1259 push @part_svc, map {
1261 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1262 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1263 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1264 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
1266 } $self->extra_part_svc;
1272 =item extra_part_svc
1274 Returns a list of FS::part_svc objects corresponding to services in this
1275 package which are still provisioned but not (any longer) available in the
1280 sub extra_part_svc {
1283 my $pkgnum = $self->pkgnum;
1284 my $pkgpart = $self->pkgpart;
1287 'table' => 'part_svc',
1289 'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1290 WHERE pkg_svc.svcpart = part_svc.svcpart
1291 AND pkg_svc.pkgpart = $pkgpart
1294 AND 0 < ( SELECT count(*)
1296 LEFT JOIN cust_pkg using ( pkgnum )
1297 WHERE cust_svc.svcpart = part_svc.svcpart
1298 AND pkgnum = $pkgnum
1305 Returns a short status string for this package, currently:
1309 =item not yet billed
1311 =item one-time charge
1326 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1328 return 'cancelled' if $self->get('cancel');
1329 return 'suspended' if $self->susp;
1330 return 'not yet billed' unless $self->setup;
1331 return 'one-time charge' if $freq =~ /^(0|$)/;
1337 Class method that returns the list of possible status strings for pacakges
1338 (see L<the status method|/status>). For example:
1340 @statuses = FS::cust_pkg->statuses();
1344 tie my %statuscolor, 'Tie::IxHash',
1345 'not yet billed' => '000000',
1346 'one-time charge' => '000000',
1347 'active' => '00CC00',
1348 'suspended' => 'FF9900',
1349 'cancelled' => 'FF0000',
1353 my $self = shift; #could be class...
1354 grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1355 # mayble split btw one-time vs. recur
1361 Returns a hex triplet color string for this package's status.
1367 $statuscolor{$self->status};
1372 Returns a list of lists, calling the label method for all services
1373 (see L<FS::cust_svc>) of this billing item.
1379 map { [ $_->label ] } $self->cust_svc;
1382 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1384 Like the labels method, but returns historical information on services that
1385 were active as of END_TIMESTAMP and (optionally) not cancelled before
1388 Returns a list of lists, calling the label method for all (historical) services
1389 (see L<FS::h_cust_svc>) of this billing item.
1395 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1398 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1400 Like h_labels, except returns a simple flat list, and shortens long
1401 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1402 identical services to one line that lists the service label and the number of
1403 individual services rather than individual items.
1407 sub h_labels_short {
1410 my $conf = new FS::Conf;
1411 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1414 #tie %labels, 'Tie::IxHash';
1415 push @{ $labels{$_->[0]} }, $_->[1]
1416 foreach $self->h_labels(@_);
1418 foreach my $label ( keys %labels ) {
1420 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1421 my $num = scalar(@values);
1422 if ( $num > $max_same_services ) {
1423 push @labels, "$label ($num)";
1425 push @labels, map { "$label: $_" } @values;
1435 Returns the parent customer object (see L<FS::cust_main>).
1441 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1444 =item seconds_since TIMESTAMP
1446 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1447 package have been online since TIMESTAMP, according to the session monitor.
1449 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1450 L<Time::Local> and L<Date::Parse> for conversion functions.
1455 my($self, $since) = @_;
1458 foreach my $cust_svc (
1459 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1461 $seconds += $cust_svc->seconds_since($since);
1468 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1470 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1471 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1474 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1475 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1481 sub seconds_since_sqlradacct {
1482 my($self, $start, $end) = @_;
1486 foreach my $cust_svc (
1488 my $part_svc = $_->part_svc;
1489 $part_svc->svcdb eq 'svc_acct'
1490 && scalar($part_svc->part_export('sqlradius'));
1493 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1500 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1502 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1503 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1507 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1508 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1513 sub attribute_since_sqlradacct {
1514 my($self, $start, $end, $attrib) = @_;
1518 foreach my $cust_svc (
1520 my $part_svc = $_->part_svc;
1521 $part_svc->svcdb eq 'svc_acct'
1522 && scalar($part_svc->part_export('sqlradius'));
1525 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1537 my( $self, $value ) = @_;
1538 if ( defined($value) ) {
1539 $self->setfield('quantity', $value);
1541 $self->getfield('quantity') || 1;
1544 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1546 Transfers as many services as possible from this package to another package.
1548 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1549 object. The destination package must already exist.
1551 Services are moved only if the destination allows services with the correct
1552 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1553 this option with caution! No provision is made for export differences
1554 between the old and new service definitions. Probably only should be used
1555 when your exports for all service definitions of a given svcdb are identical.
1556 (attempt a transfer without it first, to move all possible svcpart-matching
1559 Any services that can't be moved remain in the original package.
1561 Returns an error, if there is one; otherwise, returns the number of services
1562 that couldn't be moved.
1567 my ($self, $dest_pkgnum, %opt) = @_;
1573 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1574 $dest = $dest_pkgnum;
1575 $dest_pkgnum = $dest->pkgnum;
1577 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1580 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1582 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1583 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1586 foreach my $cust_svc ($dest->cust_svc) {
1587 $target{$cust_svc->svcpart}--;
1590 my %svcpart2svcparts = ();
1591 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1592 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1593 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1594 next if exists $svcpart2svcparts{$svcpart};
1595 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1596 $svcpart2svcparts{$svcpart} = [
1598 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1600 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1601 'svcpart' => $_ } );
1603 $pkg_svc ? $pkg_svc->primary_svc : '',
1604 $pkg_svc ? $pkg_svc->quantity : 0,
1608 grep { $_ != $svcpart }
1610 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1612 warn "alternates for svcpart $svcpart: ".
1613 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1618 foreach my $cust_svc ($self->cust_svc) {
1619 if($target{$cust_svc->svcpart} > 0) {
1620 $target{$cust_svc->svcpart}--;
1621 my $new = new FS::cust_svc { $cust_svc->hash };
1622 $new->pkgnum($dest_pkgnum);
1623 my $error = $new->replace($cust_svc);
1624 return $error if $error;
1625 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1627 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1628 warn "alternates to consider: ".
1629 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1631 my @alternate = grep {
1632 warn "considering alternate svcpart $_: ".
1633 "$target{$_} available in new package\n"
1636 } @{$svcpart2svcparts{$cust_svc->svcpart}};
1638 warn "alternate(s) found\n" if $DEBUG;
1639 my $change_svcpart = $alternate[0];
1640 $target{$change_svcpart}--;
1641 my $new = new FS::cust_svc { $cust_svc->hash };
1642 $new->svcpart($change_svcpart);
1643 $new->pkgnum($dest_pkgnum);
1644 my $error = $new->replace($cust_svc);
1645 return $error if $error;
1658 This method is deprecated. See the I<depend_jobnum> option to the insert and
1659 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1666 local $SIG{HUP} = 'IGNORE';
1667 local $SIG{INT} = 'IGNORE';
1668 local $SIG{QUIT} = 'IGNORE';
1669 local $SIG{TERM} = 'IGNORE';
1670 local $SIG{TSTP} = 'IGNORE';
1671 local $SIG{PIPE} = 'IGNORE';
1673 my $oldAutoCommit = $FS::UID::AutoCommit;
1674 local $FS::UID::AutoCommit = 0;
1677 foreach my $cust_svc ( $self->cust_svc ) {
1678 #false laziness w/svc_Common::insert
1679 my $svc_x = $cust_svc->svc_x;
1680 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1681 my $error = $part_export->export_insert($svc_x);
1683 $dbh->rollback if $oldAutoCommit;
1689 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1696 =head1 CLASS METHODS
1702 Returns an SQL expression identifying recurring packages.
1706 sub recurring_sql { "
1707 '0' != ( select freq from part_pkg
1708 where cust_pkg.pkgpart = part_pkg.pkgpart )
1713 Returns an SQL expression identifying one-time packages.
1718 '0' = ( select freq from part_pkg
1719 where cust_pkg.pkgpart = part_pkg.pkgpart )
1724 Returns an SQL expression identifying active packages.
1729 ". $_[0]->recurring_sql(). "
1730 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1731 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1736 Returns an SQL expression identifying inactive packages (one-time packages
1737 that are otherwise unsuspended/uncancelled).
1741 sub inactive_sql { "
1742 ". $_[0]->onetime_sql(). "
1743 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1744 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1750 Returns an SQL expression identifying suspended packages.
1754 sub suspended_sql { susp_sql(@_); }
1756 #$_[0]->recurring_sql(). ' AND '.
1758 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1759 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
1766 Returns an SQL exprression identifying cancelled packages.
1770 sub cancelled_sql { cancel_sql(@_); }
1772 #$_[0]->recurring_sql(). ' AND '.
1773 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1776 =item search_sql HASHREF
1780 Returns a qsearch hash expression to search for parameters specified in HASHREF.
1781 Valid parameters are
1789 active, inactive, suspended, cancel (or cancelled)
1793 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
1803 arrayref of beginning and ending epoch date
1807 arrayref of beginning and ending epoch date
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 pkgnum or APKG_pkgnum
1835 a value suited to passing to FS::UI::Web::cust_header
1839 specifies the user for agent virtualization
1846 my ($class, $params) = @_;
1853 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
1862 if ( $params->{'magic'} eq 'active'
1863 || $params->{'status'} eq 'active' ) {
1865 push @where, FS::cust_pkg->active_sql();
1867 } elsif ( $params->{'magic'} eq 'inactive'
1868 || $params->{'status'} eq 'inactive' ) {
1870 push @where, FS::cust_pkg->inactive_sql();
1872 } elsif ( $params->{'magic'} eq 'suspended'
1873 || $params->{'status'} eq 'suspended' ) {
1875 push @where, FS::cust_pkg->suspended_sql();
1877 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
1878 || $params->{'status'} =~ /^cancell?ed$/ ) {
1880 push @where, FS::cust_pkg->cancelled_sql();
1882 } elsif ( $params->{'status'} =~ /^(one-time charge|inactive)$/ ) {
1884 push @where, FS::cust_pkg->inactive_sql();
1889 # parse package class
1892 #false lazinessish w/graph/cust_bill_pkg.cgi
1895 if ( exists($params->{'classnum'})
1896 && $params->{'classnum'} =~ /^(\d*)$/
1900 if ( $classnum ) { #a specific class
1901 push @where, "classnum = $classnum";
1903 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
1904 #die "classnum $classnum not found!" unless $pkg_class[0];
1905 #$title .= $pkg_class[0]->classname.' ';
1907 } elsif ( $classnum eq '' ) { #the empty class
1909 push @where, "classnum IS NULL";
1910 #$title .= 'Empty class ';
1911 #@pkg_class = ( '(empty class)' );
1912 } elsif ( $classnum eq '0' ) {
1913 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
1914 #push @pkg_class, '(empty class)';
1916 die "illegal classnum";
1925 my $pkgpart = join (' OR pkgpart=',
1926 grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
1927 push @where, '(pkgpart=' . $pkgpart . ')' if $pkgpart;
1935 #false laziness w/report_cust_pkg.html
1938 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
1939 'active' => { 'susp'=>1, 'cancel'=>1 },
1940 'suspended' => { 'cancel' => 1 },
1945 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
1947 next unless exists($params->{$field});
1949 my($beginning, $ending) = @{$params->{$field}};
1951 next if $beginning == 0 && $ending == 4294967295;
1954 "cust_pkg.$field IS NOT NULL",
1955 "cust_pkg.$field >= $beginning",
1956 "cust_pkg.$field <= $ending";
1958 $orderby ||= "ORDER BY cust_pkg.$field";
1962 $orderby ||= 'ORDER BY bill';
1965 # parse magic, legacy, etc.
1968 if ( $params->{'magic'} &&
1969 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
1972 $orderby = 'ORDER BY pkgnum';
1974 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
1975 push @where, "pkgpart = $1";
1978 } elsif ( $params->{'query'} eq 'pkgnum' ) {
1980 $orderby = 'ORDER BY pkgnum';
1982 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
1984 $orderby = 'ORDER BY pkgnum';
1987 SELECT count(*) FROM pkg_svc
1988 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
1989 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
1990 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
1991 AND cust_svc.svcpart = pkg_svc.svcpart
1998 # setup queries, links, subs, etc. for the search
2001 # here is the agent virtualization
2002 if ($params->{CurrentUser}) {
2004 qsearchs('access_user', { username => $params->{CurrentUser} });
2007 push @where, $access_user->agentnums_sql('table' => 'cust_main');
2012 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table' => 'cust_main');
2015 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2017 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
2018 'LEFT JOIN part_pkg USING ( pkgpart ) '.
2019 'LEFT JOIN pkg_class USING ( classnum ) ';
2021 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2024 'table' => 'cust_pkg',
2026 'select' => join(', ',
2028 ( map "part_pkg.$_", qw( pkg freq ) ),
2029 'pkg_class.classname',
2030 'cust_main.custnum as cust_main_custnum',
2031 FS::UI::Web::cust_sql_fields(
2032 $params->{'cust_fields'}
2035 'extra_sql' => "$extra_sql $orderby",
2036 'addl_from' => $addl_from,
2037 'count_query' => $count_query,
2046 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
2048 CUSTNUM is a customer (see L<FS::cust_main>)
2050 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2051 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2054 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2055 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
2056 new billing items. An error is returned if this is not possible (see
2057 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
2060 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2061 newly-created cust_pkg objects.
2066 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2068 my $conf = new FS::Conf;
2070 # Transactionize this whole mess
2071 local $SIG{HUP} = 'IGNORE';
2072 local $SIG{INT} = 'IGNORE';
2073 local $SIG{QUIT} = 'IGNORE';
2074 local $SIG{TERM} = 'IGNORE';
2075 local $SIG{TSTP} = 'IGNORE';
2076 local $SIG{PIPE} = 'IGNORE';
2078 my $oldAutoCommit = $FS::UID::AutoCommit;
2079 local $FS::UID::AutoCommit = 0;
2083 my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2084 return "Customer not found: $custnum" unless $cust_main;
2086 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2089 my $change = scalar(@old_cust_pkg) != 0;
2092 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2096 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
2098 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( setup );
2099 $hash{'setup'} = $time if $old_cust_pkg[0]->setup;
2101 $hash{'change_date'} = $time;
2102 $hash{"change_$_"} = $old_cust_pkg[0]->$_() foreach qw( pkgnum pkgpart );
2105 # Create the new packages.
2106 foreach my $pkgpart (@$pkgparts) {
2107 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2108 pkgpart => $pkgpart,
2111 $error = $cust_pkg->insert( 'change' => $change );
2113 $dbh->rollback if $oldAutoCommit;
2116 push @$return_cust_pkg, $cust_pkg;
2118 # $return_cust_pkg now contains refs to all of the newly
2121 # Transfer services and cancel old packages.
2122 foreach my $old_pkg (@old_cust_pkg) {
2124 foreach my $new_pkg (@$return_cust_pkg) {
2125 $error = $old_pkg->transfer($new_pkg);
2126 if ($error and $error == 0) {
2127 # $old_pkg->transfer failed.
2128 $dbh->rollback if $oldAutoCommit;
2133 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2134 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2135 foreach my $new_pkg (@$return_cust_pkg) {
2136 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2137 if ($error and $error == 0) {
2138 # $old_pkg->transfer failed.
2139 $dbh->rollback if $oldAutoCommit;
2146 # Transfers were successful, but we went through all of the
2147 # new packages and still had services left on the old package.
2148 # We can't cancel the package under the circumstances, so abort.
2149 $dbh->rollback if $oldAutoCommit;
2150 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2152 $error = $old_pkg->cancel( quiet=>1 );
2158 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2162 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2164 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2165 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2168 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2169 replace. The services (see L<FS::cust_svc>) are moved to the
2170 new billing items. An error is returned if this is not possible (see
2173 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2174 newly-created cust_pkg objects.
2179 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2181 # Transactionize this whole mess
2182 local $SIG{HUP} = 'IGNORE';
2183 local $SIG{INT} = 'IGNORE';
2184 local $SIG{QUIT} = 'IGNORE';
2185 local $SIG{TERM} = 'IGNORE';
2186 local $SIG{TSTP} = 'IGNORE';
2187 local $SIG{PIPE} = 'IGNORE';
2189 my $oldAutoCommit = $FS::UID::AutoCommit;
2190 local $FS::UID::AutoCommit = 0;
2194 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2197 while(scalar(@old_cust_pkg)) {
2199 my $custnum = $old_cust_pkg[0]->custnum;
2200 my (@remove) = map { $_->pkgnum }
2201 grep { $_->custnum == $custnum } @old_cust_pkg;
2202 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2204 my $error = order $custnum, $pkgparts, \@remove, \@return;
2206 push @errors, $error
2208 push @$return_cust_pkg, @return;
2211 if (scalar(@errors)) {
2212 $dbh->rollback if $oldAutoCommit;
2213 return join(' / ', @errors);
2216 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2222 Associates this package with a (suspension or cancellation) reason (see
2223 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2226 Available options are:
2230 =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.
2232 =item otaker_reason - the access_user (see L<FS::access_user>) providing the reason
2234 =item date - a unix timestamp
2236 =item action - the action (cancel, susp, adjourn, expire) associated with the reason
2240 If there is an error, returns the error, otherwise returns false.
2245 my ($self, %options) = @_;
2247 my $otaker = $options{reason_otaker} ||
2248 $FS::CurrentUser::CurrentUser->username;
2251 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2255 } elsif ( ref($options{'reason'}) ) {
2257 return 'Enter a new reason (or select an existing one)'
2258 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2260 my $reason = new FS::reason({
2261 'reason_type' => $options{'reason'}->{'typenum'},
2262 'reason' => $options{'reason'}->{'reason'},
2264 my $error = $reason->insert;
2265 return $error if $error;
2267 $reasonnum = $reason->reasonnum;
2270 return "Unparsable reason: ". $options{'reason'};
2273 my $cust_pkg_reason =
2274 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2275 'reasonnum' => $reasonnum,
2276 'otaker' => $otaker,
2277 'action' => substr(uc($options{'action'}),0,1),
2278 'date' => $options{'date'}
2283 $cust_pkg_reason->insert;
2286 =item set_usage USAGE_VALUE_HASHREF
2288 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2289 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2290 upbytes, downbytes, and totalbytes are appropriate keys.
2292 All svc_accts which are part of this package have their values reset.
2297 my ($self, $valueref) = @_;
2299 foreach my $cust_svc ($self->cust_svc){
2300 my $svc_x = $cust_svc->svc_x;
2301 $svc_x->set_usage($valueref)
2302 if $svc_x->can("set_usage");
2310 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
2312 In sub order, the @pkgparts array (passed by reference) is clobbered.
2314 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
2315 method to pass dates to the recur_prog expression, it should do so.
2317 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
2318 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
2319 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
2320 configuration values. Probably need a subroutine which decides what to do
2321 based on whether or not we've fetched the user yet, rather than a hash. See
2322 FS::UID and the TODO.
2324 Now that things are transactional should the check in the insert method be
2329 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
2330 L<FS::pkg_svc>, schema.html from the base documentation