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::option_Common 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($options{options} ? %{$options{options}} : ());
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,
322 $options{options} ? ${options{options}} : ()
325 $dbh->rollback if $oldAutoCommit;
329 #for prepaid packages,
330 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
331 foreach my $old_svc_acct ( @svc_acct ) {
332 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
333 my $s_error = $new_svc_acct->replace($old_svc_acct);
335 $dbh->rollback if $oldAutoCommit;
340 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
347 Checks all fields to make sure this is a valid billing item. If there is an
348 error, returns the error, otherwise returns false. Called by the insert and
357 $self->ut_numbern('pkgnum')
358 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
359 || $self->ut_numbern('pkgpart')
360 || $self->ut_numbern('setup')
361 || $self->ut_numbern('bill')
362 || $self->ut_numbern('susp')
363 || $self->ut_numbern('cancel')
365 return $error if $error;
367 if ( $self->reg_code ) {
369 unless ( grep { $self->pkgpart == $_->pkgpart }
370 map { $_->reg_code_pkg }
371 qsearchs( 'reg_code', { 'code' => $self->reg_code,
372 'agentnum' => $self->cust_main->agentnum })
374 return "Unknown registration code";
377 } elsif ( $self->promo_code ) {
380 qsearchs('part_pkg', {
381 'pkgpart' => $self->pkgpart,
382 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
384 return 'Unknown promotional code' unless $promo_part_pkg;
388 unless ( $disable_agentcheck ) {
390 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
391 my $pkgpart_href = $agent->pkgpart_hashref;
392 return "agent ". $agent->agentnum.
393 " can't purchase pkgpart ". $self->pkgpart
394 unless $pkgpart_href->{ $self->pkgpart };
397 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
398 return $error if $error;
402 $self->otaker(getotaker) unless $self->otaker;
403 $self->otaker =~ /^([\w\.\-]{0,16})$/ or return "Illegal otaker";
406 if ( $self->dbdef_table->column('manual_flag') ) {
407 $self->manual_flag('') if $self->manual_flag eq ' ';
408 $self->manual_flag =~ /^([01]?)$/
409 or return "Illegal manual_flag ". $self->manual_flag;
410 $self->manual_flag($1);
416 =item cancel [ OPTION => VALUE ... ]
418 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
419 in this package, then cancels the package itself (sets the cancel field to
422 Available options are: I<quiet>
424 I<quiet> can be set true to supress email cancellation notices.
426 If there is an error, returns the error, otherwise returns false.
431 my( $self, %options ) = @_;
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 if ($options{'reason'}) {
446 $error = $self->insert_reason( 'reason' => $options{'reason'} );
448 dbh->rollback if $oldAutoCommit;
449 return "Error inserting cust_pkg_reason: $error";
454 foreach my $cust_svc (
457 sort { $a->[1] <=> $b->[1] }
458 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
459 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
462 my $error = $cust_svc->cancel;
465 $dbh->rollback if $oldAutoCommit;
466 return "Error cancelling cust_svc: $error";
470 # Add a credit for remaining service
471 my $remaining_value = $self->calc_remain();
472 if ( $remaining_value > 0 ) {
473 my $error = $self->cust_main->credit(
475 'Credit for unused time on '. $self->part_pkg->pkg,
478 $dbh->rollback if $oldAutoCommit;
479 return "Error crediting customer \$$remaining_value for unused time on".
480 $self->part_pkg->pkg. ": $error";
484 unless ( $self->getfield('cancel') ) {
485 my %hash = $self->hash;
486 $hash{'cancel'} = time;
487 my $new = new FS::cust_pkg ( \%hash );
488 $error = $new->replace( $self, options => { $self->options } );
490 $dbh->rollback if $oldAutoCommit;
495 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
497 my $conf = new FS::Conf;
498 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
499 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
500 my $conf = new FS::Conf;
501 my $error = send_email(
502 'from' => $conf->config('invoice_from'),
503 'to' => \@invoicing_list,
504 'subject' => $conf->config('cancelsubject'),
505 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
507 #should this do something on errors?
516 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
517 package, then suspends the package itself (sets the susp field to now).
519 If there is an error, returns the error, otherwise returns false.
524 my( $self, %options ) = @_;
527 local $SIG{HUP} = 'IGNORE';
528 local $SIG{INT} = 'IGNORE';
529 local $SIG{QUIT} = 'IGNORE';
530 local $SIG{TERM} = 'IGNORE';
531 local $SIG{TSTP} = 'IGNORE';
532 local $SIG{PIPE} = 'IGNORE';
534 my $oldAutoCommit = $FS::UID::AutoCommit;
535 local $FS::UID::AutoCommit = 0;
538 if ($options{'reason'}) {
539 $error = $self->insert_reason( 'reason' => $options{'reason'} );
541 dbh->rollback if $oldAutoCommit;
542 return "Error inserting cust_pkg_reason: $error";
546 foreach my $cust_svc (
547 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
549 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
551 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
552 $dbh->rollback if $oldAutoCommit;
553 return "Illegal svcdb value in part_svc!";
556 require "FS/$svcdb.pm";
558 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
560 $error = $svc->suspend;
562 $dbh->rollback if $oldAutoCommit;
569 unless ( $self->getfield('susp') ) {
570 my %hash = $self->hash;
571 $hash{'susp'} = time;
572 my $new = new FS::cust_pkg ( \%hash );
573 $error = $new->replace( $self, options => { $self->options } );
575 $dbh->rollback if $oldAutoCommit;
580 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
585 =item unsuspend [ OPTION => VALUE ... ]
587 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
588 package, then unsuspends the package itself (clears the susp field).
590 Available options are: I<adjust_next_bill>.
592 I<adjust_next_bill> can be set true to adjust the next bill date forward by
593 the amount of time the account was inactive. This was set true by default
594 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
595 explicitly requested. Price plans for which this makes sense (anniversary-date
596 based than prorate or subscription) could have an option to enable this
599 If there is an error, returns the error, otherwise returns false.
604 my( $self, %opt ) = @_;
607 local $SIG{HUP} = 'IGNORE';
608 local $SIG{INT} = 'IGNORE';
609 local $SIG{QUIT} = 'IGNORE';
610 local $SIG{TERM} = 'IGNORE';
611 local $SIG{TSTP} = 'IGNORE';
612 local $SIG{PIPE} = 'IGNORE';
614 my $oldAutoCommit = $FS::UID::AutoCommit;
615 local $FS::UID::AutoCommit = 0;
618 foreach my $cust_svc (
619 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
621 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
623 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
624 $dbh->rollback if $oldAutoCommit;
625 return "Illegal svcdb value in part_svc!";
628 require "FS/$svcdb.pm";
630 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
632 $error = $svc->unsuspend;
634 $dbh->rollback if $oldAutoCommit;
641 unless ( ! $self->getfield('susp') ) {
642 my %hash = $self->hash;
643 my $inactive = time - $hash{'susp'};
645 my $conf = new FS::Conf;
647 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
648 if ( $opt{'adjust_next_bill'}
649 || $conf->config('unsuspend-always_adjust_next_bill_date') )
650 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
653 my $new = new FS::cust_pkg ( \%hash );
654 $error = $new->replace( $self, options => { $self->options } );
656 $dbh->rollback if $oldAutoCommit;
661 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
668 Returns the last bill date, or if there is no last bill date, the setup date.
669 Useful for billing metered services.
675 if ( $self->dbdef_table->column('last_bill') ) {
676 return $self->setfield('last_bill', $_[0]) if @_;
677 return $self->getfield('last_bill') if $self->getfield('last_bill');
679 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
680 'edate' => $self->bill, } );
681 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
686 Returns the most recent FS::reason associated with the package.
692 my $cust_pkg_reason = qsearchs( {
693 'table' => 'cust_pkg_reason',
694 'hashref' => { 'pkgnum' => $self->pkgnum, },
695 'extra_sql'=> 'ORDER BY date DESC',
697 qsearchs ( 'reason', { 'reasonnum' => $cust_pkg_reason->reasonnum } )
703 Returns the definition for this billing item, as an FS::part_pkg object (see
710 #exists( $self->{'_pkgpart'} )
712 ? $self->{'_pkgpart'}
713 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
718 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
725 $self->part_pkg->calc_setup($self, @_);
730 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
737 $self->part_pkg->calc_recur($self, @_);
742 Calls the I<calc_remain> of the FS::part_pkg object associated with this
749 $self->part_pkg->calc_remain($self, @_);
754 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
761 $self->part_pkg->calc_cancel($self, @_);
766 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
772 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
775 =item cust_svc [ SVCPART ]
777 Returns the services for this package, as FS::cust_svc objects (see
778 L<FS::cust_svc>). If a svcpart is specified, return only the matching
787 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
788 'svcpart' => shift, } );
791 #if ( $self->{'_svcnum'} ) {
792 # values %{ $self->{'_svcnum'}->cache };
794 $self->_sort_cust_svc(
795 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
801 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
803 Returns historical services for this package created before END TIMESTAMP and
804 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
805 (see L<FS::h_cust_svc>).
812 $self->_sort_cust_svc(
813 [ qsearch( 'h_cust_svc',
814 { 'pkgnum' => $self->pkgnum, },
815 FS::h_cust_svc->sql_h_search(@_),
822 my( $self, $arrayref ) = @_;
825 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
827 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
828 'svcpart' => $_->svcpart } );
830 $pkg_svc ? $pkg_svc->primary_svc : '',
831 $pkg_svc ? $pkg_svc->quantity : 0,
838 =item num_cust_svc [ SVCPART ]
840 Returns the number of provisioned services for this package. If a svcpart is
841 specified, counts only the matching services.
847 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
848 $sql .= ' AND svcpart = ?' if @_;
849 my $sth = dbh->prepare($sql) or die dbh->errstr;
850 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
851 $sth->fetchrow_arrayref->[0];
854 =item available_part_svc
856 Returns a list of FS::part_svc objects representing services included in this
857 package but not yet provisioned. Each FS::part_svc object also has an extra
858 field, I<num_avail>, which specifies the number of available services.
862 sub available_part_svc {
864 grep { $_->num_avail > 0 }
866 my $part_svc = $_->part_svc;
867 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
868 $_->quantity - $self->num_cust_svc($_->svcpart);
871 $self->part_pkg->pkg_svc;
876 Returns a list of FS::part_svc objects representing provisioned and available
877 services included in this package. Each FS::part_svc object also has the
878 following extra fields:
882 =item num_cust_svc (count)
884 =item num_avail (quantity - count)
886 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
889 label -> ($cust_svc->label)[1]
898 #XXX some sort of sort order besides numeric by svcpart...
899 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
901 my $part_svc = $pkg_svc->part_svc;
902 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
903 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
904 $part_svc->{'Hash'}{'num_avail'} = $pkg_svc->quantity - $num_cust_svc;
905 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
907 } $self->part_pkg->pkg_svc;
910 push @part_svc, map {
912 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
913 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
914 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
915 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
917 } $self->extra_part_svc;
925 Returns a list of FS::part_svc objects corresponding to services in this
926 package which are still provisioned but not (any longer) available in the
934 my $pkgnum = $self->pkgnum;
935 my $pkgpart = $self->pkgpart;
938 'table' => 'part_svc',
940 'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
941 WHERE pkg_svc.svcpart = part_svc.svcpart
942 AND pkg_svc.pkgpart = $pkgpart
945 AND 0 < ( SELECT count(*)
947 LEFT JOIN cust_pkg using ( pkgnum )
948 WHERE cust_svc.svcpart = part_svc.svcpart
956 Returns a short status string for this package, currently:
962 =item one-time charge
977 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
979 return 'cancelled' if $self->get('cancel');
980 return 'suspended' if $self->susp;
981 return 'not yet billed' unless $self->setup;
982 return 'one-time charge' if $freq =~ /^(0|$)/;
988 Class method that returns the list of possible status strings for pacakges
989 (see L<the status method|/status>). For example:
991 @statuses = FS::cust_pkg->statuses();
995 tie my %statuscolor, 'Tie::IxHash',
996 'not yet billed' => '000000',
997 'one-time charge' => '000000',
998 'active' => '00CC00',
999 'suspended' => 'FF9900',
1000 'cancelled' => 'FF0000',
1004 my $self = shift; #could be class...
1005 grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1006 # mayble split btw one-time vs. recur
1012 Returns a hex triplet color string for this package's status.
1018 $statuscolor{$self->status};
1023 Returns a list of lists, calling the label method for all services
1024 (see L<FS::cust_svc>) of this billing item.
1030 map { [ $_->label ] } $self->cust_svc;
1033 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1035 Like the labels method, but returns historical information on services that
1036 were active as of END_TIMESTAMP and (optionally) not cancelled before
1039 Returns a list of lists, calling the label method for all (historical) services
1040 (see L<FS::h_cust_svc>) of this billing item.
1046 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1049 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1051 Like h_labels, except returns a simple flat list, and shortens long
1052 (currently >5) lists of identical services to one line that lists the service
1053 label and the number of individual services rather than individual items.
1057 sub h_labels_short {
1061 #tie %labels, 'Tie::IxHash';
1062 push @{ $labels{$_->[0]} }, $_->[1]
1063 foreach $self->h_labels(@_);
1065 foreach my $label ( keys %labels ) {
1066 my @values = @{ $labels{$label} };
1067 my $num = scalar(@values);
1069 push @labels, "$label ($num)";
1071 push @labels, map { "$label: $_" } @values;
1081 Returns the parent customer object (see L<FS::cust_main>).
1087 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1090 =item seconds_since TIMESTAMP
1092 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1093 package have been online since TIMESTAMP, according to the session monitor.
1095 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1096 L<Time::Local> and L<Date::Parse> for conversion functions.
1101 my($self, $since) = @_;
1104 foreach my $cust_svc (
1105 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1107 $seconds += $cust_svc->seconds_since($since);
1114 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1116 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1117 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1120 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1121 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1127 sub seconds_since_sqlradacct {
1128 my($self, $start, $end) = @_;
1132 foreach my $cust_svc (
1134 my $part_svc = $_->part_svc;
1135 $part_svc->svcdb eq 'svc_acct'
1136 && scalar($part_svc->part_export('sqlradius'));
1139 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1146 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1148 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1149 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1153 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1154 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1159 sub attribute_since_sqlradacct {
1160 my($self, $start, $end, $attrib) = @_;
1164 foreach my $cust_svc (
1166 my $part_svc = $_->part_svc;
1167 $part_svc->svcdb eq 'svc_acct'
1168 && scalar($part_svc->part_export('sqlradius'));
1171 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1178 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1180 Transfers as many services as possible from this package to another package.
1182 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1183 object. The destination package must already exist.
1185 Services are moved only if the destination allows services with the correct
1186 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1187 this option with caution! No provision is made for export differences
1188 between the old and new service definitions. Probably only should be used
1189 when your exports for all service definitions of a given svcdb are identical.
1190 (attempt a transfer without it first, to move all possible svcpart-matching
1193 Any services that can't be moved remain in the original package.
1195 Returns an error, if there is one; otherwise, returns the number of services
1196 that couldn't be moved.
1201 my ($self, $dest_pkgnum, %opt) = @_;
1207 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1208 $dest = $dest_pkgnum;
1209 $dest_pkgnum = $dest->pkgnum;
1211 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1214 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1216 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1217 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1220 foreach my $cust_svc ($dest->cust_svc) {
1221 $target{$cust_svc->svcpart}--;
1224 my %svcpart2svcparts = ();
1225 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1226 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1227 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1228 next if exists $svcpart2svcparts{$svcpart};
1229 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1230 $svcpart2svcparts{$svcpart} = [
1232 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1234 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1235 'svcpart' => $_ } );
1237 $pkg_svc ? $pkg_svc->primary_svc : '',
1238 $pkg_svc ? $pkg_svc->quantity : 0,
1242 grep { $_ != $svcpart }
1244 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1246 warn "alternates for svcpart $svcpart: ".
1247 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1252 foreach my $cust_svc ($self->cust_svc) {
1253 if($target{$cust_svc->svcpart} > 0) {
1254 $target{$cust_svc->svcpart}--;
1255 my $new = new FS::cust_svc {
1256 svcnum => $cust_svc->svcnum,
1257 svcpart => $cust_svc->svcpart,
1258 pkgnum => $dest_pkgnum,
1260 my $error = $new->replace($cust_svc);
1261 return $error if $error;
1262 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1264 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1265 warn "alternates to consider: ".
1266 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1268 my @alternate = grep {
1269 warn "considering alternate svcpart $_: ".
1270 "$target{$_} available in new package\n"
1273 } @{$svcpart2svcparts{$cust_svc->svcpart}};
1275 warn "alternate(s) found\n" if $DEBUG;
1276 my $change_svcpart = $alternate[0];
1277 $target{$change_svcpart}--;
1278 my $new = new FS::cust_svc {
1279 svcnum => $cust_svc->svcnum,
1280 svcpart => $change_svcpart,
1281 pkgnum => $dest_pkgnum,
1283 my $error = $new->replace($cust_svc);
1284 return $error if $error;
1297 This method is deprecated. See the I<depend_jobnum> option to the insert and
1298 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1305 local $SIG{HUP} = 'IGNORE';
1306 local $SIG{INT} = 'IGNORE';
1307 local $SIG{QUIT} = 'IGNORE';
1308 local $SIG{TERM} = 'IGNORE';
1309 local $SIG{TSTP} = 'IGNORE';
1310 local $SIG{PIPE} = 'IGNORE';
1312 my $oldAutoCommit = $FS::UID::AutoCommit;
1313 local $FS::UID::AutoCommit = 0;
1316 foreach my $cust_svc ( $self->cust_svc ) {
1317 #false laziness w/svc_Common::insert
1318 my $svc_x = $cust_svc->svc_x;
1319 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1320 my $error = $part_export->export_insert($svc_x);
1322 $dbh->rollback if $oldAutoCommit;
1328 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1335 =head1 CLASS METHODS
1341 Returns an SQL expression identifying recurring packages.
1345 sub recurring_sql { "
1346 '0' != ( select freq from part_pkg
1347 where cust_pkg.pkgpart = part_pkg.pkgpart )
1352 Returns an SQL expression identifying one-time packages.
1357 '0' = ( select freq from part_pkg
1358 where cust_pkg.pkgpart = part_pkg.pkgpart )
1363 Returns an SQL expression identifying active packages.
1368 ". $_[0]->recurring_sql(). "
1369 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1370 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1375 Returns an SQL expression identifying inactive packages (one-time packages
1376 that are otherwise unsuspended/uncancelled).
1380 sub inactive_sql { "
1381 ". $_[0]->onetime_sql(). "
1382 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1383 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1389 Returns an SQL expression identifying suspended packages.
1393 sub suspended_sql { susp_sql(@_); }
1395 #$_[0]->recurring_sql(). ' AND '.
1397 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1398 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
1405 Returns an SQL exprression identifying cancelled packages.
1409 sub cancelled_sql { cancel_sql(@_); }
1411 #$_[0]->recurring_sql(). ' AND '.
1412 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1419 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
1421 CUSTNUM is a customer (see L<FS::cust_main>)
1423 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
1424 L<FS::part_pkg>) to order for this customer. Duplicates are of course
1427 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
1428 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
1429 new billing items. An error is returned if this is not possible (see
1430 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
1433 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
1434 newly-created cust_pkg objects.
1439 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
1441 my $conf = new FS::Conf;
1443 # Transactionize this whole mess
1444 local $SIG{HUP} = 'IGNORE';
1445 local $SIG{INT} = 'IGNORE';
1446 local $SIG{QUIT} = 'IGNORE';
1447 local $SIG{TERM} = 'IGNORE';
1448 local $SIG{TSTP} = 'IGNORE';
1449 local $SIG{PIPE} = 'IGNORE';
1451 my $oldAutoCommit = $FS::UID::AutoCommit;
1452 local $FS::UID::AutoCommit = 0;
1456 my $cust_main = qsearchs('cust_main', { custnum => $custnum });
1457 return "Customer not found: $custnum" unless $cust_main;
1459 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
1462 my $change = scalar(@old_cust_pkg) != 0;
1465 if ( scalar(@old_cust_pkg) == 1 ) {
1466 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
1467 $hash{'setup'} = time;
1470 # Create the new packages.
1471 foreach my $pkgpart (@$pkgparts) {
1472 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
1473 pkgpart => $pkgpart,
1476 $error = $cust_pkg->insert( 'change' => $change );
1478 $dbh->rollback if $oldAutoCommit;
1481 push @$return_cust_pkg, $cust_pkg;
1483 # $return_cust_pkg now contains refs to all of the newly
1486 # Transfer services and cancel old packages.
1487 foreach my $old_pkg (@old_cust_pkg) {
1489 foreach my $new_pkg (@$return_cust_pkg) {
1490 $error = $old_pkg->transfer($new_pkg);
1491 if ($error and $error == 0) {
1492 # $old_pkg->transfer failed.
1493 $dbh->rollback if $oldAutoCommit;
1498 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1499 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1500 foreach my $new_pkg (@$return_cust_pkg) {
1501 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
1502 if ($error and $error == 0) {
1503 # $old_pkg->transfer failed.
1504 $dbh->rollback if $oldAutoCommit;
1511 # Transfers were successful, but we went through all of the
1512 # new packages and still had services left on the old package.
1513 # We can't cancel the package under the circumstances, so abort.
1514 $dbh->rollback if $oldAutoCommit;
1515 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
1517 $error = $old_pkg->cancel( quiet=>1 );
1523 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1528 my ($self, %options) = @_;
1530 my $otaker = $FS::CurrentUser::CurrentUser->name;
1531 $otaker = $FS::CurrentUser::CurrentUser->username
1532 if (($otaker) eq "User, Legacy");
1534 my $cust_pkg_reason =
1535 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
1536 'reasonnum' => $options{'reason'},
1537 'otaker' => $otaker,
1538 'date' => $options{'date'}
1542 return $cust_pkg_reason->insert;
1545 =item set_usage USAGE_VALUE_HASHREF
1547 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
1548 to which they should be set (see L<FS::svc_acct>). Currently seconds,
1549 upbytes, downbytes, and totalbytes are appropriate keys.
1551 All svc_accts which are part of this package have their values reset.
1556 my ($self, $valueref) = @_;
1558 foreach my $cust_svc ($self->cust_svc){
1559 my $svc_x = $cust_svc->svc_x;
1560 $svc_x->set_usage($valueref)
1561 if $svc_x->can("set_usage");
1569 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
1571 In sub order, the @pkgparts array (passed by reference) is clobbered.
1573 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
1574 method to pass dates to the recur_prog expression, it should do so.
1576 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
1577 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
1578 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
1579 configuration values. Probably need a subroutine which decides what to do
1580 based on whether or not we've fetched the user yet, rather than a hash. See
1581 FS::UID and the TODO.
1583 Now that things are transactional should the check in the insert method be
1588 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
1589 L<FS::pkg_svc>, schema.html from the base documentation