4 use vars qw(@ISA $disable_agentcheck $DEBUG $me);
6 use Scalar::Util qw( blessed );
7 use List::Util qw(max);
10 use FS::UID qw( getotaker dbh );
11 use FS::Misc qw( send_email );
12 use FS::Record qw( qsearch qsearchs );
14 use FS::cust_main_Mixin;
18 use FS::cust_location;
20 use FS::cust_bill_pkg;
21 use FS::cust_pkg_detail;
26 use FS::cust_pkg_reason;
28 use FS::cust_pkg_discount;
32 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
34 # because they load configuration by setting FS::UID::callback (see TODO)
40 # for sending cancel emails in sub cancel
43 @ISA = qw( FS::m2m_Common FS::cust_main_Mixin FS::option_Common FS::Record );
46 $me = '[FS::cust_pkg]';
48 $disable_agentcheck = 0;
52 my ( $hashref, $cache ) = @_;
53 #if ( $hashref->{'pkgpart'} ) {
54 if ( $hashref->{'pkg'} ) {
55 # #@{ $self->{'_pkgnum'} } = ();
56 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
57 # $self->{'_pkgpart'} = $subcache;
58 # #push @{ $self->{'_pkgnum'} },
59 # FS::part_pkg->new_or_cached($hashref, $subcache);
60 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
62 if ( exists $hashref->{'svcnum'} ) {
63 #@{ $self->{'_pkgnum'} } = ();
64 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
65 $self->{'_svcnum'} = $subcache;
66 #push @{ $self->{'_pkgnum'} },
67 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
73 FS::cust_pkg - Object methods for cust_pkg objects
79 $record = new FS::cust_pkg \%hash;
80 $record = new FS::cust_pkg { 'column' => 'value' };
82 $error = $record->insert;
84 $error = $new_record->replace($old_record);
86 $error = $record->delete;
88 $error = $record->check;
90 $error = $record->cancel;
92 $error = $record->suspend;
94 $error = $record->unsuspend;
96 $part_pkg = $record->part_pkg;
98 @labels = $record->labels;
100 $seconds = $record->seconds_since($timestamp);
102 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
103 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
107 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
108 inherits from FS::Record. The following fields are currently supported:
114 Primary key (assigned automatically for new billing items)
118 Customer (see L<FS::cust_main>)
122 Billing item definition (see L<FS::part_pkg>)
126 Optional link to package location (see L<FS::location>)
138 date (next bill date)
162 order taker (assigned automatically if null, see L<FS::UID>)
166 If this field is set to 1, disables the automatic
167 unsuspension of this package when using the B<unsuspendauto> config option.
171 If not set, defaults to 1
175 Date of change from previous package
185 =item change_locationnum
191 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
192 are specified as UNIX timestamps; see L<perlfunc/"time">. Also see
193 L<Time::Local> and L<Date::Parse> for conversion functions.
201 Create a new billing item. To add the item to the database, see L<"insert">.
205 sub table { 'cust_pkg'; }
206 sub cust_linked { $_[0]->cust_main_custnum; }
207 sub cust_unlinked_msg {
209 "WARNING: can't find cust_main.custnum ". $self->custnum.
210 ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
213 =item insert [ OPTION => VALUE ... ]
215 Adds this billing item to the database ("Orders" the item). If there is an
216 error, returns the error, otherwise returns false.
218 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
219 will be used to look up the package definition and agent restrictions will be
222 If the additional field I<refnum> is defined, an FS::pkg_referral record will
223 be created and inserted. Multiple FS::pkg_referral records can be created by
224 setting I<refnum> to an array reference of refnums or a hash reference with
225 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
226 record will be created corresponding to cust_main.refnum.
228 The following options are available:
234 If set true, supresses any referral credit to a referring customer.
238 cust_pkg_option records will be created
242 a ticket will be added to this customer with this subject
246 an optional queue name for ticket additions
253 my( $self, %options ) = @_;
255 local $SIG{HUP} = 'IGNORE';
256 local $SIG{INT} = 'IGNORE';
257 local $SIG{QUIT} = 'IGNORE';
258 local $SIG{TERM} = 'IGNORE';
259 local $SIG{TSTP} = 'IGNORE';
260 local $SIG{PIPE} = 'IGNORE';
262 my $oldAutoCommit = $FS::UID::AutoCommit;
263 local $FS::UID::AutoCommit = 0;
266 my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
268 $dbh->rollback if $oldAutoCommit;
272 $self->refnum($self->cust_main->refnum) unless $self->refnum;
273 $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
274 $self->process_m2m( 'link_table' => 'pkg_referral',
275 'target_table' => 'part_referral',
276 'params' => $self->refnum,
279 if ( $self->discountnum ) {
280 #XXX new/custom discount case
281 my $cust_pkg_discount = new FS::cust_pkg_discount {
282 'pkgnum' => $self->pkgnum,
283 'discountnum' => $self->discountnum,
285 'end_date' => '', #XXX
286 'otaker' => $self->otaker,
288 my $error = $cust_pkg_discount->insert;
290 $dbh->rollback if $oldAutoCommit;
295 #if ( $self->reg_code ) {
296 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
297 # $error = $reg_code->delete;
299 # $dbh->rollback if $oldAutoCommit;
304 my $conf = new FS::Conf;
306 if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
308 use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
315 my $q = new RT::Queue($RT::SystemUser);
316 $q->Load($options{ticket_queue}) if $options{ticket_queue};
317 my $t = new RT::Ticket($RT::SystemUser);
318 my $mime = new MIME::Entity;
319 $mime->build( Type => 'text/plain', Data => $options{ticket_subject} );
320 $t->Create( $options{ticket_queue} ? (Queue => $q) : (),
321 Subject => $options{ticket_subject},
324 $t->AddLink( Type => 'MemberOf',
325 Target => 'freeside://freeside/cust_main/'. $self->custnum,
329 if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
330 my $queue = new FS::queue {
331 'job' => 'FS::cust_main::queueable_print',
333 $error = $queue->insert(
334 'custnum' => $self->custnum,
335 'template' => 'welcome_letter',
339 warn "can't send welcome letter: $error";
344 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
351 This method now works but you probably shouldn't use it.
353 You don't want to delete billing items, because there would then be no record
354 the customer ever purchased the item. Instead, see the cancel method.
359 # return "Can't delete cust_pkg records!";
362 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
364 Replaces the OLD_RECORD with this one in the database. If there is an error,
365 returns the error, otherwise returns false.
367 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
369 Changing pkgpart may have disasterous effects. See the order subroutine.
371 setup and bill are normally updated by calling the bill method of a customer
372 object (see L<FS::cust_main>).
374 suspend is normally updated by the suspend and unsuspend methods.
376 cancel is normally updated by the cancel method (and also the order subroutine
379 Available options are:
385 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.
389 the access_user (see L<FS::access_user>) providing the reason
393 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
402 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
407 ( ref($_[0]) eq 'HASH' )
411 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
412 return "Can't change otaker!" if $old->otaker ne $new->otaker;
415 #return "Can't change setup once it exists!"
416 # if $old->getfield('setup') &&
417 # $old->getfield('setup') != $new->getfield('setup');
419 #some logic for bill, susp, cancel?
421 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
423 local $SIG{HUP} = 'IGNORE';
424 local $SIG{INT} = 'IGNORE';
425 local $SIG{QUIT} = 'IGNORE';
426 local $SIG{TERM} = 'IGNORE';
427 local $SIG{TSTP} = 'IGNORE';
428 local $SIG{PIPE} = 'IGNORE';
430 my $oldAutoCommit = $FS::UID::AutoCommit;
431 local $FS::UID::AutoCommit = 0;
434 foreach my $method ( qw(adjourn expire) ) { # How many reasons?
435 if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
436 my $error = $new->insert_reason(
437 'reason' => $options->{'reason'},
438 'date' => $new->$method,
440 'reason_otaker' => $options->{'reason_otaker'},
443 dbh->rollback if $oldAutoCommit;
444 return "Error inserting cust_pkg_reason: $error";
449 #save off and freeze RADIUS attributes for any associated svc_acct records
451 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
453 #also check for specific exports?
454 # to avoid spurious modify export events
455 @svc_acct = map { $_->svc_x }
456 grep { $_->part_svc->svcdb eq 'svc_acct' }
459 $_->snapshot foreach @svc_acct;
463 my $error = $new->SUPER::replace($old,
464 $options->{options} ? $options->{options} : ()
467 $dbh->rollback if $oldAutoCommit;
471 #for prepaid packages,
472 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
473 foreach my $old_svc_acct ( @svc_acct ) {
474 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
475 my $s_error = $new_svc_acct->replace($old_svc_acct);
477 $dbh->rollback if $oldAutoCommit;
482 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
489 Checks all fields to make sure this is a valid billing item. If there is an
490 error, returns the error, otherwise returns false. Called by the insert and
498 $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
501 $self->ut_numbern('pkgnum')
502 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
503 || $self->ut_numbern('pkgpart')
504 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
505 || $self->ut_numbern('start_date')
506 || $self->ut_numbern('setup')
507 || $self->ut_numbern('bill')
508 || $self->ut_numbern('susp')
509 || $self->ut_numbern('cancel')
510 || $self->ut_numbern('adjourn')
511 || $self->ut_numbern('expire')
513 return $error if $error;
515 if ( $self->reg_code ) {
517 unless ( grep { $self->pkgpart == $_->pkgpart }
518 map { $_->reg_code_pkg }
519 qsearchs( 'reg_code', { 'code' => $self->reg_code,
520 'agentnum' => $self->cust_main->agentnum })
522 return "Unknown registration code";
525 } elsif ( $self->promo_code ) {
528 qsearchs('part_pkg', {
529 'pkgpart' => $self->pkgpart,
530 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
532 return 'Unknown promotional code' unless $promo_part_pkg;
536 unless ( $disable_agentcheck ) {
538 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
539 return "agent ". $agent->agentnum. ':'. $agent->agent.
540 " can't purchase pkgpart ". $self->pkgpart
541 unless $agent->pkgpart_hashref->{ $self->pkgpart }
542 || $agent->agentnum == $self->part_pkg->agentnum;
545 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
546 return $error if $error;
550 $self->otaker(getotaker) unless $self->otaker;
551 $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
554 if ( $self->dbdef_table->column('manual_flag') ) {
555 $self->manual_flag('') if $self->manual_flag eq ' ';
556 $self->manual_flag =~ /^([01]?)$/
557 or return "Illegal manual_flag ". $self->manual_flag;
558 $self->manual_flag($1);
564 =item cancel [ OPTION => VALUE ... ]
566 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
567 in this package, then cancels the package itself (sets the cancel field to
570 Available options are:
574 =item quiet - can be set true to supress email cancellation notices.
576 =item time - can be set to cancel the package based on a specific future or historical date. Using time ensures that the remaining amount is calculated correctly. Note however that this is an immediate cancel and just changes the date. You are PROBABLY looking to expire the account instead of using this.
578 =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.
580 =item date - can be set to a unix style timestamp to specify when to cancel (expire)
582 =item nobill - can be set true to skip billing if it might otherwise be done.
586 If there is an error, returns the error, otherwise returns false.
591 my( $self, %options ) = @_;
594 my $conf = new FS::Conf;
596 warn "cust_pkg::cancel called with options".
597 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
600 local $SIG{HUP} = 'IGNORE';
601 local $SIG{INT} = 'IGNORE';
602 local $SIG{QUIT} = 'IGNORE';
603 local $SIG{TERM} = 'IGNORE';
604 local $SIG{TSTP} = 'IGNORE';
605 local $SIG{PIPE} = 'IGNORE';
607 my $oldAutoCommit = $FS::UID::AutoCommit;
608 local $FS::UID::AutoCommit = 0;
611 my $old = $self->select_for_update;
613 if ( $old->get('cancel') || $self->get('cancel') ) {
614 dbh->rollback if $oldAutoCommit;
615 return ""; # no error
618 my $date = $options{date} if $options{date}; # expire/cancel later
619 $date = '' if ($date && $date <= time); # complain instead?
621 #race condition: usage could be ongoing until unprovisioned
622 #resolved by performing a change package instead (which unprovisions) and
624 if ( !$options{nobill} && !$date && $conf->exists('bill_usage_on_cancel') ) {
625 my $copy = $self->new({$self->hash});
627 $copy->cust_main->bill( pkg_list => [ $copy ], cancel => 1 );
628 warn "Error billing during cancel, custnum ".
629 #$self->cust_main->custnum. ": $error"
635 my $cancel_time = $options{'time'} || time;
637 if ( $options{'reason'} ) {
638 $error = $self->insert_reason( 'reason' => $options{'reason'},
639 'action' => $date ? 'expire' : 'cancel',
640 'date' => $date ? $date : $cancel_time,
641 'reason_otaker' => $options{'reason_otaker'},
644 dbh->rollback if $oldAutoCommit;
645 return "Error inserting cust_pkg_reason: $error";
651 foreach my $cust_svc (
654 sort { $a->[1] <=> $b->[1] }
655 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
656 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
659 my $error = $cust_svc->cancel;
662 $dbh->rollback if $oldAutoCommit;
663 return "Error cancelling cust_svc: $error";
667 # Add a credit for remaining service
668 my $remaining_value = $self->calc_remain(time=>$cancel_time);
669 if ( $remaining_value > 0 && !$options{'no_credit'} ) {
670 my $error = $self->cust_main->credit(
672 'Credit for unused time on '. $self->part_pkg->pkg,
673 'reason_type' => $conf->config('cancel_credit_type'),
676 $dbh->rollback if $oldAutoCommit;
677 return "Error crediting customer \$$remaining_value for unused time on".
678 $self->part_pkg->pkg. ": $error";
683 my %hash = $self->hash;
684 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
685 my $new = new FS::cust_pkg ( \%hash );
686 $error = $new->replace( $self, options => { $self->options } );
688 $dbh->rollback if $oldAutoCommit;
692 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
693 return '' if $date; #no errors
695 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
696 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
697 my $error = send_email(
698 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
699 'to' => \@invoicing_list,
700 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
701 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
703 #should this do something on errors?
710 =item cancel_if_expired [ NOW_TIMESTAMP ]
712 Cancels this package if its expire date has been reached.
716 sub cancel_if_expired {
718 my $time = shift || time;
719 return '' unless $self->expire && $self->expire <= $time;
720 my $error = $self->cancel;
722 return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
723 $self->custnum. ": $error";
730 Cancels any pending expiration (sets the expire field to null).
732 If there is an error, returns the error, otherwise returns false.
737 my( $self, %options ) = @_;
740 local $SIG{HUP} = 'IGNORE';
741 local $SIG{INT} = 'IGNORE';
742 local $SIG{QUIT} = 'IGNORE';
743 local $SIG{TERM} = 'IGNORE';
744 local $SIG{TSTP} = 'IGNORE';
745 local $SIG{PIPE} = 'IGNORE';
747 my $oldAutoCommit = $FS::UID::AutoCommit;
748 local $FS::UID::AutoCommit = 0;
751 my $old = $self->select_for_update;
753 my $pkgnum = $old->pkgnum;
754 if ( $old->get('cancel') || $self->get('cancel') ) {
755 dbh->rollback if $oldAutoCommit;
756 return "Can't unexpire cancelled package $pkgnum";
757 # or at least it's pointless
760 unless ( $old->get('expire') && $self->get('expire') ) {
761 dbh->rollback if $oldAutoCommit;
762 return ""; # no error
765 my %hash = $self->hash;
766 $hash{'expire'} = '';
767 my $new = new FS::cust_pkg ( \%hash );
768 $error = $new->replace( $self, options => { $self->options } );
770 $dbh->rollback if $oldAutoCommit;
774 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
780 =item suspend [ OPTION => VALUE ... ]
782 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
783 package, then suspends the package itself (sets the susp field to now).
785 Available options are:
789 =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.
791 =item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
795 If there is an error, returns the error, otherwise returns false.
800 my( $self, %options ) = @_;
803 local $SIG{HUP} = 'IGNORE';
804 local $SIG{INT} = 'IGNORE';
805 local $SIG{QUIT} = 'IGNORE';
806 local $SIG{TERM} = 'IGNORE';
807 local $SIG{TSTP} = 'IGNORE';
808 local $SIG{PIPE} = 'IGNORE';
810 my $oldAutoCommit = $FS::UID::AutoCommit;
811 local $FS::UID::AutoCommit = 0;
814 my $old = $self->select_for_update;
816 my $pkgnum = $old->pkgnum;
817 if ( $old->get('cancel') || $self->get('cancel') ) {
818 dbh->rollback if $oldAutoCommit;
819 return "Can't suspend cancelled package $pkgnum";
822 if ( $old->get('susp') || $self->get('susp') ) {
823 dbh->rollback if $oldAutoCommit;
824 return ""; # no error # complain on adjourn?
827 my $date = $options{date} if $options{date}; # adjourn/suspend later
828 $date = '' if ($date && $date <= time); # complain instead?
830 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
831 dbh->rollback if $oldAutoCommit;
832 return "Package $pkgnum expires before it would be suspended.";
835 my $suspend_time = $options{'time'} || time;
837 if ( $options{'reason'} ) {
838 $error = $self->insert_reason( 'reason' => $options{'reason'},
839 'action' => $date ? 'adjourn' : 'suspend',
840 'date' => $date ? $date : $suspend_time,
841 'reason_otaker' => $options{'reason_otaker'},
844 dbh->rollback if $oldAutoCommit;
845 return "Error inserting cust_pkg_reason: $error";
853 foreach my $cust_svc (
854 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
856 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
858 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
859 $dbh->rollback if $oldAutoCommit;
860 return "Illegal svcdb value in part_svc!";
863 require "FS/$svcdb.pm";
865 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
867 $error = $svc->suspend;
869 $dbh->rollback if $oldAutoCommit;
872 my( $label, $value ) = $cust_svc->label;
873 push @labels, "$label: $value";
877 my $conf = new FS::Conf;
878 if ( $conf->config('suspend_email_admin') ) {
880 my $error = send_email(
881 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
882 #invoice_from ??? well as good as any
883 'to' => $conf->config('suspend_email_admin'),
884 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
886 "This is an automatic message from your Freeside installation\n",
887 "informing you that the following customer package has been suspended:\n",
889 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
890 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
891 ( map { "Service : $_\n" } @labels ),
896 warn "WARNING: can't send suspension admin email (suspending anyway): ".
904 my %hash = $self->hash;
906 $hash{'adjourn'} = $date;
908 $hash{'susp'} = $suspend_time;
910 my $new = new FS::cust_pkg ( \%hash );
911 $error = $new->replace( $self, options => { $self->options } );
913 $dbh->rollback if $oldAutoCommit;
917 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
922 =item unsuspend [ OPTION => VALUE ... ]
924 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
925 package, then unsuspends the package itself (clears the susp field and the
926 adjourn field if it is in the past).
928 Available options are:
932 =item adjust_next_bill
934 Can be set true to adjust the next bill date forward by
935 the amount of time the account was inactive. This was set true by default
936 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
937 explicitly requested. Price plans for which this makes sense (anniversary-date
938 based than prorate or subscription) could have an option to enable this
943 If there is an error, returns the error, otherwise returns false.
948 my( $self, %opt ) = @_;
951 local $SIG{HUP} = 'IGNORE';
952 local $SIG{INT} = 'IGNORE';
953 local $SIG{QUIT} = 'IGNORE';
954 local $SIG{TERM} = 'IGNORE';
955 local $SIG{TSTP} = 'IGNORE';
956 local $SIG{PIPE} = 'IGNORE';
958 my $oldAutoCommit = $FS::UID::AutoCommit;
959 local $FS::UID::AutoCommit = 0;
962 my $old = $self->select_for_update;
964 my $pkgnum = $old->pkgnum;
965 if ( $old->get('cancel') || $self->get('cancel') ) {
966 dbh->rollback if $oldAutoCommit;
967 return "Can't unsuspend cancelled package $pkgnum";
970 unless ( $old->get('susp') && $self->get('susp') ) {
971 dbh->rollback if $oldAutoCommit;
972 return ""; # no error # complain instead?
975 foreach my $cust_svc (
976 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
978 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
980 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
981 $dbh->rollback if $oldAutoCommit;
982 return "Illegal svcdb value in part_svc!";
985 require "FS/$svcdb.pm";
987 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
989 $error = $svc->unsuspend;
991 $dbh->rollback if $oldAutoCommit;
998 my %hash = $self->hash;
999 my $inactive = time - $hash{'susp'};
1001 my $conf = new FS::Conf;
1003 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
1004 if ( $opt{'adjust_next_bill'}
1005 || $conf->exists('unsuspend-always_adjust_next_bill_date') )
1006 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
1009 $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
1010 my $new = new FS::cust_pkg ( \%hash );
1011 $error = $new->replace( $self, options => { $self->options } );
1013 $dbh->rollback if $oldAutoCommit;
1017 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1024 Cancels any pending suspension (sets the adjourn field to null).
1026 If there is an error, returns the error, otherwise returns false.
1031 my( $self, %options ) = @_;
1034 local $SIG{HUP} = 'IGNORE';
1035 local $SIG{INT} = 'IGNORE';
1036 local $SIG{QUIT} = 'IGNORE';
1037 local $SIG{TERM} = 'IGNORE';
1038 local $SIG{TSTP} = 'IGNORE';
1039 local $SIG{PIPE} = 'IGNORE';
1041 my $oldAutoCommit = $FS::UID::AutoCommit;
1042 local $FS::UID::AutoCommit = 0;
1045 my $old = $self->select_for_update;
1047 my $pkgnum = $old->pkgnum;
1048 if ( $old->get('cancel') || $self->get('cancel') ) {
1049 dbh->rollback if $oldAutoCommit;
1050 return "Can't unadjourn cancelled package $pkgnum";
1051 # or at least it's pointless
1054 if ( $old->get('susp') || $self->get('susp') ) {
1055 dbh->rollback if $oldAutoCommit;
1056 return "Can't unadjourn suspended package $pkgnum";
1057 # perhaps this is arbitrary
1060 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1061 dbh->rollback if $oldAutoCommit;
1062 return ""; # no error
1065 my %hash = $self->hash;
1066 $hash{'adjourn'} = '';
1067 my $new = new FS::cust_pkg ( \%hash );
1068 $error = $new->replace( $self, options => { $self->options } );
1070 $dbh->rollback if $oldAutoCommit;
1074 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1081 =item change HASHREF | OPTION => VALUE ...
1083 Changes this package: cancels it and creates a new one, with a different
1084 pkgpart or locationnum or both. All services are transferred to the new
1085 package (no change will be made if this is not possible).
1087 Options may be passed as a list of key/value pairs or as a hash reference.
1094 New locationnum, to change the location for this package.
1098 New FS::cust_location object, to create a new location and assign it
1103 New pkgpart (see L<FS::part_pkg>).
1107 New refnum (see L<FS::part_referral>).
1111 At least one option must be specified (otherwise, what's the point?)
1113 Returns either the new FS::cust_pkg object or a scalar error.
1117 my $err_or_new_cust_pkg = $old_cust_pkg->change
1121 #some false laziness w/order
1124 my $opt = ref($_[0]) ? shift : { @_ };
1126 # my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1129 my $conf = new FS::Conf;
1131 # Transactionize this whole mess
1132 local $SIG{HUP} = 'IGNORE';
1133 local $SIG{INT} = 'IGNORE';
1134 local $SIG{QUIT} = 'IGNORE';
1135 local $SIG{TERM} = 'IGNORE';
1136 local $SIG{TSTP} = 'IGNORE';
1137 local $SIG{PIPE} = 'IGNORE';
1139 my $oldAutoCommit = $FS::UID::AutoCommit;
1140 local $FS::UID::AutoCommit = 0;
1149 #$hash{$_} = $self->$_() foreach qw( last_bill bill );
1151 #$hash{$_} = $self->$_() foreach qw( setup );
1153 $hash{'setup'} = $time if $self->setup;
1155 $hash{'change_date'} = $time;
1156 $hash{"change_$_"} = $self->$_()
1157 foreach qw( pkgnum pkgpart locationnum );
1159 if ( $opt->{'cust_location'} &&
1160 ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) {
1161 $error = $opt->{'cust_location'}->insert;
1163 $dbh->rollback if $oldAutoCommit;
1164 return "inserting cust_location (transaction rolled back): $error";
1166 $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1169 # Create the new package.
1170 my $cust_pkg = new FS::cust_pkg {
1171 custnum => $self->custnum,
1172 pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ),
1173 refnum => ( $opt->{'refnum'} || $self->refnum ),
1174 locationnum => ( $opt->{'locationnum'} || $self->locationnum ),
1178 $error = $cust_pkg->insert( 'change' => 1 );
1180 $dbh->rollback if $oldAutoCommit;
1184 # Transfer services and cancel old package.
1186 $error = $self->transfer($cust_pkg);
1187 if ($error and $error == 0) {
1188 # $old_pkg->transfer failed.
1189 $dbh->rollback if $oldAutoCommit;
1193 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1194 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1195 $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1196 if ($error and $error == 0) {
1197 # $old_pkg->transfer failed.
1198 $dbh->rollback if $oldAutoCommit;
1204 # Transfers were successful, but we still had services left on the old
1205 # package. We can't change the package under this circumstances, so abort.
1206 $dbh->rollback if $oldAutoCommit;
1207 return "Unable to transfer all services from package ". $self->pkgnum;
1210 #reset usage if changing pkgpart
1211 # AND usage rollover is off (otherwise adds twice, now and at package bill)
1212 if ($self->pkgpart != $cust_pkg->pkgpart) {
1213 my $part_pkg = $cust_pkg->part_pkg;
1214 $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1218 if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover');
1221 $dbh->rollback if $oldAutoCommit;
1222 return "Error setting usage values: $error";
1226 #Good to go, cancel old package.
1227 $error = $self->cancel( quiet=>1 );
1229 $dbh->rollback if $oldAutoCommit;
1233 if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
1235 my $error = $cust_pkg->cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
1237 $dbh->rollback if $oldAutoCommit;
1242 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1250 Returns the last bill date, or if there is no last bill date, the setup date.
1251 Useful for billing metered services.
1257 return $self->setfield('last_bill', $_[0]) if @_;
1258 return $self->getfield('last_bill') if $self->getfield('last_bill');
1259 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1260 'edate' => $self->bill, } );
1261 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1264 =item last_cust_pkg_reason ACTION
1266 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1267 Returns false if there is no reason or the package is not currenly ACTION'd
1268 ACTION is one of adjourn, susp, cancel, or expire.
1272 sub last_cust_pkg_reason {
1273 my ( $self, $action ) = ( shift, shift );
1274 my $date = $self->get($action);
1276 'table' => 'cust_pkg_reason',
1277 'hashref' => { 'pkgnum' => $self->pkgnum,
1278 'action' => substr(uc($action), 0, 1),
1281 'order_by' => 'ORDER BY num DESC LIMIT 1',
1285 =item last_reason ACTION
1287 Returns the most recent ACTION FS::reason associated with the package.
1288 Returns false if there is no reason or the package is not currenly ACTION'd
1289 ACTION is one of adjourn, susp, cancel, or expire.
1294 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1295 $cust_pkg_reason->reason
1296 if $cust_pkg_reason;
1301 Returns the definition for this billing item, as an FS::part_pkg object (see
1308 return $self->{'_pkgpart'} if $self->{'_pkgpart'};
1309 cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
1310 qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1315 Returns the cancelled package this package was changed from, if any.
1321 return '' unless $self->change_pkgnum;
1322 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1327 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1334 $self->part_pkg->calc_setup($self, @_);
1339 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1346 $self->part_pkg->calc_recur($self, @_);
1351 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1358 $self->part_pkg->calc_remain($self, @_);
1363 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1370 $self->part_pkg->calc_cancel($self, @_);
1375 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1381 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1384 =item cust_pkg_detail [ DETAILTYPE ]
1386 Returns any customer package details for this package (see
1387 L<FS::cust_pkg_detail>).
1389 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1393 sub cust_pkg_detail {
1395 my %hash = ( 'pkgnum' => $self->pkgnum );
1396 $hash{detailtype} = shift if @_;
1398 'table' => 'cust_pkg_detail',
1399 'hashref' => \%hash,
1400 'order_by' => 'ORDER BY weight, pkgdetailnum',
1404 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1406 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1408 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1410 If there is an error, returns the error, otherwise returns false.
1414 sub set_cust_pkg_detail {
1415 my( $self, $detailtype, @details ) = @_;
1417 local $SIG{HUP} = 'IGNORE';
1418 local $SIG{INT} = 'IGNORE';
1419 local $SIG{QUIT} = 'IGNORE';
1420 local $SIG{TERM} = 'IGNORE';
1421 local $SIG{TSTP} = 'IGNORE';
1422 local $SIG{PIPE} = 'IGNORE';
1424 my $oldAutoCommit = $FS::UID::AutoCommit;
1425 local $FS::UID::AutoCommit = 0;
1428 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1429 my $error = $current->delete;
1431 $dbh->rollback if $oldAutoCommit;
1432 return "error removing old detail: $error";
1436 foreach my $detail ( @details ) {
1437 my $cust_pkg_detail = new FS::cust_pkg_detail {
1438 'pkgnum' => $self->pkgnum,
1439 'detailtype' => $detailtype,
1440 'detail' => $detail,
1442 my $error = $cust_pkg_detail->insert;
1444 $dbh->rollback if $oldAutoCommit;
1445 return "error adding new detail: $error";
1450 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1457 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
1461 #false laziness w/cust_bill.pm
1465 'table' => 'cust_event',
1466 'addl_from' => 'JOIN part_event USING ( eventpart )',
1467 'hashref' => { 'tablenum' => $self->pkgnum },
1468 'extra_sql' => " AND eventtable = 'cust_pkg' ",
1472 =item num_cust_event
1474 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
1478 #false laziness w/cust_bill.pm
1479 sub num_cust_event {
1482 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
1483 " WHERE tablenum = ? AND eventtable = 'cust_pkg'";
1484 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
1485 $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
1486 $sth->fetchrow_arrayref->[0];
1489 =item cust_svc [ SVCPART ]
1491 Returns the services for this package, as FS::cust_svc objects (see
1492 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1500 return () unless $self->num_cust_svc(@_);
1503 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1504 'svcpart' => shift, } );
1507 cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
1509 #if ( $self->{'_svcnum'} ) {
1510 # values %{ $self->{'_svcnum'}->cache };
1512 $self->_sort_cust_svc(
1513 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1519 =item overlimit [ SVCPART ]
1521 Returns the services for this package which have exceeded their
1522 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1523 is specified, return only the matching services.
1529 return () unless $self->num_cust_svc(@_);
1530 grep { $_->overlimit } $self->cust_svc(@_);
1533 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1535 Returns historical services for this package created before END TIMESTAMP and
1536 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1537 (see L<FS::h_cust_svc>).
1544 $self->_sort_cust_svc(
1545 [ qsearch( 'h_cust_svc',
1546 { 'pkgnum' => $self->pkgnum, },
1547 FS::h_cust_svc->sql_h_search(@_),
1553 sub _sort_cust_svc {
1554 my( $self, $arrayref ) = @_;
1557 sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] };
1562 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1563 'svcpart' => $_->svcpart } );
1565 $pkg_svc ? $pkg_svc->primary_svc : '',
1566 $pkg_svc ? $pkg_svc->quantity : 0,
1573 =item num_cust_svc [ SVCPART ]
1575 Returns the number of provisioned services for this package. If a svcpart is
1576 specified, counts only the matching services.
1583 return $self->{'_num_cust_svc'}
1585 && exists($self->{'_num_cust_svc'})
1586 && $self->{'_num_cust_svc'} =~ /\d/;
1588 cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
1591 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1592 $sql .= ' AND svcpart = ?' if @_;
1594 my $sth = dbh->prepare($sql) or die dbh->errstr;
1595 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1596 $sth->fetchrow_arrayref->[0];
1599 =item available_part_svc
1601 Returns a list of FS::part_svc objects representing services included in this
1602 package but not yet provisioned. Each FS::part_svc object also has an extra
1603 field, I<num_avail>, which specifies the number of available services.
1607 sub available_part_svc {
1609 grep { $_->num_avail > 0 }
1611 my $part_svc = $_->part_svc;
1612 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1613 $_->quantity - $self->num_cust_svc($_->svcpart);
1616 $self->part_pkg->pkg_svc;
1621 Returns a list of FS::part_svc objects representing provisioned and available
1622 services included in this package. Each FS::part_svc object also has the
1623 following extra fields:
1627 =item num_cust_svc (count)
1629 =item num_avail (quantity - count)
1631 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1634 label -> ($cust_svc->label)[1]
1643 #XXX some sort of sort order besides numeric by svcpart...
1644 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1646 my $part_svc = $pkg_svc->part_svc;
1647 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1648 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1649 $part_svc->{'Hash'}{'num_avail'} =
1650 max( 0, $pkg_svc->quantity - $num_cust_svc );
1651 $part_svc->{'Hash'}{'cust_pkg_svc'} =
1652 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1654 } $self->part_pkg->pkg_svc;
1657 push @part_svc, map {
1659 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1660 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1661 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1662 $part_svc->{'Hash'}{'cust_pkg_svc'} =
1663 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1665 } $self->extra_part_svc;
1671 =item extra_part_svc
1673 Returns a list of FS::part_svc objects corresponding to services in this
1674 package which are still provisioned but not (any longer) available in the
1679 sub extra_part_svc {
1682 my $pkgnum = $self->pkgnum;
1683 my $pkgpart = $self->pkgpart;
1686 # 'table' => 'part_svc',
1689 # "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1690 # WHERE pkg_svc.svcpart = part_svc.svcpart
1691 # AND pkg_svc.pkgpart = ?
1694 # AND 0 < ( SELECT COUNT(*) FROM cust_svc
1695 # LEFT JOIN cust_pkg USING ( pkgnum )
1696 # WHERE cust_svc.svcpart = part_svc.svcpart
1699 # 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1702 #seems to benchmark slightly faster...
1704 'select' => 'DISTINCT ON (svcpart) part_svc.*',
1705 'table' => 'part_svc',
1707 'LEFT JOIN pkg_svc ON ( pkg_svc.svcpart = part_svc.svcpart
1708 AND pkg_svc.pkgpart = ?
1711 LEFT JOIN cust_svc ON ( cust_svc.svcpart = part_svc.svcpart )
1712 LEFT JOIN cust_pkg USING ( pkgnum )
1715 'extra_sql' => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
1716 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1722 Returns a short status string for this package, currently:
1726 =item not yet billed
1728 =item one-time charge
1743 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1745 return 'cancelled' if $self->get('cancel');
1746 return 'suspended' if $self->susp;
1747 return 'not yet billed' unless $self->setup;
1748 return 'one-time charge' if $freq =~ /^(0|$)/;
1754 Class method that returns the list of possible status strings for packages
1755 (see L<the status method|/status>). For example:
1757 @statuses = FS::cust_pkg->statuses();
1761 tie my %statuscolor, 'Tie::IxHash',
1762 'not yet billed' => '000000',
1763 'one-time charge' => '000000',
1764 'active' => '00CC00',
1765 'suspended' => 'FF9900',
1766 'cancelled' => 'FF0000',
1770 my $self = shift; #could be class...
1771 #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1772 # # mayble split btw one-time vs. recur
1778 Returns a hex triplet color string for this package's status.
1784 $statuscolor{$self->status};
1789 Returns a label for this package. (Currently "pkgnum: pkg - comment" or
1790 "pkg-comment" depending on user preference).
1796 my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
1797 $label = $self->pkgnum. ": $label"
1798 if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
1802 =item pkg_label_long
1804 Returns a long label for this package, adding the primary service's label to
1809 sub pkg_label_long {
1811 my $label = $self->pkg_label;
1812 my $cust_svc = $self->primary_cust_svc;
1813 $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
1817 =item primary_cust_svc
1819 Returns a primary service (as FS::cust_svc object) if one can be identified.
1823 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
1825 sub primary_cust_svc {
1828 my @cust_svc = $self->cust_svc;
1830 return '' unless @cust_svc; #no serivces - irrelevant then
1832 return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
1834 # primary service as specified in the package definition
1835 # or exactly one service definition with quantity one
1836 my $svcpart = $self->part_pkg->svcpart;
1837 @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
1838 return $cust_svc[0] if scalar(@cust_svc) == 1;
1840 #couldn't identify one thing..
1846 Returns a list of lists, calling the label method for all services
1847 (see L<FS::cust_svc>) of this billing item.
1853 map { [ $_->label ] } $self->cust_svc;
1856 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1858 Like the labels method, but returns historical information on services that
1859 were active as of END_TIMESTAMP and (optionally) not cancelled before
1862 Returns a list of lists, calling the label method for all (historical) services
1863 (see L<FS::h_cust_svc>) of this billing item.
1869 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1874 Like labels, except returns a simple flat list, and shortens long
1875 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1876 identical services to one line that lists the service label and the number of
1877 individual services rather than individual items.
1882 shift->_labels_short( 'labels', @_ );
1885 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1887 Like h_labels, except returns a simple flat list, and shortens long
1888 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1889 identical services to one line that lists the service label and the number of
1890 individual services rather than individual items.
1894 sub h_labels_short {
1895 shift->_labels_short( 'h_labels', @_ );
1899 my( $self, $method ) = ( shift, shift );
1901 my $conf = new FS::Conf;
1902 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1905 #tie %labels, 'Tie::IxHash';
1906 push @{ $labels{$_->[0]} }, $_->[1]
1907 foreach $self->h_labels(@_);
1909 foreach my $label ( keys %labels ) {
1911 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1912 my $num = scalar(@values);
1913 if ( $num > $max_same_services ) {
1914 push @labels, "$label ($num)";
1916 if ( $conf->exists('cust_bill-consolidate_services') ) {
1917 # push @labels, "$label: ". join(', ', @values);
1919 my $detail = "$label: ";
1920 $detail .= shift(@values). ', '
1921 while @values && length($detail.$values[0]) < 78;
1923 push @labels, $detail;
1926 push @labels, map { "$label: $_" } @values;
1937 Returns the parent customer object (see L<FS::cust_main>).
1943 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1948 Returns the location object, if any (see L<FS::cust_location>).
1954 return '' unless $self->locationnum;
1955 qsearchs( 'cust_location', { 'locationnum' => $self->locationnum } );
1958 =item cust_location_or_main
1960 If this package is associated with a location, returns the locaiton (see
1961 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
1965 sub cust_location_or_main {
1967 $self->cust_location || $self->cust_main;
1970 =item location_label [ OPTION => VALUE ... ]
1972 Returns the label of the location object (see L<FS::cust_location>).
1976 sub location_label {
1978 my $object = $self->cust_location_or_main;
1979 $object->location_label(@_);
1982 =item seconds_since TIMESTAMP
1984 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1985 package have been online since TIMESTAMP, according to the session monitor.
1987 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1988 L<Time::Local> and L<Date::Parse> for conversion functions.
1993 my($self, $since) = @_;
1996 foreach my $cust_svc (
1997 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1999 $seconds += $cust_svc->seconds_since($since);
2006 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
2008 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
2009 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
2012 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2013 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
2019 sub seconds_since_sqlradacct {
2020 my($self, $start, $end) = @_;
2024 foreach my $cust_svc (
2026 my $part_svc = $_->part_svc;
2027 $part_svc->svcdb eq 'svc_acct'
2028 && scalar($part_svc->part_export('sqlradius'));
2031 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
2038 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
2040 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
2041 in this package for sessions ending between TIMESTAMP_START (inclusive) and
2045 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2046 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
2051 sub attribute_since_sqlradacct {
2052 my($self, $start, $end, $attrib) = @_;
2056 foreach my $cust_svc (
2058 my $part_svc = $_->part_svc;
2059 $part_svc->svcdb eq 'svc_acct'
2060 && scalar($part_svc->part_export('sqlradius'));
2063 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
2075 my( $self, $value ) = @_;
2076 if ( defined($value) ) {
2077 $self->setfield('quantity', $value);
2079 $self->getfield('quantity') || 1;
2082 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
2084 Transfers as many services as possible from this package to another package.
2086 The destination package can be specified by pkgnum by passing an FS::cust_pkg
2087 object. The destination package must already exist.
2089 Services are moved only if the destination allows services with the correct
2090 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
2091 this option with caution! No provision is made for export differences
2092 between the old and new service definitions. Probably only should be used
2093 when your exports for all service definitions of a given svcdb are identical.
2094 (attempt a transfer without it first, to move all possible svcpart-matching
2097 Any services that can't be moved remain in the original package.
2099 Returns an error, if there is one; otherwise, returns the number of services
2100 that couldn't be moved.
2105 my ($self, $dest_pkgnum, %opt) = @_;
2111 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
2112 $dest = $dest_pkgnum;
2113 $dest_pkgnum = $dest->pkgnum;
2115 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
2118 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
2120 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
2121 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
2124 foreach my $cust_svc ($dest->cust_svc) {
2125 $target{$cust_svc->svcpart}--;
2128 my %svcpart2svcparts = ();
2129 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2130 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
2131 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
2132 next if exists $svcpart2svcparts{$svcpart};
2133 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2134 $svcpart2svcparts{$svcpart} = [
2136 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
2138 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
2139 'svcpart' => $_ } );
2141 $pkg_svc ? $pkg_svc->primary_svc : '',
2142 $pkg_svc ? $pkg_svc->quantity : 0,
2146 grep { $_ != $svcpart }
2148 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
2150 warn "alternates for svcpart $svcpart: ".
2151 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
2156 foreach my $cust_svc ($self->cust_svc) {
2157 if($target{$cust_svc->svcpart} > 0) {
2158 $target{$cust_svc->svcpart}--;
2159 my $new = new FS::cust_svc { $cust_svc->hash };
2160 $new->pkgnum($dest_pkgnum);
2161 my $error = $new->replace($cust_svc);
2162 return $error if $error;
2163 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2165 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
2166 warn "alternates to consider: ".
2167 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
2169 my @alternate = grep {
2170 warn "considering alternate svcpart $_: ".
2171 "$target{$_} available in new package\n"
2174 } @{$svcpart2svcparts{$cust_svc->svcpart}};
2176 warn "alternate(s) found\n" if $DEBUG;
2177 my $change_svcpart = $alternate[0];
2178 $target{$change_svcpart}--;
2179 my $new = new FS::cust_svc { $cust_svc->hash };
2180 $new->svcpart($change_svcpart);
2181 $new->pkgnum($dest_pkgnum);
2182 my $error = $new->replace($cust_svc);
2183 return $error if $error;
2196 This method is deprecated. See the I<depend_jobnum> option to the insert and
2197 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2204 local $SIG{HUP} = 'IGNORE';
2205 local $SIG{INT} = 'IGNORE';
2206 local $SIG{QUIT} = 'IGNORE';
2207 local $SIG{TERM} = 'IGNORE';
2208 local $SIG{TSTP} = 'IGNORE';
2209 local $SIG{PIPE} = 'IGNORE';
2211 my $oldAutoCommit = $FS::UID::AutoCommit;
2212 local $FS::UID::AutoCommit = 0;
2215 foreach my $cust_svc ( $self->cust_svc ) {
2216 #false laziness w/svc_Common::insert
2217 my $svc_x = $cust_svc->svc_x;
2218 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2219 my $error = $part_export->export_insert($svc_x);
2221 $dbh->rollback if $oldAutoCommit;
2227 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2234 Associates this package with a (suspension or cancellation) reason (see
2235 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2238 Available options are:
2244 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.
2248 the access_user (see L<FS::access_user>) providing the reason
2256 the action (cancel, susp, adjourn, expire) associated with the reason
2260 If there is an error, returns the error, otherwise returns false.
2265 my ($self, %options) = @_;
2267 my $otaker = $options{reason_otaker} ||
2268 $FS::CurrentUser::CurrentUser->username;
2271 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2275 } elsif ( ref($options{'reason'}) ) {
2277 return 'Enter a new reason (or select an existing one)'
2278 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2280 my $reason = new FS::reason({
2281 'reason_type' => $options{'reason'}->{'typenum'},
2282 'reason' => $options{'reason'}->{'reason'},
2284 my $error = $reason->insert;
2285 return $error if $error;
2287 $reasonnum = $reason->reasonnum;
2290 return "Unparsable reason: ". $options{'reason'};
2293 my $cust_pkg_reason =
2294 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2295 'reasonnum' => $reasonnum,
2296 'otaker' => $otaker,
2297 'action' => substr(uc($options{'action'}),0,1),
2298 'date' => $options{'date'}
2303 $cust_pkg_reason->insert;
2306 =item set_usage USAGE_VALUE_HASHREF
2308 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2309 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2310 upbytes, downbytes, and totalbytes are appropriate keys.
2312 All svc_accts which are part of this package have their values reset.
2317 my ($self, $valueref, %opt) = @_;
2319 foreach my $cust_svc ($self->cust_svc){
2320 my $svc_x = $cust_svc->svc_x;
2321 $svc_x->set_usage($valueref, %opt)
2322 if $svc_x->can("set_usage");
2326 =item recharge USAGE_VALUE_HASHREF
2328 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2329 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2330 upbytes, downbytes, and totalbytes are appropriate keys.
2332 All svc_accts which are part of this package have their values incremented.
2337 my ($self, $valueref) = @_;
2339 foreach my $cust_svc ($self->cust_svc){
2340 my $svc_x = $cust_svc->svc_x;
2341 $svc_x->recharge($valueref)
2342 if $svc_x->can("recharge");
2346 =item cust_pkg_discount
2350 sub cust_pkg_discount {
2352 qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
2355 =item cust_pkg_discount_active
2359 sub cust_pkg_discount_active {
2361 grep { my $d = $_->discount;
2362 ! $d->months || $_->months_used < $d->months; # XXX also end date
2364 $self->cust_pkg_discount;
2369 =head1 CLASS METHODS
2375 Returns an SQL expression identifying recurring packages.
2379 sub recurring_sql { "
2380 '0' != ( select freq from part_pkg
2381 where cust_pkg.pkgpart = part_pkg.pkgpart )
2386 Returns an SQL expression identifying one-time packages.
2391 '0' = ( select freq from part_pkg
2392 where cust_pkg.pkgpart = part_pkg.pkgpart )
2397 Returns an SQL expression identifying active packages.
2402 ". $_[0]->recurring_sql(). "
2403 AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2404 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2405 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2408 =item not_yet_billed_sql
2410 Returns an SQL expression identifying packages which have not yet been billed.
2414 sub not_yet_billed_sql { "
2415 ( cust_pkg.setup IS NULL OR cust_pkg.setup = 0 )
2416 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2417 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2422 Returns an SQL expression identifying inactive packages (one-time packages
2423 that are otherwise unsuspended/uncancelled).
2427 sub inactive_sql { "
2428 ". $_[0]->onetime_sql(). "
2429 AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2430 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2431 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2437 Returns an SQL expression identifying suspended packages.
2441 sub suspended_sql { susp_sql(@_); }
2443 #$_[0]->recurring_sql(). ' AND '.
2445 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2446 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
2453 Returns an SQL exprression identifying cancelled packages.
2457 sub cancelled_sql { cancel_sql(@_); }
2459 #$_[0]->recurring_sql(). ' AND '.
2460 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
2463 =item search HASHREF
2467 Returns a qsearch hash expression to search for parameters specified in HASHREF.
2468 Valid parameters are
2476 active, inactive, suspended, cancel (or cancelled)
2480 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
2484 boolean selects custom packages
2490 pkgpart or arrayref or hashref of pkgparts
2494 arrayref of beginning and ending epoch date
2498 arrayref of beginning and ending epoch date
2502 arrayref of beginning and ending epoch date
2506 arrayref of beginning and ending epoch date
2510 arrayref of beginning and ending epoch date
2514 arrayref of beginning and ending epoch date
2518 arrayref of beginning and ending epoch date
2522 pkgnum or APKG_pkgnum
2526 a value suited to passing to FS::UI::Web::cust_header
2530 specifies the user for agent virtualization
2537 my ($class, $params) = @_;
2544 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2546 "cust_main.agentnum = $1";
2553 if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
2555 "cust_pkg.custnum = $1";
2562 if ( $params->{'magic'} eq 'active'
2563 || $params->{'status'} eq 'active' ) {
2565 push @where, FS::cust_pkg->active_sql();
2567 } elsif ( $params->{'magic'} =~ /^not[ _]yet[ _]billed$/
2568 || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
2570 push @where, FS::cust_pkg->not_yet_billed_sql();
2572 } elsif ( $params->{'magic'} =~ /^(one-time charge|inactive)/
2573 || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
2575 push @where, FS::cust_pkg->inactive_sql();
2577 } elsif ( $params->{'magic'} eq 'suspended'
2578 || $params->{'status'} eq 'suspended' ) {
2580 push @where, FS::cust_pkg->suspended_sql();
2582 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
2583 || $params->{'status'} =~ /^cancell?ed$/ ) {
2585 push @where, FS::cust_pkg->cancelled_sql();
2590 # parse package class
2593 #false lazinessish w/graph/cust_bill_pkg.cgi
2596 if ( exists($params->{'classnum'})
2597 && $params->{'classnum'} =~ /^(\d*)$/
2601 if ( $classnum ) { #a specific class
2602 push @where, "part_pkg.classnum = $classnum";
2604 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
2605 #die "classnum $classnum not found!" unless $pkg_class[0];
2606 #$title .= $pkg_class[0]->classname.' ';
2608 } elsif ( $classnum eq '' ) { #the empty class
2610 push @where, "part_pkg.classnum IS NULL";
2611 #$title .= 'Empty class ';
2612 #@pkg_class = ( '(empty class)' );
2613 } elsif ( $classnum eq '0' ) {
2614 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
2615 #push @pkg_class, '(empty class)';
2617 die "illegal classnum";
2623 # parse package report options
2626 my @report_option = ();
2627 if ( exists($params->{'report_option'})
2628 && $params->{'report_option'} =~ /^([,\d]*)$/
2631 @report_option = split(',', $1);
2634 if (@report_option) {
2635 # this will result in the empty set for the dangling comma case as it should
2637 map{ "0 < ( SELECT count(*) FROM part_pkg_option
2638 WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
2639 AND optionname = 'report_option_$_'
2640 AND optionvalue = '1' )"
2650 push @where, "part_pkg.custom = 'Y'" if $params->{custom};
2656 if ( exists($params->{'censustract'}) ) {
2657 $params->{'censustract'} =~ /^([.\d]*)$/;
2658 my $censustract = "cust_main.censustract = '$1'";
2659 $censustract .= ' OR cust_main.censustract is NULL' unless $1;
2660 push @where, "( $censustract )";
2667 if ( ref($params->{'pkgpart'}) ) {
2670 if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
2671 @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
2672 } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
2673 @pkgpart = @{ $params->{'pkgpart'} };
2675 die 'unhandled pkgpart ref '. $params->{'pkgpart'};
2678 @pkgpart = grep /^(\d+)$/, @pkgpart;
2680 push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
2682 } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2683 push @where, "pkgpart = $1";
2692 #false laziness w/report_cust_pkg.html
2695 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
2696 'active' => { 'susp'=>1, 'cancel'=>1 },
2697 'suspended' => { 'cancel' => 1 },
2702 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
2704 next unless exists($params->{$field});
2706 my($beginning, $ending) = @{$params->{$field}};
2708 next if $beginning == 0 && $ending == 4294967295;
2711 "cust_pkg.$field IS NOT NULL",
2712 "cust_pkg.$field >= $beginning",
2713 "cust_pkg.$field <= $ending";
2715 $orderby ||= "ORDER BY cust_pkg.$field";
2719 $orderby ||= 'ORDER BY bill';
2722 # parse magic, legacy, etc.
2725 if ( $params->{'magic'} &&
2726 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
2729 $orderby = 'ORDER BY pkgnum';
2731 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2732 push @where, "pkgpart = $1";
2735 } elsif ( $params->{'query'} eq 'pkgnum' ) {
2737 $orderby = 'ORDER BY pkgnum';
2739 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2741 $orderby = 'ORDER BY pkgnum';
2744 SELECT count(*) FROM pkg_svc
2745 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
2746 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2747 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
2748 AND cust_svc.svcpart = pkg_svc.svcpart
2755 # setup queries, links, subs, etc. for the search
2758 # here is the agent virtualization
2759 if ($params->{CurrentUser}) {
2761 qsearchs('access_user', { username => $params->{CurrentUser} });
2764 push @where, $access_user->agentnums_sql('table'=>'cust_main');
2769 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
2772 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2774 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
2775 'LEFT JOIN part_pkg USING ( pkgpart ) '.
2776 'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
2778 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2781 'table' => 'cust_pkg',
2783 'select' => join(', ',
2785 ( map "part_pkg.$_", qw( pkg freq ) ),
2786 'pkg_class.classname',
2787 'cust_main.custnum AS cust_main_custnum',
2788 FS::UI::Web::cust_sql_fields(
2789 $params->{'cust_fields'}
2792 'extra_sql' => "$extra_sql $orderby",
2793 'addl_from' => $addl_from,
2794 'count_query' => $count_query,
2801 Returns a list: the first item is an SQL fragment identifying matching
2802 packages/customers via location (taking into account shipping and package
2803 address taxation, if enabled), and subsequent items are the parameters to
2804 substitute for the placeholders in that fragment.
2809 my($class, %opt) = @_;
2810 my $ornull = $opt{'ornull'};
2812 my $conf = new FS::Conf;
2814 # '?' placeholders in _location_sql_where
2815 my $x = $ornull ? 3 : 2;
2816 my @bill_param = ( ('city')x3, ('county')x$x, ('state')x$x, 'country' );
2820 if ( $conf->exists('tax-ship_address') ) {
2823 ( ( ship_last IS NULL OR ship_last = '' )
2824 AND ". _location_sql_where('cust_main', '', $ornull ). "
2826 OR ( ship_last IS NOT NULL AND ship_last != ''
2827 AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
2830 # AND payby != 'COMP'
2832 @main_param = ( @bill_param, @bill_param );
2836 $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
2837 @main_param = @bill_param;
2843 if ( $conf->exists('tax-pkg_address') ) {
2845 my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
2848 ( cust_pkg.locationnum IS NULL AND $main_where )
2849 OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where )
2852 @param = ( @main_param, @bill_param );
2856 $where = $main_where;
2857 @param = @main_param;
2865 #subroutine, helper for location_sql
2866 sub _location_sql_where {
2868 my $prefix = @_ ? shift : '';
2869 my $ornull = @_ ? shift : '';
2871 # $ornull = $ornull ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
2873 $ornull = $ornull ? ' OR ? IS NULL ' : '';
2875 my $or_empty_city = " OR ( ? = '' AND $table.${prefix}city IS NULL ) ";
2876 my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
2877 my $or_empty_state = " OR ( ? = '' AND $table.${prefix}state IS NULL ) ";
2879 # ( $table.${prefix}city = ? $or_empty_city $ornull )
2881 ( $table.${prefix}city = ? OR ? = '' OR CAST(? AS text) IS NULL )
2882 AND ( $table.${prefix}county = ? $or_empty_county $ornull )
2883 AND ( $table.${prefix}state = ? $or_empty_state $ornull )
2884 AND $table.${prefix}country = ?
2892 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
2894 CUSTNUM is a customer (see L<FS::cust_main>)
2896 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2897 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2900 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2901 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
2902 new billing items. An error is returned if this is not possible (see
2903 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
2906 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2907 newly-created cust_pkg objects.
2909 REFNUM, if specified, will specify the FS::pkg_referral record to be created
2910 and inserted. Multiple FS::pkg_referral records can be created by
2911 setting I<refnum> to an array reference of refnums or a hash reference with
2912 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
2913 record will be created corresponding to cust_main.refnum.
2918 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
2920 my $conf = new FS::Conf;
2922 # Transactionize this whole mess
2923 local $SIG{HUP} = 'IGNORE';
2924 local $SIG{INT} = 'IGNORE';
2925 local $SIG{QUIT} = 'IGNORE';
2926 local $SIG{TERM} = 'IGNORE';
2927 local $SIG{TSTP} = 'IGNORE';
2928 local $SIG{PIPE} = 'IGNORE';
2930 my $oldAutoCommit = $FS::UID::AutoCommit;
2931 local $FS::UID::AutoCommit = 0;
2935 # my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2936 # return "Customer not found: $custnum" unless $cust_main;
2938 warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
2941 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2944 my $change = scalar(@old_cust_pkg) != 0;
2947 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2949 warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
2950 " to pkgpart ". $pkgparts->[0]. "\n"
2953 my $err_or_cust_pkg =
2954 $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
2955 'refnum' => $refnum,
2958 unless (ref($err_or_cust_pkg)) {
2959 $dbh->rollback if $oldAutoCommit;
2960 return $err_or_cust_pkg;
2963 push @$return_cust_pkg, $err_or_cust_pkg;
2964 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2969 # Create the new packages.
2970 foreach my $pkgpart (@$pkgparts) {
2972 warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
2974 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2975 pkgpart => $pkgpart,
2979 $error = $cust_pkg->insert( 'change' => $change );
2981 $dbh->rollback if $oldAutoCommit;
2984 push @$return_cust_pkg, $cust_pkg;
2986 # $return_cust_pkg now contains refs to all of the newly
2989 # Transfer services and cancel old packages.
2990 foreach my $old_pkg (@old_cust_pkg) {
2992 warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
2995 foreach my $new_pkg (@$return_cust_pkg) {
2996 $error = $old_pkg->transfer($new_pkg);
2997 if ($error and $error == 0) {
2998 # $old_pkg->transfer failed.
2999 $dbh->rollback if $oldAutoCommit;
3004 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
3005 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
3006 foreach my $new_pkg (@$return_cust_pkg) {
3007 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
3008 if ($error and $error == 0) {
3009 # $old_pkg->transfer failed.
3010 $dbh->rollback if $oldAutoCommit;
3017 # Transfers were successful, but we went through all of the
3018 # new packages and still had services left on the old package.
3019 # We can't cancel the package under the circumstances, so abort.
3020 $dbh->rollback if $oldAutoCommit;
3021 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
3023 $error = $old_pkg->cancel( quiet=>1 );
3029 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3033 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
3035 A bulk change method to change packages for multiple customers.
3037 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3038 L<FS::part_pkg>) to order for each customer. Duplicates are of course
3041 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
3042 replace. The services (see L<FS::cust_svc>) are moved to the
3043 new billing items. An error is returned if this is not possible (see
3046 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3047 newly-created cust_pkg objects.
3052 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
3054 # Transactionize this whole mess
3055 local $SIG{HUP} = 'IGNORE';
3056 local $SIG{INT} = 'IGNORE';
3057 local $SIG{QUIT} = 'IGNORE';
3058 local $SIG{TERM} = 'IGNORE';
3059 local $SIG{TSTP} = 'IGNORE';
3060 local $SIG{PIPE} = 'IGNORE';
3062 my $oldAutoCommit = $FS::UID::AutoCommit;
3063 local $FS::UID::AutoCommit = 0;
3067 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3070 while(scalar(@old_cust_pkg)) {
3072 my $custnum = $old_cust_pkg[0]->custnum;
3073 my (@remove) = map { $_->pkgnum }
3074 grep { $_->custnum == $custnum } @old_cust_pkg;
3075 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
3077 my $error = order $custnum, $pkgparts, \@remove, \@return;
3079 push @errors, $error
3081 push @$return_cust_pkg, @return;
3084 if (scalar(@errors)) {
3085 $dbh->rollback if $oldAutoCommit;
3086 return join(' / ', @errors);
3089 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3097 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
3099 In sub order, the @pkgparts array (passed by reference) is clobbered.
3101 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
3102 method to pass dates to the recur_prog expression, it should do so.
3104 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
3105 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
3106 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
3107 configuration values. Probably need a subroutine which decides what to do
3108 based on whether or not we've fetched the user yet, rather than a hash. See
3109 FS::UID and the TODO.
3111 Now that things are transactional should the check in the insert method be
3116 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
3117 L<FS::pkg_svc>, schema.html from the base documentation