4 use vars qw(@ISA $disable_agentcheck $DEBUG);
6 use FS::UID qw( getotaker dbh );
7 use FS::Misc qw( send_email );
8 use FS::Record qw( qsearch qsearchs );
9 use FS::cust_main_Mixin;
15 use FS::cust_bill_pkg;
19 use FS::cust_pkg_reason;
22 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
24 # because they load configuration by setting FS::UID::callback (see TODO)
30 # for sending cancel emails in sub cancel
33 @ISA = qw( FS::cust_main_Mixin FS::Record );
37 $disable_agentcheck = 0;
41 my ( $hashref, $cache ) = @_;
42 #if ( $hashref->{'pkgpart'} ) {
43 if ( $hashref->{'pkg'} ) {
44 # #@{ $self->{'_pkgnum'} } = ();
45 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
46 # $self->{'_pkgpart'} = $subcache;
47 # #push @{ $self->{'_pkgnum'} },
48 # FS::part_pkg->new_or_cached($hashref, $subcache);
49 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
51 if ( exists $hashref->{'svcnum'} ) {
52 #@{ $self->{'_pkgnum'} } = ();
53 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
54 $self->{'_svcnum'} = $subcache;
55 #push @{ $self->{'_pkgnum'} },
56 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
62 FS::cust_pkg - Object methods for cust_pkg objects
68 $record = new FS::cust_pkg \%hash;
69 $record = new FS::cust_pkg { 'column' => 'value' };
71 $error = $record->insert;
73 $error = $new_record->replace($old_record);
75 $error = $record->delete;
77 $error = $record->check;
79 $error = $record->cancel;
81 $error = $record->suspend;
83 $error = $record->unsuspend;
85 $part_pkg = $record->part_pkg;
87 @labels = $record->labels;
89 $seconds = $record->seconds_since($timestamp);
91 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
92 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
96 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
97 inherits from FS::Record. The following fields are currently supported:
101 =item pkgnum - primary key (assigned automatically for new billing items)
103 =item custnum - Customer (see L<FS::cust_main>)
105 =item pkgpart - Billing item definition (see L<FS::part_pkg>)
109 =item bill - date (next bill date)
111 =item last_bill - last bill date
119 =item otaker - order taker (assigned automatically if null, see L<FS::UID>)
121 =item manual_flag - If this field is set to 1, disables the automatic
122 unsuspension of this package when using the B<unsuspendauto> config file.
126 Note: setup, bill, susp, expire and cancel are specified as UNIX timestamps;
127 see L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for
128 conversion functions.
136 Create a new billing item. To add the item to the database, see L<"insert">.
140 sub table { 'cust_pkg'; }
141 sub cust_linked { $_[0]->cust_main_custnum; }
142 sub cust_unlinked_msg {
144 "WARNING: can't find cust_main.custnum ". $self->custnum.
145 ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
148 =item insert [ OPTION => VALUE ... ]
150 Adds this billing item to the database ("Orders" the item). If there is an
151 error, returns the error, otherwise returns false.
153 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
154 will be used to look up the package definition and agent restrictions will be
157 The following options are available: I<change>
159 I<change>, if set true, supresses any referral credit to a referring customer.
164 my( $self, %options ) = @_;
166 local $SIG{HUP} = 'IGNORE';
167 local $SIG{INT} = 'IGNORE';
168 local $SIG{QUIT} = 'IGNORE';
169 local $SIG{TERM} = 'IGNORE';
170 local $SIG{TSTP} = 'IGNORE';
171 local $SIG{PIPE} = 'IGNORE';
173 my $oldAutoCommit = $FS::UID::AutoCommit;
174 local $FS::UID::AutoCommit = 0;
177 my $error = $self->SUPER::insert;
179 $dbh->rollback if $oldAutoCommit;
183 #if ( $self->reg_code ) {
184 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
185 # $error = $reg_code->delete;
187 # $dbh->rollback if $oldAutoCommit;
192 my $conf = new FS::Conf;
193 my $cust_main = $self->cust_main;
194 my $part_pkg = $self->part_pkg;
195 if ( $conf->exists('referral_credit')
196 && $cust_main->referral_custnum
197 && ! $options{'change'}
198 && $part_pkg->freq !~ /^0\D?$/
201 my $referring_cust_main = $cust_main->referring_cust_main;
202 if ( $referring_cust_main->status ne 'cancelled' ) {
204 if ( $part_pkg->freq !~ /^\d+$/ ) {
205 warn 'WARNING: Not crediting customer '. $cust_main->referral_custnum.
206 ' for package '. $self->pkgnum.
207 ' ( customer '. $self->custnum. ')'.
208 ' - One-time referral credits not (yet) available for '.
209 ' packages with '. $part_pkg->freq_pretty. ' frequency';
212 my $amount = sprintf( "%.2f", $part_pkg->base_recur / $part_pkg->freq );
214 $referring_cust_main->credit( $amount,
215 'Referral credit for '. $cust_main->name
218 $dbh->rollback if $oldAutoCommit;
219 return "Error crediting customer ". $cust_main->referral_custnum.
220 " for referral: $error";
228 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
235 This method now works but you probably shouldn't use it.
237 You don't want to delete billing items, because there would then be no record
238 the customer ever purchased the item. Instead, see the cancel method.
243 # return "Can't delete cust_pkg records!";
246 =item replace OLD_RECORD
248 Replaces the OLD_RECORD with this one in the database. If there is an error,
249 returns the error, otherwise returns false.
251 Currently, custnum, setup, bill, susp, expire, and cancel may be changed.
253 Changing pkgpart may have disasterous effects. See the order subroutine.
255 setup and bill are normally updated by calling the bill method of a customer
256 object (see L<FS::cust_main>).
258 suspend is normally updated by the suspend and unsuspend methods.
260 cancel is normally updated by the cancel method (and also the order subroutine
268 my( $new, $old, %options ) = @_;
270 # We absolutely have to have an old vs. new record to make this work.
271 if (!defined($old)) {
272 $old = qsearchs( 'cust_pkg', { 'pkgnum' => $new->pkgnum } );
274 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
275 return "Can't change otaker!" if $old->otaker ne $new->otaker;
278 #return "Can't change setup once it exists!"
279 # if $old->getfield('setup') &&
280 # $old->getfield('setup') != $new->getfield('setup');
282 #some logic for bill, susp, cancel?
284 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
286 local $SIG{HUP} = 'IGNORE';
287 local $SIG{INT} = 'IGNORE';
288 local $SIG{QUIT} = 'IGNORE';
289 local $SIG{TERM} = 'IGNORE';
290 local $SIG{TSTP} = 'IGNORE';
291 local $SIG{PIPE} = 'IGNORE';
293 my $oldAutoCommit = $FS::UID::AutoCommit;
294 local $FS::UID::AutoCommit = 0;
297 if ($options{'reason'} && $new->expire && $old->expire ne $new->expire) {
298 my $error = $new->insert_reason( 'reason' => $options{'reason'},
299 'date' => $new->expire,
302 dbh->rollback if $oldAutoCommit;
303 return "Error inserting cust_pkg_reason: $error";
307 #save off and freeze RADIUS attributes for any associated svc_acct records
309 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
311 #also check for specific exports?
312 # to avoid spurious modify export events
313 @svc_acct = map { $_->svc_x }
314 grep { $_->part_svc->svcdb eq 'svc_acct' }
317 $_->snapshot foreach @svc_acct;
321 my $error = $new->SUPER::replace($old);
323 $dbh->rollback if $oldAutoCommit;
327 #for prepaid packages,
328 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
329 foreach my $old_svc_acct ( @svc_acct ) {
330 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
331 my $s_error = $new_svc_acct->replace($old_svc_acct);
333 $dbh->rollback if $oldAutoCommit;
338 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
345 Checks all fields to make sure this is a valid billing item. If there is an
346 error, returns the error, otherwise returns false. Called by the insert and
355 $self->ut_numbern('pkgnum')
356 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
357 || $self->ut_numbern('pkgpart')
358 || $self->ut_numbern('setup')
359 || $self->ut_numbern('bill')
360 || $self->ut_numbern('susp')
361 || $self->ut_numbern('cancel')
363 return $error if $error;
365 if ( $self->reg_code ) {
367 unless ( grep { $self->pkgpart == $_->pkgpart }
368 map { $_->reg_code_pkg }
369 qsearchs( 'reg_code', { 'code' => $self->reg_code,
370 'agentnum' => $self->cust_main->agentnum })
372 return "Unknown registration code";
375 } elsif ( $self->promo_code ) {
378 qsearchs('part_pkg', {
379 'pkgpart' => $self->pkgpart,
380 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
382 return 'Unknown promotional code' unless $promo_part_pkg;
386 unless ( $disable_agentcheck ) {
388 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
389 my $pkgpart_href = $agent->pkgpart_hashref;
390 return "agent ". $agent->agentnum.
391 " can't purchase pkgpart ". $self->pkgpart
392 unless $pkgpart_href->{ $self->pkgpart };
395 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
396 return $error if $error;
400 $self->otaker(getotaker) unless $self->otaker;
401 $self->otaker =~ /^([\w\.\-]{0,16})$/ or return "Illegal otaker";
404 if ( $self->dbdef_table->column('manual_flag') ) {
405 $self->manual_flag('') if $self->manual_flag eq ' ';
406 $self->manual_flag =~ /^([01]?)$/
407 or return "Illegal manual_flag ". $self->manual_flag;
408 $self->manual_flag($1);
414 =item cancel [ OPTION => VALUE ... ]
416 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
417 in this package, then cancels the package itself (sets the cancel field to
420 Available options are: I<quiet>
422 I<quiet> can be set true to supress email cancellation notices.
424 If there is an error, returns the error, otherwise returns false.
429 my( $self, %options ) = @_;
432 local $SIG{HUP} = 'IGNORE';
433 local $SIG{INT} = 'IGNORE';
434 local $SIG{QUIT} = 'IGNORE';
435 local $SIG{TERM} = 'IGNORE';
436 local $SIG{TSTP} = 'IGNORE';
437 local $SIG{PIPE} = 'IGNORE';
439 my $oldAutoCommit = $FS::UID::AutoCommit;
440 local $FS::UID::AutoCommit = 0;
443 if ($options{'reason'}) {
444 $error = $self->insert_reason( 'reason' => $options{'reason'} );
446 dbh->rollback if $oldAutoCommit;
447 return "Error inserting cust_pkg_reason: $error";
452 foreach my $cust_svc (
455 sort { $a->[1] <=> $b->[1] }
456 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
457 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
460 my $error = $cust_svc->cancel;
463 $dbh->rollback if $oldAutoCommit;
464 return "Error cancelling cust_svc: $error";
468 # Add a credit for remaining service
469 my $remaining_value = $self->calc_remain();
470 if ( $remaining_value > 0 ) {
471 my $error = $self->cust_main->credit(
473 'Credit for unused time on '. $self->part_pkg->pkg,
476 $dbh->rollback if $oldAutoCommit;
477 return "Error crediting customer \$$remaining_value for unused time on".
478 $self->part_pkg->pkg. ": $error";
482 unless ( $self->getfield('cancel') ) {
483 my %hash = $self->hash;
484 $hash{'cancel'} = time;
485 my $new = new FS::cust_pkg ( \%hash );
486 $error = $new->replace($self);
488 $dbh->rollback if $oldAutoCommit;
493 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
495 my $conf = new FS::Conf;
496 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
497 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
498 my $conf = new FS::Conf;
499 my $error = send_email(
500 'from' => $conf->config('invoice_from'),
501 'to' => \@invoicing_list,
502 'subject' => $conf->config('cancelsubject'),
503 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
505 #should this do something on errors?
514 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
515 package, then suspends the package itself (sets the susp field to now).
517 If there is an error, returns the error, otherwise returns false.
522 my( $self, %options ) = @_;
525 local $SIG{HUP} = 'IGNORE';
526 local $SIG{INT} = 'IGNORE';
527 local $SIG{QUIT} = 'IGNORE';
528 local $SIG{TERM} = 'IGNORE';
529 local $SIG{TSTP} = 'IGNORE';
530 local $SIG{PIPE} = 'IGNORE';
532 my $oldAutoCommit = $FS::UID::AutoCommit;
533 local $FS::UID::AutoCommit = 0;
536 if ($options{'reason'}) {
537 $error = $self->insert_reason( 'reason' => $options{'reason'} );
539 dbh->rollback if $oldAutoCommit;
540 return "Error inserting cust_pkg_reason: $error";
544 foreach my $cust_svc (
545 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
547 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
549 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
550 $dbh->rollback if $oldAutoCommit;
551 return "Illegal svcdb value in part_svc!";
554 require "FS/$svcdb.pm";
556 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
558 $error = $svc->suspend;
560 $dbh->rollback if $oldAutoCommit;
567 unless ( $self->getfield('susp') ) {
568 my %hash = $self->hash;
569 $hash{'susp'} = time;
570 my $new = new FS::cust_pkg ( \%hash );
571 $error = $new->replace($self);
573 $dbh->rollback if $oldAutoCommit;
578 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
583 =item unsuspend [ OPTION => VALUE ... ]
585 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
586 package, then unsuspends the package itself (clears the susp field).
588 Available options are: I<adjust_next_bill>.
590 I<adjust_next_bill> can be set true to adjust the next bill date forward by
591 the amount of time the account was inactive. This was set true by default
592 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
593 explicitly requested. Price plans for which this makes sense (anniversary-date
594 based than prorate or subscription) could have an option to enable this
597 If there is an error, returns the error, otherwise returns false.
602 my( $self, %opt ) = @_;
605 local $SIG{HUP} = 'IGNORE';
606 local $SIG{INT} = 'IGNORE';
607 local $SIG{QUIT} = 'IGNORE';
608 local $SIG{TERM} = 'IGNORE';
609 local $SIG{TSTP} = 'IGNORE';
610 local $SIG{PIPE} = 'IGNORE';
612 my $oldAutoCommit = $FS::UID::AutoCommit;
613 local $FS::UID::AutoCommit = 0;
616 foreach my $cust_svc (
617 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
619 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
621 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
622 $dbh->rollback if $oldAutoCommit;
623 return "Illegal svcdb value in part_svc!";
626 require "FS/$svcdb.pm";
628 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
630 $error = $svc->unsuspend;
632 $dbh->rollback if $oldAutoCommit;
639 unless ( ! $self->getfield('susp') ) {
640 my %hash = $self->hash;
641 my $inactive = time - $hash{'susp'};
643 my $conf = new FS::Conf;
645 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
646 if ( $opt{'adjust_next_bill'}
647 || $conf->config('unsuspend-always_adjust_next_bill_date') )
648 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
651 my $new = new FS::cust_pkg ( \%hash );
652 $error = $new->replace($self);
654 $dbh->rollback if $oldAutoCommit;
659 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
666 Returns the last bill date, or if there is no last bill date, the setup date.
667 Useful for billing metered services.
673 if ( $self->dbdef_table->column('last_bill') ) {
674 return $self->setfield('last_bill', $_[0]) if @_;
675 return $self->getfield('last_bill') if $self->getfield('last_bill');
677 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
678 'edate' => $self->bill, } );
679 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
684 Returns the most recent FS::reason associated with the package.
690 my $cust_pkg_reason = qsearchs( {
691 'table' => 'cust_pkg_reason',
692 'hashref' => { 'pkgnum' => $self->pkgnum, },
693 'extra_sql'=> 'ORDER BY date DESC',
695 qsearchs ( 'reason', { 'reasonnum' => $cust_pkg_reason->reasonnum } )
701 Returns the definition for this billing item, as an FS::part_pkg object (see
708 #exists( $self->{'_pkgpart'} )
710 ? $self->{'_pkgpart'}
711 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
716 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
723 $self->part_pkg->calc_setup($self, @_);
728 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
735 $self->part_pkg->calc_recur($self, @_);
740 Calls the I<calc_remain> of the FS::part_pkg object associated with this
747 $self->part_pkg->calc_remain($self, @_);
752 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
759 $self->part_pkg->calc_cancel($self, @_);
764 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
770 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
773 =item cust_svc [ SVCPART ]
775 Returns the services for this package, as FS::cust_svc objects (see
776 L<FS::cust_svc>). If a svcpart is specified, return only the matching
785 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
786 'svcpart' => shift, } );
789 #if ( $self->{'_svcnum'} ) {
790 # values %{ $self->{'_svcnum'}->cache };
792 $self->_sort_cust_svc(
793 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
799 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
801 Returns historical services for this package created before END TIMESTAMP and
802 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
803 (see L<FS::h_cust_svc>).
810 $self->_sort_cust_svc(
811 [ qsearch( 'h_cust_svc',
812 { 'pkgnum' => $self->pkgnum, },
813 FS::h_cust_svc->sql_h_search(@_),
820 my( $self, $arrayref ) = @_;
823 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
825 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
826 'svcpart' => $_->svcpart } );
828 $pkg_svc ? $pkg_svc->primary_svc : '',
829 $pkg_svc ? $pkg_svc->quantity : 0,
836 =item num_cust_svc [ SVCPART ]
838 Returns the number of provisioned services for this package. If a svcpart is
839 specified, counts only the matching services.
845 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
846 $sql .= ' AND svcpart = ?' if @_;
847 my $sth = dbh->prepare($sql) or die dbh->errstr;
848 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
849 $sth->fetchrow_arrayref->[0];
852 =item available_part_svc
854 Returns a list of FS::part_svc objects representing services included in this
855 package but not yet provisioned. Each FS::part_svc object also has an extra
856 field, I<num_avail>, which specifies the number of available services.
860 sub available_part_svc {
862 grep { $_->num_avail > 0 }
864 my $part_svc = $_->part_svc;
865 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
866 $_->quantity - $self->num_cust_svc($_->svcpart);
869 $self->part_pkg->pkg_svc;
874 Returns a list of FS::part_svc objects representing provisioned and available
875 services included in this package. Each FS::part_svc object also has the
876 following extra fields:
880 =item num_cust_svc (count)
882 =item num_avail (quantity - count)
884 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
887 label -> ($cust_svc->label)[1]
896 #XXX some sort of sort order besides numeric by svcpart...
897 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
899 my $part_svc = $pkg_svc->part_svc;
900 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
901 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
902 $part_svc->{'Hash'}{'num_avail'} = $pkg_svc->quantity - $num_cust_svc;
903 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
905 } $self->part_pkg->pkg_svc;
908 push @part_svc, map {
910 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
911 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
912 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
913 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
915 } $self->extra_part_svc;
923 Returns a list of FS::part_svc objects corresponding to services in this
924 package which are still provisioned but not (any longer) available in the
932 my $pkgnum = $self->pkgnum;
933 my $pkgpart = $self->pkgpart;
936 'table' => 'part_svc',
938 'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
939 WHERE pkg_svc.svcpart = part_svc.svcpart
940 AND pkg_svc.pkgpart = $pkgpart
943 AND 0 < ( SELECT count(*)
945 LEFT JOIN cust_pkg using ( pkgnum )
946 WHERE cust_svc.svcpart = part_svc.svcpart
954 Returns a short status string for this package, currently:
960 =item one-time charge
975 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
977 return 'cancelled' if $self->get('cancel');
978 return 'suspended' if $self->susp;
979 return 'not yet billed' unless $self->setup;
980 return 'one-time charge' if $freq =~ /^(0|$)/;
986 Class method that returns the list of possible status strings for pacakges
987 (see L<the status method|/status>). For example:
989 @statuses = FS::cust_pkg->statuses();
993 tie my %statuscolor, 'Tie::IxHash',
994 'not yet billed' => '000000',
995 'one-time charge' => '000000',
996 'active' => '00CC00',
997 'suspended' => 'FF9900',
998 'cancelled' => 'FF0000',
1002 my $self = shift; #could be class...
1003 grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1004 # mayble split btw one-time vs. recur
1010 Returns a hex triplet color string for this package's status.
1016 $statuscolor{$self->status};
1021 Returns a list of lists, calling the label method for all services
1022 (see L<FS::cust_svc>) of this billing item.
1028 map { [ $_->label ] } $self->cust_svc;
1031 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1033 Like the labels method, but returns historical information on services that
1034 were active as of END_TIMESTAMP and (optionally) not cancelled before
1037 Returns a list of lists, calling the label method for all (historical) services
1038 (see L<FS::h_cust_svc>) of this billing item.
1044 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1047 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1049 Like h_labels, except returns a simple flat list, and shortens long
1050 (currently >5) lists of identical services to one line that lists the service
1051 label and the number of individual services rather than individual items.
1055 sub h_labels_short {
1059 #tie %labels, 'Tie::IxHash';
1060 push @{ $labels{$_->[0]} }, $_->[1]
1061 foreach $self->h_labels(@_);
1063 foreach my $label ( keys %labels ) {
1064 my @values = @{ $labels{$label} };
1065 my $num = scalar(@values);
1067 push @labels, "$label ($num)";
1069 push @labels, map { "$label: $_" } @values;
1079 Returns the parent customer object (see L<FS::cust_main>).
1085 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1088 =item seconds_since TIMESTAMP
1090 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1091 package have been online since TIMESTAMP, according to the session monitor.
1093 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1094 L<Time::Local> and L<Date::Parse> for conversion functions.
1099 my($self, $since) = @_;
1102 foreach my $cust_svc (
1103 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1105 $seconds += $cust_svc->seconds_since($since);
1112 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1114 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1115 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1118 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1119 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1125 sub seconds_since_sqlradacct {
1126 my($self, $start, $end) = @_;
1130 foreach my $cust_svc (
1132 my $part_svc = $_->part_svc;
1133 $part_svc->svcdb eq 'svc_acct'
1134 && scalar($part_svc->part_export('sqlradius'));
1137 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1144 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1146 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1147 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1151 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1152 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1157 sub attribute_since_sqlradacct {
1158 my($self, $start, $end, $attrib) = @_;
1162 foreach my $cust_svc (
1164 my $part_svc = $_->part_svc;
1165 $part_svc->svcdb eq 'svc_acct'
1166 && scalar($part_svc->part_export('sqlradius'));
1169 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1176 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1178 Transfers as many services as possible from this package to another package.
1180 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1181 object. The destination package must already exist.
1183 Services are moved only if the destination allows services with the correct
1184 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1185 this option with caution! No provision is made for export differences
1186 between the old and new service definitions. Probably only should be used
1187 when your exports for all service definitions of a given svcdb are identical.
1188 (attempt a transfer without it first, to move all possible svcpart-matching
1191 Any services that can't be moved remain in the original package.
1193 Returns an error, if there is one; otherwise, returns the number of services
1194 that couldn't be moved.
1199 my ($self, $dest_pkgnum, %opt) = @_;
1205 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1206 $dest = $dest_pkgnum;
1207 $dest_pkgnum = $dest->pkgnum;
1209 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1212 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1214 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1215 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1218 foreach my $cust_svc ($dest->cust_svc) {
1219 $target{$cust_svc->svcpart}--;
1222 my %svcpart2svcparts = ();
1223 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1224 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1225 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1226 next if exists $svcpart2svcparts{$svcpart};
1227 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1228 $svcpart2svcparts{$svcpart} = [
1230 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1232 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1233 'svcpart' => $_ } );
1235 $pkg_svc ? $pkg_svc->primary_svc : '',
1236 $pkg_svc ? $pkg_svc->quantity : 0,
1240 grep { $_ != $svcpart }
1242 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1244 warn "alternates for svcpart $svcpart: ".
1245 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1250 foreach my $cust_svc ($self->cust_svc) {
1251 if($target{$cust_svc->svcpart} > 0) {
1252 $target{$cust_svc->svcpart}--;
1253 my $new = new FS::cust_svc {
1254 svcnum => $cust_svc->svcnum,
1255 svcpart => $cust_svc->svcpart,
1256 pkgnum => $dest_pkgnum,
1258 my $error = $new->replace($cust_svc);
1259 return $error if $error;
1260 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1262 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1263 warn "alternates to consider: ".
1264 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1266 my @alternate = grep {
1267 warn "considering alternate svcpart $_: ".
1268 "$target{$_} available in new package\n"
1271 } @{$svcpart2svcparts{$cust_svc->svcpart}};
1273 warn "alternate(s) found\n" if $DEBUG;
1274 my $change_svcpart = $alternate[0];
1275 $target{$change_svcpart}--;
1276 my $new = new FS::cust_svc {
1277 svcnum => $cust_svc->svcnum,
1278 svcpart => $change_svcpart,
1279 pkgnum => $dest_pkgnum,
1281 my $error = $new->replace($cust_svc);
1282 return $error if $error;
1295 This method is deprecated. See the I<depend_jobnum> option to the insert and
1296 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1303 local $SIG{HUP} = 'IGNORE';
1304 local $SIG{INT} = 'IGNORE';
1305 local $SIG{QUIT} = 'IGNORE';
1306 local $SIG{TERM} = 'IGNORE';
1307 local $SIG{TSTP} = 'IGNORE';
1308 local $SIG{PIPE} = 'IGNORE';
1310 my $oldAutoCommit = $FS::UID::AutoCommit;
1311 local $FS::UID::AutoCommit = 0;
1314 foreach my $cust_svc ( $self->cust_svc ) {
1315 #false laziness w/svc_Common::insert
1316 my $svc_x = $cust_svc->svc_x;
1317 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1318 my $error = $part_export->export_insert($svc_x);
1320 $dbh->rollback if $oldAutoCommit;
1326 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1333 =head1 CLASS METHODS
1339 Returns an SQL expression identifying recurring packages.
1343 sub recurring_sql { "
1344 '0' != ( select freq from part_pkg
1345 where cust_pkg.pkgpart = part_pkg.pkgpart )
1350 Returns an SQL expression identifying one-time packages.
1355 '0' = ( select freq from part_pkg
1356 where cust_pkg.pkgpart = part_pkg.pkgpart )
1361 Returns an SQL expression identifying active packages.
1366 ". $_[0]->recurring_sql(). "
1367 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1368 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1373 Returns an SQL expression identifying inactive packages (one-time packages
1374 that are otherwise unsuspended/uncancelled).
1378 sub inactive_sql { "
1379 ". $_[0]->onetime_sql(). "
1380 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1381 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1387 Returns an SQL expression identifying suspended packages.
1391 sub suspended_sql { susp_sql(@_); }
1393 #$_[0]->recurring_sql(). ' AND '.
1395 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1396 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
1403 Returns an SQL exprression identifying cancelled packages.
1407 sub cancelled_sql { cancel_sql(@_); }
1409 #$_[0]->recurring_sql(). ' AND '.
1410 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1417 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
1419 CUSTNUM is a customer (see L<FS::cust_main>)
1421 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
1422 L<FS::part_pkg>) to order for this customer. Duplicates are of course
1425 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
1426 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
1427 new billing items. An error is returned if this is not possible (see
1428 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
1431 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
1432 newly-created cust_pkg objects.
1437 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
1439 my $conf = new FS::Conf;
1441 # Transactionize this whole mess
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;
1454 my $cust_main = qsearchs('cust_main', { custnum => $custnum });
1455 return "Customer not found: $custnum" unless $cust_main;
1457 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
1460 my $change = scalar(@old_cust_pkg) != 0;
1463 if ( scalar(@old_cust_pkg) == 1 ) {
1464 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
1465 $hash{'setup'} = time;
1468 # Create the new packages.
1469 foreach my $pkgpart (@$pkgparts) {
1470 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
1471 pkgpart => $pkgpart,
1474 $error = $cust_pkg->insert( 'change' => $change );
1476 $dbh->rollback if $oldAutoCommit;
1479 push @$return_cust_pkg, $cust_pkg;
1481 # $return_cust_pkg now contains refs to all of the newly
1484 # Transfer services and cancel old packages.
1485 foreach my $old_pkg (@old_cust_pkg) {
1487 foreach my $new_pkg (@$return_cust_pkg) {
1488 $error = $old_pkg->transfer($new_pkg);
1489 if ($error and $error == 0) {
1490 # $old_pkg->transfer failed.
1491 $dbh->rollback if $oldAutoCommit;
1496 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1497 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1498 foreach my $new_pkg (@$return_cust_pkg) {
1499 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
1500 if ($error and $error == 0) {
1501 # $old_pkg->transfer failed.
1502 $dbh->rollback if $oldAutoCommit;
1509 # Transfers were successful, but we went through all of the
1510 # new packages and still had services left on the old package.
1511 # We can't cancel the package under the circumstances, so abort.
1512 $dbh->rollback if $oldAutoCommit;
1513 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
1515 $error = $old_pkg->cancel( quiet=>1 );
1521 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1526 my ($self, %options) = @_;
1528 my $otaker = $FS::CurrentUser::CurrentUser->name;
1529 $otaker = $FS::CurrentUser::CurrentUser->username
1530 if (($otaker) eq "User, Legacy");
1532 my $cust_pkg_reason =
1533 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
1534 'reasonnum' => $options{'reason'},
1535 'otaker' => $otaker,
1536 'date' => $options{'date'}
1540 return $cust_pkg_reason->insert;
1543 =item set_usage USAGE_VALUE_HASHREF
1545 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
1546 to which they should be set (see L<FS::svc_acct>). Currently seconds,
1547 upbytes, downbytes, and totalbytes are appropriate keys.
1549 All svc_accts which are part of this package have their values reset.
1554 my ($self, $valueref) = @_;
1556 foreach my $cust_svc ($self->cust_svc){
1557 my $svc_x = $cust_svc->svc_x;
1558 $svc_x->set_usage($valueref)
1559 if $svc_x->can("set_usage");
1567 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
1569 In sub order, the @pkgparts array (passed by reference) is clobbered.
1571 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
1572 method to pass dates to the recur_prog expression, it should do so.
1574 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
1575 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
1576 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
1577 configuration values. Probably need a subroutine which decides what to do
1578 based on whether or not we've fetched the user yet, rather than a hash. See
1579 FS::UID and the TODO.
1581 Now that things are transactional should the check in the insert method be
1586 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
1587 L<FS::pkg_svc>, schema.html from the base documentation