4 use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::location_Mixin
5 FS::m2m_Common FS::option_Common );
6 use vars qw($disable_agentcheck $DEBUG $me);
8 use Scalar::Util qw( blessed );
9 use List::Util qw(max);
11 use Time::Local qw( timelocal_nocheck );
13 use FS::UID qw( getotaker dbh );
14 use FS::Misc qw( send_email );
15 use FS::Record qw( qsearch qsearchs );
20 use FS::cust_location;
22 use FS::cust_bill_pkg;
23 use FS::cust_pkg_detail;
28 use FS::cust_pkg_reason;
30 use FS::cust_pkg_discount;
34 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
36 # because they load configuration by setting FS::UID::callback (see TODO)
42 # for sending cancel emails in sub cancel
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 (see L<FS::access_user>)
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 if ( $self->part_pkg->option('start_1st', 1) && !$self->start_date ) {
256 my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5];
257 $mon += 1 unless $mday == 1;
258 until ( $mon < 12 ) { $mon -= 12; $year++; }
259 $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) );
262 foreach my $action ( qw(expire adjourn) ) {
263 my $months = $self->part_pkg->option("${action}_months",1);
264 if($months and !$self->$action) {
265 my $start = $self->start_date || $self->setup || time;
266 $self->$action( $self->part_pkg->add_freq($start, $months) );
270 local $SIG{HUP} = 'IGNORE';
271 local $SIG{INT} = 'IGNORE';
272 local $SIG{QUIT} = 'IGNORE';
273 local $SIG{TERM} = 'IGNORE';
274 local $SIG{TSTP} = 'IGNORE';
275 local $SIG{PIPE} = 'IGNORE';
277 my $oldAutoCommit = $FS::UID::AutoCommit;
278 local $FS::UID::AutoCommit = 0;
281 my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
283 $dbh->rollback if $oldAutoCommit;
287 $self->refnum($self->cust_main->refnum) unless $self->refnum;
288 $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
289 $self->process_m2m( 'link_table' => 'pkg_referral',
290 'target_table' => 'part_referral',
291 'params' => $self->refnum,
294 if ( $self->discountnum ) {
295 my $error = $self->insert_discount();
297 $dbh->rollback if $oldAutoCommit;
302 #if ( $self->reg_code ) {
303 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
304 # $error = $reg_code->delete;
306 # $dbh->rollback if $oldAutoCommit;
311 my $conf = new FS::Conf;
313 if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
316 # use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
323 use FS::TicketSystem;
324 FS::TicketSystem->init();
326 my $q = new RT::Queue($RT::SystemUser);
327 $q->Load($options{ticket_queue}) if $options{ticket_queue};
328 my $t = new RT::Ticket($RT::SystemUser);
329 my $mime = new MIME::Entity;
330 $mime->build( Type => 'text/plain', Data => $options{ticket_subject} );
331 $t->Create( $options{ticket_queue} ? (Queue => $q) : (),
332 Subject => $options{ticket_subject},
335 $t->AddLink( Type => 'MemberOf',
336 Target => 'freeside://freeside/cust_main/'. $self->custnum,
340 if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
341 my $queue = new FS::queue {
342 'job' => 'FS::cust_main::queueable_print',
344 $error = $queue->insert(
345 'custnum' => $self->custnum,
346 'template' => 'welcome_letter',
350 warn "can't send welcome letter: $error";
355 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
362 This method now works but you probably shouldn't use it.
364 You don't want to delete billing items, because there would then be no record
365 the customer ever purchased the item. Instead, see the cancel method.
370 # return "Can't delete cust_pkg records!";
373 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
375 Replaces the OLD_RECORD with this one in the database. If there is an error,
376 returns the error, otherwise returns false.
378 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
380 Changing pkgpart may have disasterous effects. See the order subroutine.
382 setup and bill are normally updated by calling the bill method of a customer
383 object (see L<FS::cust_main>).
385 suspend is normally updated by the suspend and unsuspend methods.
387 cancel is normally updated by the cancel method (and also the order subroutine
390 Available options are:
396 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.
400 the access_user (see L<FS::access_user>) providing the reason
404 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
413 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
418 ( ref($_[0]) eq 'HASH' )
422 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
423 #return "Can't change otaker!" if $old->otaker ne $new->otaker;
426 #return "Can't change setup once it exists!"
427 # if $old->getfield('setup') &&
428 # $old->getfield('setup') != $new->getfield('setup');
430 #some logic for bill, susp, cancel?
432 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
434 local $SIG{HUP} = 'IGNORE';
435 local $SIG{INT} = 'IGNORE';
436 local $SIG{QUIT} = 'IGNORE';
437 local $SIG{TERM} = 'IGNORE';
438 local $SIG{TSTP} = 'IGNORE';
439 local $SIG{PIPE} = 'IGNORE';
441 my $oldAutoCommit = $FS::UID::AutoCommit;
442 local $FS::UID::AutoCommit = 0;
445 foreach my $method ( qw(adjourn expire) ) { # How many reasons?
446 if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
447 my $error = $new->insert_reason(
448 'reason' => $options->{'reason'},
449 'date' => $new->$method,
451 'reason_otaker' => $options->{'reason_otaker'},
454 dbh->rollback if $oldAutoCommit;
455 return "Error inserting cust_pkg_reason: $error";
460 #save off and freeze RADIUS attributes for any associated svc_acct records
462 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
464 #also check for specific exports?
465 # to avoid spurious modify export events
466 @svc_acct = map { $_->svc_x }
467 grep { $_->part_svc->svcdb eq 'svc_acct' }
470 $_->snapshot foreach @svc_acct;
474 my $error = $new->SUPER::replace($old,
475 $options->{options} ? $options->{options} : ()
478 $dbh->rollback if $oldAutoCommit;
482 #for prepaid packages,
483 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
484 foreach my $old_svc_acct ( @svc_acct ) {
485 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
486 my $s_error = $new_svc_acct->replace($old_svc_acct);
488 $dbh->rollback if $oldAutoCommit;
493 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
500 Checks all fields to make sure this is a valid billing item. If there is an
501 error, returns the error, otherwise returns false. Called by the insert and
509 $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
512 $self->ut_numbern('pkgnum')
513 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
514 || $self->ut_numbern('pkgpart')
515 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
516 || $self->ut_numbern('start_date')
517 || $self->ut_numbern('setup')
518 || $self->ut_numbern('bill')
519 || $self->ut_numbern('susp')
520 || $self->ut_numbern('cancel')
521 || $self->ut_numbern('adjourn')
522 || $self->ut_numbern('expire')
523 || $self->ut_enum('no_auto', [ '', 'Y' ])
525 return $error if $error;
527 if ( $self->reg_code ) {
529 unless ( grep { $self->pkgpart == $_->pkgpart }
530 map { $_->reg_code_pkg }
531 qsearchs( 'reg_code', { 'code' => $self->reg_code,
532 'agentnum' => $self->cust_main->agentnum })
534 return "Unknown registration code";
537 } elsif ( $self->promo_code ) {
540 qsearchs('part_pkg', {
541 'pkgpart' => $self->pkgpart,
542 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
544 return 'Unknown promotional code' unless $promo_part_pkg;
548 unless ( $disable_agentcheck ) {
550 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
551 return "agent ". $agent->agentnum. ':'. $agent->agent.
552 " can't purchase pkgpart ". $self->pkgpart
553 unless $agent->pkgpart_hashref->{ $self->pkgpart }
554 || $agent->agentnum == $self->part_pkg->agentnum;
557 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
558 return $error if $error;
562 $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
564 if ( $self->dbdef_table->column('manual_flag') ) {
565 $self->manual_flag('') if $self->manual_flag eq ' ';
566 $self->manual_flag =~ /^([01]?)$/
567 or return "Illegal manual_flag ". $self->manual_flag;
568 $self->manual_flag($1);
574 =item cancel [ OPTION => VALUE ... ]
576 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
577 in this package, then cancels the package itself (sets the cancel field to
580 Available options are:
584 =item quiet - can be set true to supress email cancellation notices.
586 =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.
588 =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.
590 =item date - can be set to a unix style timestamp to specify when to cancel (expire)
592 =item nobill - can be set true to skip billing if it might otherwise be done.
596 If there is an error, returns the error, otherwise returns false.
601 my( $self, %options ) = @_;
604 my $conf = new FS::Conf;
606 warn "cust_pkg::cancel called with options".
607 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
610 local $SIG{HUP} = 'IGNORE';
611 local $SIG{INT} = 'IGNORE';
612 local $SIG{QUIT} = 'IGNORE';
613 local $SIG{TERM} = 'IGNORE';
614 local $SIG{TSTP} = 'IGNORE';
615 local $SIG{PIPE} = 'IGNORE';
617 my $oldAutoCommit = $FS::UID::AutoCommit;
618 local $FS::UID::AutoCommit = 0;
621 my $old = $self->select_for_update;
623 if ( $old->get('cancel') || $self->get('cancel') ) {
624 dbh->rollback if $oldAutoCommit;
625 return ""; # no error
628 my $date = $options{date} if $options{date}; # expire/cancel later
629 $date = '' if ($date && $date <= time); # complain instead?
631 #race condition: usage could be ongoing until unprovisioned
632 #resolved by performing a change package instead (which unprovisions) and
634 if ( !$options{nobill} && !$date && $conf->exists('bill_usage_on_cancel') ) {
635 my $copy = $self->new({$self->hash});
637 $copy->cust_main->bill( pkg_list => [ $copy ], cancel => 1 );
638 warn "Error billing during cancel, custnum ".
639 #$self->cust_main->custnum. ": $error"
645 my $cancel_time = $options{'time'} || time;
647 if ( $options{'reason'} ) {
648 $error = $self->insert_reason( 'reason' => $options{'reason'},
649 'action' => $date ? 'expire' : 'cancel',
650 'date' => $date ? $date : $cancel_time,
651 'reason_otaker' => $options{'reason_otaker'},
654 dbh->rollback if $oldAutoCommit;
655 return "Error inserting cust_pkg_reason: $error";
661 foreach my $cust_svc (
664 sort { $a->[1] <=> $b->[1] }
665 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
666 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
669 my $error = $cust_svc->cancel;
672 $dbh->rollback if $oldAutoCommit;
673 return "Error cancelling cust_svc: $error";
677 # Add a credit for remaining service
678 my $remaining_value = $self->calc_remain(time=>$cancel_time);
679 if ( $remaining_value > 0 && !$options{'no_credit'} ) {
680 my $error = $self->cust_main->credit(
682 'Credit for unused time on '. $self->part_pkg->pkg,
683 'reason_type' => $conf->config('cancel_credit_type'),
686 $dbh->rollback if $oldAutoCommit;
687 return "Error crediting customer \$$remaining_value for unused time on".
688 $self->part_pkg->pkg. ": $error";
693 my %hash = $self->hash;
694 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
695 my $new = new FS::cust_pkg ( \%hash );
696 $error = $new->replace( $self, options => { $self->options } );
698 $dbh->rollback if $oldAutoCommit;
702 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
703 return '' if $date; #no errors
705 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
706 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
707 my $msgnum = $conf->config('cancel_msgnum', $self->cust_main->agentnum);
710 my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
711 $error = $msg_template->send( 'cust_main' => $self->cust_main,
716 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
717 'to' => \@invoicing_list,
718 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
719 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
722 #should this do something on errors?
729 =item cancel_if_expired [ NOW_TIMESTAMP ]
731 Cancels this package if its expire date has been reached.
735 sub cancel_if_expired {
737 my $time = shift || time;
738 return '' unless $self->expire && $self->expire <= $time;
739 my $error = $self->cancel;
741 return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
742 $self->custnum. ": $error";
749 Cancels any pending expiration (sets the expire field to null).
751 If there is an error, returns the error, otherwise returns false.
756 my( $self, %options ) = @_;
759 local $SIG{HUP} = 'IGNORE';
760 local $SIG{INT} = 'IGNORE';
761 local $SIG{QUIT} = 'IGNORE';
762 local $SIG{TERM} = 'IGNORE';
763 local $SIG{TSTP} = 'IGNORE';
764 local $SIG{PIPE} = 'IGNORE';
766 my $oldAutoCommit = $FS::UID::AutoCommit;
767 local $FS::UID::AutoCommit = 0;
770 my $old = $self->select_for_update;
772 my $pkgnum = $old->pkgnum;
773 if ( $old->get('cancel') || $self->get('cancel') ) {
774 dbh->rollback if $oldAutoCommit;
775 return "Can't unexpire cancelled package $pkgnum";
776 # or at least it's pointless
779 unless ( $old->get('expire') && $self->get('expire') ) {
780 dbh->rollback if $oldAutoCommit;
781 return ""; # no error
784 my %hash = $self->hash;
785 $hash{'expire'} = '';
786 my $new = new FS::cust_pkg ( \%hash );
787 $error = $new->replace( $self, options => { $self->options } );
789 $dbh->rollback if $oldAutoCommit;
793 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
799 =item suspend [ OPTION => VALUE ... ]
801 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
802 package, then suspends the package itself (sets the susp field to now).
804 Available options are:
808 =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.
810 =item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
814 If there is an error, returns the error, otherwise returns false.
819 my( $self, %options ) = @_;
822 local $SIG{HUP} = 'IGNORE';
823 local $SIG{INT} = 'IGNORE';
824 local $SIG{QUIT} = 'IGNORE';
825 local $SIG{TERM} = 'IGNORE';
826 local $SIG{TSTP} = 'IGNORE';
827 local $SIG{PIPE} = 'IGNORE';
829 my $oldAutoCommit = $FS::UID::AutoCommit;
830 local $FS::UID::AutoCommit = 0;
833 my $old = $self->select_for_update;
835 my $pkgnum = $old->pkgnum;
836 if ( $old->get('cancel') || $self->get('cancel') ) {
837 dbh->rollback if $oldAutoCommit;
838 return "Can't suspend cancelled package $pkgnum";
841 if ( $old->get('susp') || $self->get('susp') ) {
842 dbh->rollback if $oldAutoCommit;
843 return ""; # no error # complain on adjourn?
846 my $date = $options{date} if $options{date}; # adjourn/suspend later
847 $date = '' if ($date && $date <= time); # complain instead?
849 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
850 dbh->rollback if $oldAutoCommit;
851 return "Package $pkgnum expires before it would be suspended.";
854 my $suspend_time = $options{'time'} || time;
856 if ( $options{'reason'} ) {
857 $error = $self->insert_reason( 'reason' => $options{'reason'},
858 'action' => $date ? 'adjourn' : 'suspend',
859 'date' => $date ? $date : $suspend_time,
860 'reason_otaker' => $options{'reason_otaker'},
863 dbh->rollback if $oldAutoCommit;
864 return "Error inserting cust_pkg_reason: $error";
872 foreach my $cust_svc (
873 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
875 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
877 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
878 $dbh->rollback if $oldAutoCommit;
879 return "Illegal svcdb value in part_svc!";
882 require "FS/$svcdb.pm";
884 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
886 $error = $svc->suspend;
888 $dbh->rollback if $oldAutoCommit;
891 my( $label, $value ) = $cust_svc->label;
892 push @labels, "$label: $value";
896 my $conf = new FS::Conf;
897 if ( $conf->config('suspend_email_admin') ) {
899 my $error = send_email(
900 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
901 #invoice_from ??? well as good as any
902 'to' => $conf->config('suspend_email_admin'),
903 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
905 "This is an automatic message from your Freeside installation\n",
906 "informing you that the following customer package has been suspended:\n",
908 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
909 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
910 ( map { "Service : $_\n" } @labels ),
915 warn "WARNING: can't send suspension admin email (suspending anyway): ".
923 my %hash = $self->hash;
925 $hash{'adjourn'} = $date;
927 $hash{'susp'} = $suspend_time;
929 my $new = new FS::cust_pkg ( \%hash );
930 $error = $new->replace( $self, options => { $self->options } );
932 $dbh->rollback if $oldAutoCommit;
936 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
941 =item unsuspend [ OPTION => VALUE ... ]
943 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
944 package, then unsuspends the package itself (clears the susp field and the
945 adjourn field if it is in the past).
947 Available options are:
951 =item adjust_next_bill
953 Can be set true to adjust the next bill date forward by
954 the amount of time the account was inactive. This was set true by default
955 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
956 explicitly requested. Price plans for which this makes sense (anniversary-date
957 based than prorate or subscription) could have an option to enable this
962 If there is an error, returns the error, otherwise returns false.
967 my( $self, %opt ) = @_;
970 local $SIG{HUP} = 'IGNORE';
971 local $SIG{INT} = 'IGNORE';
972 local $SIG{QUIT} = 'IGNORE';
973 local $SIG{TERM} = 'IGNORE';
974 local $SIG{TSTP} = 'IGNORE';
975 local $SIG{PIPE} = 'IGNORE';
977 my $oldAutoCommit = $FS::UID::AutoCommit;
978 local $FS::UID::AutoCommit = 0;
981 my $old = $self->select_for_update;
983 my $pkgnum = $old->pkgnum;
984 if ( $old->get('cancel') || $self->get('cancel') ) {
985 dbh->rollback if $oldAutoCommit;
986 return "Can't unsuspend cancelled package $pkgnum";
989 unless ( $old->get('susp') && $self->get('susp') ) {
990 dbh->rollback if $oldAutoCommit;
991 return ""; # no error # complain instead?
994 foreach my $cust_svc (
995 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
997 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
999 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1000 $dbh->rollback if $oldAutoCommit;
1001 return "Illegal svcdb value in part_svc!";
1004 require "FS/$svcdb.pm";
1006 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1008 $error = $svc->unsuspend;
1010 $dbh->rollback if $oldAutoCommit;
1017 my %hash = $self->hash;
1018 my $inactive = time - $hash{'susp'};
1020 my $conf = new FS::Conf;
1022 if ( $inactive > 0 &&
1023 ( $hash{'bill'} || $hash{'setup'} ) &&
1024 ( $opt{'adjust_next_bill'} ||
1025 $conf->exists('unsuspend-always_adjust_next_bill_date') ||
1026 $self->part_pkg->option('unsuspend_adjust_bill', 1) )
1029 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive;
1034 $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
1035 my $new = new FS::cust_pkg ( \%hash );
1036 $error = $new->replace( $self, options => { $self->options } );
1038 $dbh->rollback if $oldAutoCommit;
1042 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1049 Cancels any pending suspension (sets the adjourn field to null).
1051 If there is an error, returns the error, otherwise returns false.
1056 my( $self, %options ) = @_;
1059 local $SIG{HUP} = 'IGNORE';
1060 local $SIG{INT} = 'IGNORE';
1061 local $SIG{QUIT} = 'IGNORE';
1062 local $SIG{TERM} = 'IGNORE';
1063 local $SIG{TSTP} = 'IGNORE';
1064 local $SIG{PIPE} = 'IGNORE';
1066 my $oldAutoCommit = $FS::UID::AutoCommit;
1067 local $FS::UID::AutoCommit = 0;
1070 my $old = $self->select_for_update;
1072 my $pkgnum = $old->pkgnum;
1073 if ( $old->get('cancel') || $self->get('cancel') ) {
1074 dbh->rollback if $oldAutoCommit;
1075 return "Can't unadjourn cancelled package $pkgnum";
1076 # or at least it's pointless
1079 if ( $old->get('susp') || $self->get('susp') ) {
1080 dbh->rollback if $oldAutoCommit;
1081 return "Can't unadjourn suspended package $pkgnum";
1082 # perhaps this is arbitrary
1085 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1086 dbh->rollback if $oldAutoCommit;
1087 return ""; # no error
1090 my %hash = $self->hash;
1091 $hash{'adjourn'} = '';
1092 my $new = new FS::cust_pkg ( \%hash );
1093 $error = $new->replace( $self, options => { $self->options } );
1095 $dbh->rollback if $oldAutoCommit;
1099 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1106 =item change HASHREF | OPTION => VALUE ...
1108 Changes this package: cancels it and creates a new one, with a different
1109 pkgpart or locationnum or both. All services are transferred to the new
1110 package (no change will be made if this is not possible).
1112 Options may be passed as a list of key/value pairs or as a hash reference.
1119 New locationnum, to change the location for this package.
1123 New FS::cust_location object, to create a new location and assign it
1128 New pkgpart (see L<FS::part_pkg>).
1132 New refnum (see L<FS::part_referral>).
1136 At least one option must be specified (otherwise, what's the point?)
1138 Returns either the new FS::cust_pkg object or a scalar error.
1142 my $err_or_new_cust_pkg = $old_cust_pkg->change
1146 #some false laziness w/order
1149 my $opt = ref($_[0]) ? shift : { @_ };
1151 # my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1154 my $conf = new FS::Conf;
1156 # Transactionize this whole mess
1157 local $SIG{HUP} = 'IGNORE';
1158 local $SIG{INT} = 'IGNORE';
1159 local $SIG{QUIT} = 'IGNORE';
1160 local $SIG{TERM} = 'IGNORE';
1161 local $SIG{TSTP} = 'IGNORE';
1162 local $SIG{PIPE} = 'IGNORE';
1164 my $oldAutoCommit = $FS::UID::AutoCommit;
1165 local $FS::UID::AutoCommit = 0;
1174 #$hash{$_} = $self->$_() foreach qw( last_bill bill );
1176 #$hash{$_} = $self->$_() foreach qw( setup );
1178 $hash{'setup'} = $time if $self->setup;
1180 $hash{'change_date'} = $time;
1181 $hash{"change_$_"} = $self->$_()
1182 foreach qw( pkgnum pkgpart locationnum );
1184 if ( $opt->{'cust_location'} &&
1185 ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) {
1186 $error = $opt->{'cust_location'}->insert;
1188 $dbh->rollback if $oldAutoCommit;
1189 return "inserting cust_location (transaction rolled back): $error";
1191 $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1194 # Create the new package.
1195 my $cust_pkg = new FS::cust_pkg {
1196 custnum => $self->custnum,
1197 pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ),
1198 refnum => ( $opt->{'refnum'} || $self->refnum ),
1199 locationnum => ( $opt->{'locationnum'} || $self->locationnum ),
1203 $error = $cust_pkg->insert( 'change' => 1 );
1205 $dbh->rollback if $oldAutoCommit;
1209 # Transfer services and cancel old package.
1211 $error = $self->transfer($cust_pkg);
1212 if ($error and $error == 0) {
1213 # $old_pkg->transfer failed.
1214 $dbh->rollback if $oldAutoCommit;
1218 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1219 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1220 $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1221 if ($error and $error == 0) {
1222 # $old_pkg->transfer failed.
1223 $dbh->rollback if $oldAutoCommit;
1229 # Transfers were successful, but we still had services left on the old
1230 # package. We can't change the package under this circumstances, so abort.
1231 $dbh->rollback if $oldAutoCommit;
1232 return "Unable to transfer all services from package ". $self->pkgnum;
1235 #reset usage if changing pkgpart
1236 # AND usage rollover is off (otherwise adds twice, now and at package bill)
1237 if ($self->pkgpart != $cust_pkg->pkgpart) {
1238 my $part_pkg = $cust_pkg->part_pkg;
1239 $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1243 if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover');
1246 $dbh->rollback if $oldAutoCommit;
1247 return "Error setting usage values: $error";
1251 #Good to go, cancel old package.
1252 $error = $self->cancel( quiet=>1 );
1254 $dbh->rollback if $oldAutoCommit;
1258 if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
1260 my $error = $cust_pkg->cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
1262 $dbh->rollback if $oldAutoCommit;
1267 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1275 Returns the last bill date, or if there is no last bill date, the setup date.
1276 Useful for billing metered services.
1282 return $self->setfield('last_bill', $_[0]) if @_;
1283 return $self->getfield('last_bill') if $self->getfield('last_bill');
1284 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1285 'edate' => $self->bill, } );
1286 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1289 =item last_cust_pkg_reason ACTION
1291 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1292 Returns false if there is no reason or the package is not currenly ACTION'd
1293 ACTION is one of adjourn, susp, cancel, or expire.
1297 sub last_cust_pkg_reason {
1298 my ( $self, $action ) = ( shift, shift );
1299 my $date = $self->get($action);
1301 'table' => 'cust_pkg_reason',
1302 'hashref' => { 'pkgnum' => $self->pkgnum,
1303 'action' => substr(uc($action), 0, 1),
1306 'order_by' => 'ORDER BY num DESC LIMIT 1',
1310 =item last_reason ACTION
1312 Returns the most recent ACTION FS::reason associated with the package.
1313 Returns false if there is no reason or the package is not currenly ACTION'd
1314 ACTION is one of adjourn, susp, cancel, or expire.
1319 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1320 $cust_pkg_reason->reason
1321 if $cust_pkg_reason;
1326 Returns the definition for this billing item, as an FS::part_pkg object (see
1333 return $self->{'_pkgpart'} if $self->{'_pkgpart'};
1334 cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
1335 qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1340 Returns the cancelled package this package was changed from, if any.
1346 return '' unless $self->change_pkgnum;
1347 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1352 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1359 $self->part_pkg->calc_setup($self, @_);
1364 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1371 $self->part_pkg->calc_recur($self, @_);
1376 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1383 $self->part_pkg->calc_remain($self, @_);
1388 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1395 $self->part_pkg->calc_cancel($self, @_);
1400 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1406 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1409 =item cust_pkg_detail [ DETAILTYPE ]
1411 Returns any customer package details for this package (see
1412 L<FS::cust_pkg_detail>).
1414 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1418 sub cust_pkg_detail {
1420 my %hash = ( 'pkgnum' => $self->pkgnum );
1421 $hash{detailtype} = shift if @_;
1423 'table' => 'cust_pkg_detail',
1424 'hashref' => \%hash,
1425 'order_by' => 'ORDER BY weight, pkgdetailnum',
1429 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1431 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1433 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1435 If there is an error, returns the error, otherwise returns false.
1439 sub set_cust_pkg_detail {
1440 my( $self, $detailtype, @details ) = @_;
1442 local $SIG{HUP} = 'IGNORE';
1443 local $SIG{INT} = 'IGNORE';
1444 local $SIG{QUIT} = 'IGNORE';
1445 local $SIG{TERM} = 'IGNORE';
1446 local $SIG{TSTP} = 'IGNORE';
1447 local $SIG{PIPE} = 'IGNORE';
1449 my $oldAutoCommit = $FS::UID::AutoCommit;
1450 local $FS::UID::AutoCommit = 0;
1453 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1454 my $error = $current->delete;
1456 $dbh->rollback if $oldAutoCommit;
1457 return "error removing old detail: $error";
1461 foreach my $detail ( @details ) {
1462 my $cust_pkg_detail = new FS::cust_pkg_detail {
1463 'pkgnum' => $self->pkgnum,
1464 'detailtype' => $detailtype,
1465 'detail' => $detail,
1467 my $error = $cust_pkg_detail->insert;
1469 $dbh->rollback if $oldAutoCommit;
1470 return "error adding new detail: $error";
1475 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1482 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
1486 #false laziness w/cust_bill.pm
1490 'table' => 'cust_event',
1491 'addl_from' => 'JOIN part_event USING ( eventpart )',
1492 'hashref' => { 'tablenum' => $self->pkgnum },
1493 'extra_sql' => " AND eventtable = 'cust_pkg' ",
1497 =item num_cust_event
1499 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
1503 #false laziness w/cust_bill.pm
1504 sub num_cust_event {
1507 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
1508 " WHERE tablenum = ? AND eventtable = 'cust_pkg'";
1509 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
1510 $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
1511 $sth->fetchrow_arrayref->[0];
1514 =item cust_svc [ SVCPART ]
1516 Returns the services for this package, as FS::cust_svc objects (see
1517 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1525 return () unless $self->num_cust_svc(@_);
1528 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1529 'svcpart' => shift, } );
1532 cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
1534 #if ( $self->{'_svcnum'} ) {
1535 # values %{ $self->{'_svcnum'}->cache };
1537 $self->_sort_cust_svc(
1538 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1544 =item overlimit [ SVCPART ]
1546 Returns the services for this package which have exceeded their
1547 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1548 is specified, return only the matching services.
1554 return () unless $self->num_cust_svc(@_);
1555 grep { $_->overlimit } $self->cust_svc(@_);
1558 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1560 Returns historical services for this package created before END TIMESTAMP and
1561 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1562 (see L<FS::h_cust_svc>).
1569 $self->_sort_cust_svc(
1570 [ qsearch( 'h_cust_svc',
1571 { 'pkgnum' => $self->pkgnum, },
1572 FS::h_cust_svc->sql_h_search(@_),
1578 sub _sort_cust_svc {
1579 my( $self, $arrayref ) = @_;
1582 sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] };
1587 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1588 'svcpart' => $_->svcpart } );
1590 $pkg_svc ? $pkg_svc->primary_svc : '',
1591 $pkg_svc ? $pkg_svc->quantity : 0,
1598 =item num_cust_svc [ SVCPART ]
1600 Returns the number of provisioned services for this package. If a svcpart is
1601 specified, counts only the matching services.
1608 return $self->{'_num_cust_svc'}
1610 && exists($self->{'_num_cust_svc'})
1611 && $self->{'_num_cust_svc'} =~ /\d/;
1613 cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
1616 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1617 $sql .= ' AND svcpart = ?' if @_;
1619 my $sth = dbh->prepare($sql) or die dbh->errstr;
1620 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1621 $sth->fetchrow_arrayref->[0];
1624 =item available_part_svc
1626 Returns a list of FS::part_svc objects representing services included in this
1627 package but not yet provisioned. Each FS::part_svc object also has an extra
1628 field, I<num_avail>, which specifies the number of available services.
1632 sub available_part_svc {
1634 grep { $_->num_avail > 0 }
1636 my $part_svc = $_->part_svc;
1637 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1638 $_->quantity - $self->num_cust_svc($_->svcpart);
1641 $self->part_pkg->pkg_svc;
1646 Returns a list of FS::part_svc objects representing provisioned and available
1647 services included in this package. Each FS::part_svc object also has the
1648 following extra fields:
1652 =item num_cust_svc (count)
1654 =item num_avail (quantity - count)
1656 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1659 label -> ($cust_svc->label)[1]
1668 #XXX some sort of sort order besides numeric by svcpart...
1669 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1671 my $part_svc = $pkg_svc->part_svc;
1672 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1673 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1674 $part_svc->{'Hash'}{'num_avail'} =
1675 max( 0, $pkg_svc->quantity - $num_cust_svc );
1676 $part_svc->{'Hash'}{'cust_pkg_svc'} =
1677 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1679 } $self->part_pkg->pkg_svc;
1682 push @part_svc, map {
1684 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1685 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1686 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1687 $part_svc->{'Hash'}{'cust_pkg_svc'} =
1688 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1690 } $self->extra_part_svc;
1696 =item extra_part_svc
1698 Returns a list of FS::part_svc objects corresponding to services in this
1699 package which are still provisioned but not (any longer) available in the
1704 sub extra_part_svc {
1707 my $pkgnum = $self->pkgnum;
1708 my $pkgpart = $self->pkgpart;
1711 # 'table' => 'part_svc',
1714 # "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1715 # WHERE pkg_svc.svcpart = part_svc.svcpart
1716 # AND pkg_svc.pkgpart = ?
1719 # AND 0 < ( SELECT COUNT(*) FROM cust_svc
1720 # LEFT JOIN cust_pkg USING ( pkgnum )
1721 # WHERE cust_svc.svcpart = part_svc.svcpart
1724 # 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1727 #seems to benchmark slightly faster...
1729 #'select' => 'DISTINCT ON (svcpart) part_svc.*',
1730 #MySQL doesn't grok DISINCT ON
1731 'select' => 'DISTINCT part_svc.*',
1732 'table' => 'part_svc',
1734 'LEFT JOIN pkg_svc ON ( pkg_svc.svcpart = part_svc.svcpart
1735 AND pkg_svc.pkgpart = ?
1738 LEFT JOIN cust_svc ON ( cust_svc.svcpart = part_svc.svcpart )
1739 LEFT JOIN cust_pkg USING ( pkgnum )
1742 'extra_sql' => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
1743 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1749 Returns a short status string for this package, currently:
1753 =item not yet billed
1755 =item one-time charge
1770 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1772 return 'cancelled' if $self->get('cancel');
1773 return 'suspended' if $self->susp;
1774 return 'not yet billed' unless $self->setup;
1775 return 'one-time charge' if $freq =~ /^(0|$)/;
1779 =item ucfirst_status
1781 Returns the status with the first character capitalized.
1785 sub ucfirst_status {
1786 ucfirst(shift->status);
1791 Class method that returns the list of possible status strings for packages
1792 (see L<the status method|/status>). For example:
1794 @statuses = FS::cust_pkg->statuses();
1798 tie my %statuscolor, 'Tie::IxHash',
1799 'not yet billed' => '000000',
1800 'one-time charge' => '000000',
1801 'active' => '00CC00',
1802 'suspended' => 'FF9900',
1803 'cancelled' => 'FF0000',
1807 my $self = shift; #could be class...
1808 #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1809 # # mayble split btw one-time vs. recur
1815 Returns a hex triplet color string for this package's status.
1821 $statuscolor{$self->status};
1826 Returns a label for this package. (Currently "pkgnum: pkg - comment" or
1827 "pkg-comment" depending on user preference).
1833 my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
1834 $label = $self->pkgnum. ": $label"
1835 if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
1839 =item pkg_label_long
1841 Returns a long label for this package, adding the primary service's label to
1846 sub pkg_label_long {
1848 my $label = $self->pkg_label;
1849 my $cust_svc = $self->primary_cust_svc;
1850 $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
1854 =item primary_cust_svc
1856 Returns a primary service (as FS::cust_svc object) if one can be identified.
1860 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
1862 sub primary_cust_svc {
1865 my @cust_svc = $self->cust_svc;
1867 return '' unless @cust_svc; #no serivces - irrelevant then
1869 return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
1871 # primary service as specified in the package definition
1872 # or exactly one service definition with quantity one
1873 my $svcpart = $self->part_pkg->svcpart;
1874 @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
1875 return $cust_svc[0] if scalar(@cust_svc) == 1;
1877 #couldn't identify one thing..
1883 Returns a list of lists, calling the label method for all services
1884 (see L<FS::cust_svc>) of this billing item.
1890 map { [ $_->label ] } $self->cust_svc;
1893 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1895 Like the labels method, but returns historical information on services that
1896 were active as of END_TIMESTAMP and (optionally) not cancelled before
1899 Returns a list of lists, calling the label method for all (historical) services
1900 (see L<FS::h_cust_svc>) of this billing item.
1906 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1911 Like labels, except returns a simple flat list, and shortens long
1912 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1913 identical services to one line that lists the service label and the number of
1914 individual services rather than individual items.
1919 shift->_labels_short( 'labels', @_ );
1922 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1924 Like h_labels, except returns a simple flat list, and shortens long
1925 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1926 identical services to one line that lists the service label and the number of
1927 individual services rather than individual items.
1931 sub h_labels_short {
1932 shift->_labels_short( 'h_labels', @_ );
1936 my( $self, $method ) = ( shift, shift );
1938 my $conf = new FS::Conf;
1939 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1942 #tie %labels, 'Tie::IxHash';
1943 push @{ $labels{$_->[0]} }, $_->[1]
1944 foreach $self->$method(@_);
1946 foreach my $label ( keys %labels ) {
1948 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1949 my $num = scalar(@values);
1950 if ( $num > $max_same_services ) {
1951 push @labels, "$label ($num)";
1953 if ( $conf->exists('cust_bill-consolidate_services') ) {
1954 # push @labels, "$label: ". join(', ', @values);
1956 my $detail = "$label: ";
1957 $detail .= shift(@values). ', '
1958 while @values && length($detail.$values[0]) < 78;
1960 push @labels, $detail;
1963 push @labels, map { "$label: $_" } @values;
1974 Returns the parent customer object (see L<FS::cust_main>).
1980 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1983 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
1987 Returns the location object, if any (see L<FS::cust_location>).
1989 =item cust_location_or_main
1991 If this package is associated with a location, returns the locaiton (see
1992 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
1994 =item location_label [ OPTION => VALUE ... ]
1996 Returns the label of the location object (see L<FS::cust_location>).
2000 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
2002 =item seconds_since TIMESTAMP
2004 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
2005 package have been online since TIMESTAMP, according to the session monitor.
2007 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
2008 L<Time::Local> and L<Date::Parse> for conversion functions.
2013 my($self, $since) = @_;
2016 foreach my $cust_svc (
2017 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
2019 $seconds += $cust_svc->seconds_since($since);
2026 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
2028 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
2029 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
2032 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2033 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
2039 sub seconds_since_sqlradacct {
2040 my($self, $start, $end) = @_;
2044 foreach my $cust_svc (
2046 my $part_svc = $_->part_svc;
2047 $part_svc->svcdb eq 'svc_acct'
2048 && scalar($part_svc->part_export('sqlradius'));
2051 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
2058 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
2060 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
2061 in this package for sessions ending between TIMESTAMP_START (inclusive) and
2065 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2066 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
2071 sub attribute_since_sqlradacct {
2072 my($self, $start, $end, $attrib) = @_;
2076 foreach my $cust_svc (
2078 my $part_svc = $_->part_svc;
2079 $part_svc->svcdb eq 'svc_acct'
2080 && scalar($part_svc->part_export('sqlradius'));
2083 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
2095 my( $self, $value ) = @_;
2096 if ( defined($value) ) {
2097 $self->setfield('quantity', $value);
2099 $self->getfield('quantity') || 1;
2102 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
2104 Transfers as many services as possible from this package to another package.
2106 The destination package can be specified by pkgnum by passing an FS::cust_pkg
2107 object. The destination package must already exist.
2109 Services are moved only if the destination allows services with the correct
2110 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
2111 this option with caution! No provision is made for export differences
2112 between the old and new service definitions. Probably only should be used
2113 when your exports for all service definitions of a given svcdb are identical.
2114 (attempt a transfer without it first, to move all possible svcpart-matching
2117 Any services that can't be moved remain in the original package.
2119 Returns an error, if there is one; otherwise, returns the number of services
2120 that couldn't be moved.
2125 my ($self, $dest_pkgnum, %opt) = @_;
2131 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
2132 $dest = $dest_pkgnum;
2133 $dest_pkgnum = $dest->pkgnum;
2135 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
2138 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
2140 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
2141 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
2144 foreach my $cust_svc ($dest->cust_svc) {
2145 $target{$cust_svc->svcpart}--;
2148 my %svcpart2svcparts = ();
2149 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2150 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
2151 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
2152 next if exists $svcpart2svcparts{$svcpart};
2153 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2154 $svcpart2svcparts{$svcpart} = [
2156 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
2158 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
2159 'svcpart' => $_ } );
2161 $pkg_svc ? $pkg_svc->primary_svc : '',
2162 $pkg_svc ? $pkg_svc->quantity : 0,
2166 grep { $_ != $svcpart }
2168 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
2170 warn "alternates for svcpart $svcpart: ".
2171 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
2176 foreach my $cust_svc ($self->cust_svc) {
2177 if($target{$cust_svc->svcpart} > 0) {
2178 $target{$cust_svc->svcpart}--;
2179 my $new = new FS::cust_svc { $cust_svc->hash };
2180 $new->pkgnum($dest_pkgnum);
2181 my $error = $new->replace($cust_svc);
2182 return $error if $error;
2183 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2185 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
2186 warn "alternates to consider: ".
2187 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
2189 my @alternate = grep {
2190 warn "considering alternate svcpart $_: ".
2191 "$target{$_} available in new package\n"
2194 } @{$svcpart2svcparts{$cust_svc->svcpart}};
2196 warn "alternate(s) found\n" if $DEBUG;
2197 my $change_svcpart = $alternate[0];
2198 $target{$change_svcpart}--;
2199 my $new = new FS::cust_svc { $cust_svc->hash };
2200 $new->svcpart($change_svcpart);
2201 $new->pkgnum($dest_pkgnum);
2202 my $error = $new->replace($cust_svc);
2203 return $error if $error;
2216 This method is deprecated. See the I<depend_jobnum> option to the insert and
2217 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2224 local $SIG{HUP} = 'IGNORE';
2225 local $SIG{INT} = 'IGNORE';
2226 local $SIG{QUIT} = 'IGNORE';
2227 local $SIG{TERM} = 'IGNORE';
2228 local $SIG{TSTP} = 'IGNORE';
2229 local $SIG{PIPE} = 'IGNORE';
2231 my $oldAutoCommit = $FS::UID::AutoCommit;
2232 local $FS::UID::AutoCommit = 0;
2235 foreach my $cust_svc ( $self->cust_svc ) {
2236 #false laziness w/svc_Common::insert
2237 my $svc_x = $cust_svc->svc_x;
2238 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2239 my $error = $part_export->export_insert($svc_x);
2241 $dbh->rollback if $oldAutoCommit;
2247 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2254 Associates this package with a (suspension or cancellation) reason (see
2255 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2258 Available options are:
2264 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.
2268 the access_user (see L<FS::access_user>) providing the reason
2276 the action (cancel, susp, adjourn, expire) associated with the reason
2280 If there is an error, returns the error, otherwise returns false.
2285 my ($self, %options) = @_;
2287 my $otaker = $options{reason_otaker} ||
2288 $FS::CurrentUser::CurrentUser->username;
2291 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2295 } elsif ( ref($options{'reason'}) ) {
2297 return 'Enter a new reason (or select an existing one)'
2298 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2300 my $reason = new FS::reason({
2301 'reason_type' => $options{'reason'}->{'typenum'},
2302 'reason' => $options{'reason'}->{'reason'},
2304 my $error = $reason->insert;
2305 return $error if $error;
2307 $reasonnum = $reason->reasonnum;
2310 return "Unparsable reason: ". $options{'reason'};
2313 my $cust_pkg_reason =
2314 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2315 'reasonnum' => $reasonnum,
2316 'otaker' => $otaker,
2317 'action' => substr(uc($options{'action'}),0,1),
2318 'date' => $options{'date'}
2323 $cust_pkg_reason->insert;
2326 =item insert_discount
2328 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
2329 inserting a new discount on the fly (see L<FS::discount>).
2331 Available options are:
2339 If there is an error, returns the error, otherwise returns false.
2343 sub insert_discount {
2344 #my ($self, %options) = @_;
2347 my $cust_pkg_discount = new FS::cust_pkg_discount {
2348 'pkgnum' => $self->pkgnum,
2349 'discountnum' => $self->discountnum,
2351 'end_date' => '', #XXX
2352 'otaker' => $self->otaker,
2353 #for the create a new discount case
2354 '_type' => $self->discountnum__type,
2355 'amount' => $self->discountnum_amount,
2356 'percent' => $self->discountnum_percent,
2357 'months' => $self->discountnum_months,
2358 #'disabled' => $self->discountnum_disabled,
2361 $cust_pkg_discount->insert;
2364 =item set_usage USAGE_VALUE_HASHREF
2366 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2367 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2368 upbytes, downbytes, and totalbytes are appropriate keys.
2370 All svc_accts which are part of this package have their values reset.
2375 my ($self, $valueref, %opt) = @_;
2377 foreach my $cust_svc ($self->cust_svc){
2378 my $svc_x = $cust_svc->svc_x;
2379 $svc_x->set_usage($valueref, %opt)
2380 if $svc_x->can("set_usage");
2384 =item recharge USAGE_VALUE_HASHREF
2386 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2387 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2388 upbytes, downbytes, and totalbytes are appropriate keys.
2390 All svc_accts which are part of this package have their values incremented.
2395 my ($self, $valueref) = @_;
2397 foreach my $cust_svc ($self->cust_svc){
2398 my $svc_x = $cust_svc->svc_x;
2399 $svc_x->recharge($valueref)
2400 if $svc_x->can("recharge");
2404 =item cust_pkg_discount
2408 sub cust_pkg_discount {
2410 qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
2413 =item cust_pkg_discount_active
2417 sub cust_pkg_discount_active {
2419 grep { $_->status eq 'active' } $self->cust_pkg_discount;
2424 =head1 CLASS METHODS
2430 Returns an SQL expression identifying recurring packages.
2434 sub recurring_sql { "
2435 '0' != ( select freq from part_pkg
2436 where cust_pkg.pkgpart = part_pkg.pkgpart )
2441 Returns an SQL expression identifying one-time packages.
2446 '0' = ( select freq from part_pkg
2447 where cust_pkg.pkgpart = part_pkg.pkgpart )
2452 Returns an SQL expression identifying ordered packages (recurring packages not
2458 $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
2463 Returns an SQL expression identifying active packages.
2468 $_[0]->recurring_sql. "
2469 AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2470 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2471 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2474 =item not_yet_billed_sql
2476 Returns an SQL expression identifying packages which have not yet been billed.
2480 sub not_yet_billed_sql { "
2481 ( cust_pkg.setup IS NULL OR cust_pkg.setup = 0 )
2482 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2483 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2488 Returns an SQL expression identifying inactive packages (one-time packages
2489 that are otherwise unsuspended/uncancelled).
2493 sub inactive_sql { "
2494 ". $_[0]->onetime_sql(). "
2495 AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2496 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2497 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2503 Returns an SQL expression identifying suspended packages.
2507 sub suspended_sql { susp_sql(@_); }
2509 #$_[0]->recurring_sql(). ' AND '.
2511 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2512 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
2519 Returns an SQL exprression identifying cancelled packages.
2523 sub cancelled_sql { cancel_sql(@_); }
2525 #$_[0]->recurring_sql(). ' AND '.
2526 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
2529 =item search HASHREF
2533 Returns a qsearch hash expression to search for parameters specified in HASHREF.
2534 Valid parameters are
2542 active, inactive, suspended, cancel (or cancelled)
2546 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
2550 boolean selects custom packages
2556 pkgpart or arrayref or hashref of pkgparts
2560 arrayref of beginning and ending epoch date
2564 arrayref of beginning and ending epoch date
2568 arrayref of beginning and ending epoch date
2572 arrayref of beginning and ending epoch date
2576 arrayref of beginning and ending epoch date
2580 arrayref of beginning and ending epoch date
2584 arrayref of beginning and ending epoch date
2588 pkgnum or APKG_pkgnum
2592 a value suited to passing to FS::UI::Web::cust_header
2596 specifies the user for agent virtualization
2600 boolean selects packages containing fcc form 477 telco lines
2607 my ($class, $params) = @_;
2614 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2616 "cust_main.agentnum = $1";
2623 if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
2625 "cust_pkg.custnum = $1";
2632 if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
2634 "cust_pkg.pkgbatch = '$1'";
2641 if ( $params->{'magic'} eq 'active'
2642 || $params->{'status'} eq 'active' ) {
2644 push @where, FS::cust_pkg->active_sql();
2646 } elsif ( $params->{'magic'} =~ /^not[ _]yet[ _]billed$/
2647 || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
2649 push @where, FS::cust_pkg->not_yet_billed_sql();
2651 } elsif ( $params->{'magic'} =~ /^(one-time charge|inactive)/
2652 || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
2654 push @where, FS::cust_pkg->inactive_sql();
2656 } elsif ( $params->{'magic'} eq 'suspended'
2657 || $params->{'status'} eq 'suspended' ) {
2659 push @where, FS::cust_pkg->suspended_sql();
2661 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
2662 || $params->{'status'} =~ /^cancell?ed$/ ) {
2664 push @where, FS::cust_pkg->cancelled_sql();
2669 # parse package class
2672 #false lazinessish w/graph/cust_bill_pkg.cgi
2675 if ( exists($params->{'classnum'})
2676 && $params->{'classnum'} =~ /^(\d*)$/
2680 if ( $classnum ) { #a specific class
2681 push @where, "part_pkg.classnum = $classnum";
2683 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
2684 #die "classnum $classnum not found!" unless $pkg_class[0];
2685 #$title .= $pkg_class[0]->classname.' ';
2687 } elsif ( $classnum eq '' ) { #the empty class
2689 push @where, "part_pkg.classnum IS NULL";
2690 #$title .= 'Empty class ';
2691 #@pkg_class = ( '(empty class)' );
2692 } elsif ( $classnum eq '0' ) {
2693 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
2694 #push @pkg_class, '(empty class)';
2696 die "illegal classnum";
2702 # parse package report options
2705 my @report_option = ();
2706 if ( exists($params->{'report_option'})
2707 && $params->{'report_option'} =~ /^([,\d]*)$/
2710 @report_option = split(',', $1);
2713 if (@report_option) {
2714 # this will result in the empty set for the dangling comma case as it should
2716 map{ "0 < ( SELECT count(*) FROM part_pkg_option
2717 WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
2718 AND optionname = 'report_option_$_'
2719 AND optionvalue = '1' )"
2729 push @where, "part_pkg.custom = 'Y'" if $params->{custom};
2735 push @where, "part_pkg.fcc_ds0s > 0" if $params->{fcc_line};
2741 if ( exists($params->{'censustract'}) ) {
2742 $params->{'censustract'} =~ /^([.\d]*)$/;
2743 my $censustract = "cust_main.censustract = '$1'";
2744 $censustract .= ' OR cust_main.censustract is NULL' unless $1;
2745 push @where, "( $censustract )";
2752 if ( ref($params->{'pkgpart'}) ) {
2755 if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
2756 @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
2757 } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
2758 @pkgpart = @{ $params->{'pkgpart'} };
2760 die 'unhandled pkgpart ref '. $params->{'pkgpart'};
2763 @pkgpart = grep /^(\d+)$/, @pkgpart;
2765 push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
2767 } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2768 push @where, "pkgpart = $1";
2777 #false laziness w/report_cust_pkg.html
2780 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
2781 'active' => { 'susp'=>1, 'cancel'=>1 },
2782 'suspended' => { 'cancel' => 1 },
2787 if( exists($params->{'active'} ) ) {
2788 # This overrides all the other date-related fields
2789 my($beginning, $ending) = @{$params->{'active'}};
2791 "cust_pkg.setup IS NOT NULL",
2792 "cust_pkg.setup <= $ending",
2793 "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
2794 "NOT (".FS::cust_pkg->onetime_sql . ")";
2797 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
2799 next unless exists($params->{$field});
2801 my($beginning, $ending) = @{$params->{$field}};
2803 next if $beginning == 0 && $ending == 4294967295;
2806 "cust_pkg.$field IS NOT NULL",
2807 "cust_pkg.$field >= $beginning",
2808 "cust_pkg.$field <= $ending";
2810 $orderby ||= "ORDER BY cust_pkg.$field";
2815 $orderby ||= 'ORDER BY bill';
2818 # parse magic, legacy, etc.
2821 if ( $params->{'magic'} &&
2822 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
2825 $orderby = 'ORDER BY pkgnum';
2827 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2828 push @where, "pkgpart = $1";
2831 } elsif ( $params->{'query'} eq 'pkgnum' ) {
2833 $orderby = 'ORDER BY pkgnum';
2835 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2837 $orderby = 'ORDER BY pkgnum';
2840 SELECT count(*) FROM pkg_svc
2841 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
2842 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2843 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
2844 AND cust_svc.svcpart = pkg_svc.svcpart
2851 # setup queries, links, subs, etc. for the search
2854 # here is the agent virtualization
2855 if ($params->{CurrentUser}) {
2857 qsearchs('access_user', { username => $params->{CurrentUser} });
2860 push @where, $access_user->agentnums_sql('table'=>'cust_main');
2865 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
2868 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2870 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
2871 'LEFT JOIN part_pkg USING ( pkgpart ) '.
2872 'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
2874 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2877 'table' => 'cust_pkg',
2879 'select' => join(', ',
2881 ( map "part_pkg.$_", qw( pkg freq ) ),
2882 'pkg_class.classname',
2883 'cust_main.custnum AS cust_main_custnum',
2884 FS::UI::Web::cust_sql_fields(
2885 $params->{'cust_fields'}
2888 'extra_sql' => "$extra_sql $orderby",
2889 'addl_from' => $addl_from,
2890 'count_query' => $count_query,
2897 Returns a list of two package counts. The first is a count of packages
2898 based on the supplied criteria and the second is the count of residential
2899 packages with those same criteria. Criteria are specified as in the search
2905 my ($class, $params) = @_;
2907 my $sql_query = $class->search( $params );
2909 my $count_sql = delete($sql_query->{'count_query'});
2910 $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
2911 or die "couldn't parse count_sql";
2913 my $count_sth = dbh->prepare($count_sql)
2914 or die "Error preparing $count_sql: ". dbh->errstr;
2916 or die "Error executing $count_sql: ". $count_sth->errstr;
2917 my $count_arrayref = $count_sth->fetchrow_arrayref;
2919 return ( @$count_arrayref );
2926 Returns a list: the first item is an SQL fragment identifying matching
2927 packages/customers via location (taking into account shipping and package
2928 address taxation, if enabled), and subsequent items are the parameters to
2929 substitute for the placeholders in that fragment.
2934 my($class, %opt) = @_;
2935 my $ornull = $opt{'ornull'};
2937 my $conf = new FS::Conf;
2939 # '?' placeholders in _location_sql_where
2940 my $x = $ornull ? 3 : 2;
2941 my @bill_param = ( ('city')x3, ('county')x$x, ('state')x$x, 'country' );
2945 if ( $conf->exists('tax-ship_address') ) {
2948 ( ( ship_last IS NULL OR ship_last = '' )
2949 AND ". _location_sql_where('cust_main', '', $ornull ). "
2951 OR ( ship_last IS NOT NULL AND ship_last != ''
2952 AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
2955 # AND payby != 'COMP'
2957 @main_param = ( @bill_param, @bill_param );
2961 $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
2962 @main_param = @bill_param;
2968 if ( $conf->exists('tax-pkg_address') ) {
2970 my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
2973 ( cust_pkg.locationnum IS NULL AND $main_where )
2974 OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where )
2977 @param = ( @main_param, @bill_param );
2981 $where = $main_where;
2982 @param = @main_param;
2990 #subroutine, helper for location_sql
2991 sub _location_sql_where {
2993 my $prefix = @_ ? shift : '';
2994 my $ornull = @_ ? shift : '';
2996 # $ornull = $ornull ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
2998 $ornull = $ornull ? ' OR ? IS NULL ' : '';
3000 my $or_empty_city = " OR ( ? = '' AND $table.${prefix}city IS NULL ) ";
3001 my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
3002 my $or_empty_state = " OR ( ? = '' AND $table.${prefix}state IS NULL ) ";
3004 # ( $table.${prefix}city = ? $or_empty_city $ornull )
3006 ( $table.${prefix}city = ? OR ? = '' OR CAST(? AS text) IS NULL )
3007 AND ( $table.${prefix}county = ? $or_empty_county $ornull )
3008 AND ( $table.${prefix}state = ? $or_empty_state $ornull )
3009 AND $table.${prefix}country = ?
3017 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
3019 CUSTNUM is a customer (see L<FS::cust_main>)
3021 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3022 L<FS::part_pkg>) to order for this customer. Duplicates are of course
3025 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
3026 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
3027 new billing items. An error is returned if this is not possible (see
3028 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
3031 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3032 newly-created cust_pkg objects.
3034 REFNUM, if specified, will specify the FS::pkg_referral record to be created
3035 and inserted. Multiple FS::pkg_referral records can be created by
3036 setting I<refnum> to an array reference of refnums or a hash reference with
3037 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
3038 record will be created corresponding to cust_main.refnum.
3043 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
3045 my $conf = new FS::Conf;
3047 # Transactionize this whole mess
3048 local $SIG{HUP} = 'IGNORE';
3049 local $SIG{INT} = 'IGNORE';
3050 local $SIG{QUIT} = 'IGNORE';
3051 local $SIG{TERM} = 'IGNORE';
3052 local $SIG{TSTP} = 'IGNORE';
3053 local $SIG{PIPE} = 'IGNORE';
3055 my $oldAutoCommit = $FS::UID::AutoCommit;
3056 local $FS::UID::AutoCommit = 0;
3060 # my $cust_main = qsearchs('cust_main', { custnum => $custnum });
3061 # return "Customer not found: $custnum" unless $cust_main;
3063 warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
3066 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3069 my $change = scalar(@old_cust_pkg) != 0;
3072 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
3074 warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
3075 " to pkgpart ". $pkgparts->[0]. "\n"
3078 my $err_or_cust_pkg =
3079 $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
3080 'refnum' => $refnum,
3083 unless (ref($err_or_cust_pkg)) {
3084 $dbh->rollback if $oldAutoCommit;
3085 return $err_or_cust_pkg;
3088 push @$return_cust_pkg, $err_or_cust_pkg;
3089 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3094 # Create the new packages.
3095 foreach my $pkgpart (@$pkgparts) {
3097 warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
3099 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
3100 pkgpart => $pkgpart,
3104 $error = $cust_pkg->insert( 'change' => $change );
3106 $dbh->rollback if $oldAutoCommit;
3109 push @$return_cust_pkg, $cust_pkg;
3111 # $return_cust_pkg now contains refs to all of the newly
3114 # Transfer services and cancel old packages.
3115 foreach my $old_pkg (@old_cust_pkg) {
3117 warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
3120 foreach my $new_pkg (@$return_cust_pkg) {
3121 $error = $old_pkg->transfer($new_pkg);
3122 if ($error and $error == 0) {
3123 # $old_pkg->transfer failed.
3124 $dbh->rollback if $oldAutoCommit;
3129 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
3130 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
3131 foreach my $new_pkg (@$return_cust_pkg) {
3132 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
3133 if ($error and $error == 0) {
3134 # $old_pkg->transfer failed.
3135 $dbh->rollback if $oldAutoCommit;
3142 # Transfers were successful, but we went through all of the
3143 # new packages and still had services left on the old package.
3144 # We can't cancel the package under the circumstances, so abort.
3145 $dbh->rollback if $oldAutoCommit;
3146 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
3148 $error = $old_pkg->cancel( quiet=>1 );
3154 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3158 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
3160 A bulk change method to change packages for multiple customers.
3162 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3163 L<FS::part_pkg>) to order for each customer. Duplicates are of course
3166 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
3167 replace. The services (see L<FS::cust_svc>) are moved to the
3168 new billing items. An error is returned if this is not possible (see
3171 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3172 newly-created cust_pkg objects.
3177 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
3179 # Transactionize this whole mess
3180 local $SIG{HUP} = 'IGNORE';
3181 local $SIG{INT} = 'IGNORE';
3182 local $SIG{QUIT} = 'IGNORE';
3183 local $SIG{TERM} = 'IGNORE';
3184 local $SIG{TSTP} = 'IGNORE';
3185 local $SIG{PIPE} = 'IGNORE';
3187 my $oldAutoCommit = $FS::UID::AutoCommit;
3188 local $FS::UID::AutoCommit = 0;
3192 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3195 while(scalar(@old_cust_pkg)) {
3197 my $custnum = $old_cust_pkg[0]->custnum;
3198 my (@remove) = map { $_->pkgnum }
3199 grep { $_->custnum == $custnum } @old_cust_pkg;
3200 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
3202 my $error = order $custnum, $pkgparts, \@remove, \@return;
3204 push @errors, $error
3206 push @$return_cust_pkg, @return;
3209 if (scalar(@errors)) {
3210 $dbh->rollback if $oldAutoCommit;
3211 return join(' / ', @errors);
3214 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3218 # Used by FS::Upgrade to migrate to a new database.
3219 sub _upgrade_data { # class method
3220 my ($class, %opts) = @_;
3221 $class->_upgrade_otaker(%opts);
3228 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
3230 In sub order, the @pkgparts array (passed by reference) is clobbered.
3232 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
3233 method to pass dates to the recur_prog expression, it should do so.
3235 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
3236 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
3237 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
3238 configuration values. Probably need a subroutine which decides what to do
3239 based on whether or not we've fetched the user yet, rather than a hash. See
3240 FS::UID and the TODO.
3242 Now that things are transactional should the check in the insert method be
3247 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
3248 L<FS::pkg_svc>, schema.html from the base documentation